mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
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:
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user