diff --git a/src/openhuman/migrations/mod.rs b/src/openhuman/migrations/mod.rs index a245c2140..136f24659 100644 --- a/src/openhuman/migrations/mod.rs +++ b/src/openhuman/migrations/mod.rs @@ -25,6 +25,7 @@ use crate::openhuman::config::Config; mod expand_autonomy_defaults; mod migrate_legacy_embedding_provider; +mod normalize_default_model_tier; mod phase_out_profile_md; mod reconcile_orphaned_providers; mod remove_write_auto_approve; @@ -33,7 +34,7 @@ mod retire_chat_v1_model; mod unify_ai_provider_settings; /// Current target schema version. Bumped alongside every new migration. -pub const CURRENT_SCHEMA_VERSION: u32 = 7; +pub const CURRENT_SCHEMA_VERSION: u32 = 8; /// Run any migrations whose `schema_version` gate hasn't yet been /// crossed for this workspace. @@ -381,6 +382,49 @@ pub async fn run_pending(config: &mut Config) { } } } + + // 7 -> 8: retire the two stale OpenHuman reasoning-tier `default_model` + // defaults to `chat-v1`. `reasoning-v1` (a former DEFAULT_MODEL) and the + // deprecated `reasoning-quick-v1` alias were the persisted default for older + // builds and drive the implicit managed turns (triage, the subconscious tick, + // escalation base, chat-fallback) onto a stale tier, since app updates never + // refresh `default_model`. Only those two values are rewritten — `default_model` + // round-trips arbitrary custom/BYOK ids (config-mutation contract), so anything + // else (including `chat-v1` and `None`) is left untouched. Guard on `== 7` so + // an earlier failed step doesn't get skipped. + if config.schema_version == 7 { + let previous_default_model = config.default_model.clone(); + match normalize_default_model_tier::run(config) { + Ok(stats) => { + let previous_version = config.schema_version; + config.schema_version = 8; + if let Err(err) = config.save().await { + // Roll back BOTH the version and the mutated `default_model` + // so a failed save doesn't leave `load_or_init` returning a + // half-migrated in-memory config; next launch retries. + config.default_model = previous_default_model; + config.schema_version = previous_version; + log::warn!( + "[migrations] normalize_default_model_tier ran but config.save failed: \ + {err:#} — rolled in-memory schema_version back to {previous_version}, \ + will retry on next launch" + ); + return; + } + log::info!( + "[migrations] schema_version bumped to 8 (normalize_default_model_tier \ + default_model_normalized={})", + stats.default_model_normalized, + ); + } + Err(err) => { + log::warn!( + "[migrations] normalize_default_model_tier failed: {err:#} — \ + will retry on next launch" + ); + } + } + } } #[cfg(test)] diff --git a/src/openhuman/migrations/mod_tests.rs b/src/openhuman/migrations/mod_tests.rs index d4cadbd3d..0f7e5a937 100644 --- a/src/openhuman/migrations/mod_tests.rs +++ b/src/openhuman/migrations/mod_tests.rs @@ -113,8 +113,8 @@ async fn run_pending_runs_phase_out_when_version_zero() { let on_disk = std::fs::read_to_string(&config.config_path).unwrap(); assert!( - on_disk.contains("schema_version = 7"), - "saved config.toml must record schema_version=7, got:\n{on_disk}" + on_disk.contains("schema_version = 8"), + "saved config.toml must record schema_version=8, got:\n{on_disk}" ); } @@ -129,7 +129,7 @@ async fn run_pending_bumps_version_on_fresh_install() { assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); let on_disk = std::fs::read_to_string(&config.config_path).unwrap(); - assert!(on_disk.contains("schema_version = 7")); + assert!(on_disk.contains("schema_version = 8")); } #[tokio::test] @@ -149,14 +149,14 @@ async fn run_pending_migrates_fastembed_to_managed_without_local_ollama() { run_pending(&mut config).await; - assert_eq!(config.schema_version, 7); + assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); assert_eq!( config.memory.embedding_provider, "managed", "no reachable local Ollama ⇒ managed cloud target" ); assert_eq!(config.memory.embedding_dimensions, 1024); let on_disk = std::fs::read_to_string(&config.config_path).unwrap(); - assert!(on_disk.contains("schema_version = 7")); + assert!(on_disk.contains("schema_version = 8")); } #[tokio::test] @@ -181,6 +181,33 @@ async fn run_pending_rolls_back_schema_version_when_save_fails() { ); } +#[tokio::test] +async fn run_pending_v7_to_v8_rolls_back_default_model_when_save_fails() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("workspace")).unwrap(); + + let mut config = config_in(&tmp); + config.schema_version = 7; + config.default_model = Some("reasoning-v1".to_string()); + // Force save() to fail after normalize_default_model_tier mutates + // default_model, so the 7->8 rollback path runs. + let blocker = tmp.path().join("blocker"); + fs::write(&blocker, "not a directory").unwrap(); + config.config_path = blocker.join("nested").join("config.toml"); + + run_pending(&mut config).await; + + assert_eq!( + config.schema_version, 7, + "save failed → schema_version must roll back to 7" + ); + assert_eq!( + config.default_model.as_deref(), + Some("reasoning-v1"), + "save failed → default_model must roll back to its pre-migration value" + ); +} + #[tokio::test] async fn run_pending_is_a_no_op_on_second_invocation() { let tmp = TempDir::new().unwrap(); @@ -280,8 +307,8 @@ async fn run_pending_expands_autonomy_defaults_from_v3() { // On-disk config must reflect the new schema_version. let on_disk = fs::read_to_string(&config.config_path).unwrap(); assert!( - on_disk.contains("schema_version = 7"), - "saved config.toml must record schema_version=7, got:\n{on_disk}" + on_disk.contains("schema_version = 8"), + "saved config.toml must record schema_version=8, got:\n{on_disk}" ); } @@ -313,8 +340,8 @@ async fn run_pending_v4_to_v5_removes_write_tools_from_auto_approve() { let on_disk = fs::read_to_string(&config.config_path).unwrap(); assert!( - on_disk.contains("schema_version = 7"), - "saved config.toml must record schema_version=7, got:\n{on_disk}" + on_disk.contains("schema_version = 8"), + "saved config.toml must record schema_version=8, got:\n{on_disk}" ); } @@ -349,8 +376,8 @@ async fn run_pending_v5_to_v6_repairs_http_request_limits() { // The version bump must be persisted to disk too. let on_disk = fs::read_to_string(&config.config_path).unwrap(); assert!( - on_disk.contains("schema_version = 7"), - "saved config.toml must record schema_version=7, got:\n{on_disk}" + on_disk.contains("schema_version = 8"), + "saved config.toml must record schema_version=8, got:\n{on_disk}" ); } @@ -384,8 +411,8 @@ async fn run_pending_v5_to_v6_reconciles_orphaned_providers() { let on_disk = fs::read_to_string(&config.config_path).unwrap(); assert!( - on_disk.contains("schema_version = 7"), - "saved config.toml must record schema_version=7, got:\n{on_disk}" + on_disk.contains("schema_version = 8"), + "saved config.toml must record schema_version=8, got:\n{on_disk}" ); } diff --git a/src/openhuman/migrations/normalize_default_model_tier.rs b/src/openhuman/migrations/normalize_default_model_tier.rs new file mode 100644 index 000000000..5d65c0384 --- /dev/null +++ b/src/openhuman/migrations/normalize_default_model_tier.rs @@ -0,0 +1,158 @@ +//! Migration 7 → 8: retire the stale OpenHuman reasoning-tier `default_model` +//! defaults, rewriting them to the canonical `chat-v1` tier. +//! +//! `config.default_model` selects the managed-backend tier for the implicit +//! turns that read it (triage classification, the subconscious cloud tick, +//! escalation base, chat-fallback). Older builds shipped a heavier default: +//! `reasoning-v1` was the `DEFAULT_MODEL` constant for a window in 2026, and the +//! `reasoning-quick-v1` alias (which resolves to `chat-v1`) was the default for +//! another window. App updates never refresh a persisted `default_model`, so +//! those workspaces still drive background turns onto the stale tier. +//! +//! This migration rewrites **only** those two known stale OpenHuman tier values +//! to `chat-v1`. It deliberately leaves every other value untouched — +//! `default_model` round-trips arbitrary strings (custom/BYOK model ids set via +//! `config.update_model_settings` or `OPENHUMAN_MODEL`, plus the current +//! `chat-v1` default), and clobbering those would break the config-mutation +//! contract (see `config_*_e2e` round-trip tests). The only substantive change +//! is `reasoning-v1` → `chat-v1`; `reasoning-quick-v1` → `chat-v1` is slug +//! canonicalization (same upstream model). +//! +//! ## Behaviour +//! +//! - Pure in-memory mutation of `Config`. The caller (`migrations::run_pending`) +//! persists the result via `Config::save()` and bumps `schema_version`. +//! - Idempotent: re-running on a non-stale value is a no-op. +//! - Touches nothing other than `default_model`, and only for the two stale tiers. + +use crate::openhuman::config::{ + Config, MODEL_CHAT_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, +}; + +/// Counters returned by [`run`] for diagnostics. +#[derive(Debug, Default, Clone)] +pub struct MigrationStats { + /// `true` when a stale reasoning-tier `default_model` was rewritten to `chat-v1`. + pub default_model_normalized: bool, +} + +/// Retire a stale reasoning-tier `default_model` to `chat-v1` in place. +/// +/// Synchronous — pure config mutation, no I/O. Caller persists via +/// `Config::save()` once `schema_version` is also bumped. +pub fn run(config: &mut Config) -> anyhow::Result { + let mut stats = MigrationStats::default(); + + // Only the two known stale OpenHuman tiers are rewritten. Trim so a padded + // `" reasoning-v1 "` is still caught, but never touch arbitrary/custom values. + let is_stale_reasoning_tier = config.default_model.as_deref().is_some_and(|model| { + let trimmed = model.trim(); + trimmed == MODEL_REASONING_V1 || trimmed == MODEL_REASONING_QUICK_V1 + }); + + if is_stale_reasoning_tier { + log::info!( + "[migrations][normalize-default-model] stale default_model {:?} rewritten to \ + '{MODEL_CHAT_V1}'", + config.default_model + ); + config.default_model = Some(MODEL_CHAT_V1.to_string()); + stats.default_model_normalized = true; + } else { + log::debug!( + "[migrations][normalize-default-model] default_model {:?} is not a stale reasoning \ + tier — leaving unchanged", + config.default_model + ); + } + + Ok(stats) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::{Config, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1}; + + #[test] + fn rewrites_stale_reasoning_v1_to_chat_v1() { + // `reasoning-v1` is a stale former DEFAULT_MODEL — the substantive change. + let mut config = Config::default(); + config.default_model = Some(MODEL_REASONING_V1.to_string()); + + let stats = run(&mut config).expect("migration should succeed"); + + assert!(stats.default_model_normalized); + assert_eq!(config.default_model.as_deref(), Some(MODEL_CHAT_V1)); + } + + #[test] + fn rewrites_padded_reasoning_v1_to_chat_v1() { + let mut config = Config::default(); + config.default_model = Some(" reasoning-v1 ".to_string()); + + let stats = run(&mut config).expect("migration should succeed"); + + assert!(stats.default_model_normalized); + assert_eq!(config.default_model.as_deref(), Some(MODEL_CHAT_V1)); + } + + #[test] + fn rewrites_deprecated_reasoning_quick_v1_alias_to_chat_v1() { + // `reasoning-quick-v1` resolves to `chat-v1`; canonicalize the slug. + let mut config = Config::default(); + config.default_model = Some(MODEL_REASONING_QUICK_V1.to_string()); + + let stats = run(&mut config).expect("migration should succeed"); + + assert!(stats.default_model_normalized); + assert_eq!(config.default_model.as_deref(), Some(MODEL_CHAT_V1)); + } + + #[test] + fn leaves_chat_v1_unchanged() { + let mut config = Config::default(); + config.default_model = Some(MODEL_CHAT_V1.to_string()); + + let stats = run(&mut config).expect("migration should succeed"); + + assert!(!stats.default_model_normalized); + assert_eq!(config.default_model.as_deref(), Some(MODEL_CHAT_V1)); + } + + #[test] + fn leaves_arbitrary_custom_value_unchanged() { + // `default_model` round-trips custom/BYOK ids; the migration must not + // clobber them (config-mutation contract; config_*_e2e round-trip tests). + let mut config = Config::default(); + config.default_model = Some("worker-a-updated".to_string()); + + let stats = run(&mut config).expect("migration should succeed"); + + assert!(!stats.default_model_normalized); + assert_eq!(config.default_model.as_deref(), Some("worker-a-updated")); + } + + #[test] + fn leaves_other_known_tier_unchanged() { + // An explicit non-reasoning tier (e.g. agentic) is a deliberate value. + let mut config = Config::default(); + config.default_model = Some("agentic-v1".to_string()); + + let stats = run(&mut config).expect("migration should succeed"); + + assert!(!stats.default_model_normalized); + assert_eq!(config.default_model.as_deref(), Some("agentic-v1")); + } + + #[test] + fn leaves_none_unchanged() { + let mut config = Config::default(); + config.default_model = None; + + let stats = run(&mut config).expect("migration should succeed"); + + assert!(!stats.default_model_normalized); + assert_eq!(config.default_model, None); + } +}