fix(migrations): normalize stale default_model to chat-v1 (schema 7->8) (#4080)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-06-24 15:04:29 -07:00
committed by GitHub
co-authored by Claude
parent 004803628e
commit 0e8caf03fd
3 changed files with 243 additions and 14 deletions
+45 -1
View File
@@ -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)]
+40 -13
View File
@@ -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}"
);
}
@@ -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<MigrationStats> {
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);
}
}