706f33d424
The shared checkout serves every profile, but hermes update migrated only the active profile's config.yaml. Siblings kept their old _config_version until their (correctly restarted, post-#91378) gateway hit a config shape the new code couldn't read — the last unabsorbed substance from the Phase-2 restart-swarm audit (#20438 earliest, 2026 field repro on #79048: sibling at v33 vs v37). _migrate_sibling_profile_configs(): per sibling home, scope config reads/writes via the context-local HERMES_HOME override (ContextVar — never os.environ), check version, run the NON-INTERACTIVE safe migration; prompt-requiring settings stay for the profile's own next interactive session (same contract as gateway-mode). Broken profiles are skipped without blocking the sweep; override always reset. Sabotage-verified; live E2E in a fresh process with real drifted config files: v12→v38 and v25→v38 on disk, provider preserved, the documented #81946 personality-reset migration correctly applied to siblings too, never-configured profile untouched, active home untouched, second run idempotent.
125 lines
4.5 KiB
Python
125 lines
4.5 KiB
Python
"""Fleet-wide config migration (#91277 Phase 2 — #20438/#54926/#79048 class).
|
|
|
|
`hermes update` migrated only the active profile's config.yaml; sibling
|
|
profiles silently drifted config versions until their gateway hit a config
|
|
the new code couldn't read. `_migrate_sibling_profile_configs()` runs the
|
|
same non-interactive safe migration for every sibling home, scoped via the
|
|
context-local HERMES_HOME override.
|
|
|
|
These tests use REAL config files on disk and the REAL migration pipeline —
|
|
only the profile-root location is pointed at tmp_path.
|
|
"""
|
|
|
|
import yaml
|
|
from pathlib import Path
|
|
|
|
import hermes_cli.update_cmd as update_cmd
|
|
|
|
|
|
def _write_profile(root: Path, name: str, version: int) -> Path:
|
|
home = root / name
|
|
home.mkdir(parents=True)
|
|
(home / "config.yaml").write_text(
|
|
yaml.safe_dump({"_config_version": version, "model": {"provider": "nous"}}),
|
|
encoding="utf-8",
|
|
)
|
|
return home
|
|
|
|
|
|
def _latest_version() -> int:
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
return int(DEFAULT_CONFIG["_config_version"])
|
|
|
|
|
|
def _setup(monkeypatch, tmp_path, active_home: Path):
|
|
import hermes_cli.profiles as profiles_mod
|
|
|
|
monkeypatch.setattr(profiles_mod, "_get_profiles_root", lambda: tmp_path / "profiles")
|
|
import hermes_constants
|
|
|
|
monkeypatch.setattr(
|
|
hermes_constants, "get_process_hermes_home", lambda: active_home
|
|
)
|
|
monkeypatch.setattr(
|
|
update_cmd, "_reload_config_modules", lambda: None
|
|
) # module reload is orthogonal here; the real one re-imports from disk
|
|
|
|
|
|
def test_sibling_behind_is_migrated_on_disk(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
sibling = _write_profile(tmp_path / "profiles", "research", 12)
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
migrated = update_cmd._migrate_sibling_profile_configs()
|
|
|
|
names = [m[0] for m in migrated]
|
|
assert "research" in names
|
|
entry = next(m for m in migrated if m[0] == "research")
|
|
assert entry[1] == 12 and entry[2] == _latest_version()
|
|
# REAL file on disk carries the new version
|
|
on_disk = yaml.safe_load((sibling / "config.yaml").read_text())
|
|
assert on_disk["_config_version"] == _latest_version()
|
|
# and user settings survived
|
|
assert on_disk["model"]["provider"] == "nous"
|
|
|
|
|
|
def test_active_profile_is_skipped(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", 12)
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
migrated = update_cmd._migrate_sibling_profile_configs()
|
|
|
|
assert "active" not in [m[0] for m in migrated]
|
|
# active home untouched (the caller's own migration handles it)
|
|
on_disk = yaml.safe_load((active / "config.yaml").read_text())
|
|
assert on_disk["_config_version"] == 12
|
|
|
|
|
|
def test_current_sibling_untouched(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
sibling = _write_profile(tmp_path / "profiles", "work", _latest_version())
|
|
_setup(monkeypatch, tmp_path, active)
|
|
before = (sibling / "config.yaml").read_bytes()
|
|
|
|
migrated = update_cmd._migrate_sibling_profile_configs()
|
|
|
|
assert migrated == []
|
|
assert (sibling / "config.yaml").read_bytes() == before
|
|
|
|
|
|
def test_unconfigured_profile_skipped(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
bare = tmp_path / "profiles" / "empty"
|
|
bare.mkdir()
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
assert update_cmd._migrate_sibling_profile_configs() == []
|
|
assert not (bare / "config.yaml").exists()
|
|
|
|
|
|
def test_one_broken_profile_does_not_block_others(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
broken_home = tmp_path / "profiles" / "broken"
|
|
broken_home.mkdir()
|
|
(broken_home / "config.yaml").write_text(":\nnot yaml: [", encoding="utf-8")
|
|
_write_profile(tmp_path / "profiles", "healthy", 12)
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
migrated = update_cmd._migrate_sibling_profile_configs()
|
|
|
|
assert [m[0] for m in migrated] == ["healthy"]
|
|
|
|
|
|
def test_override_is_reset_after_run(monkeypatch, tmp_path):
|
|
"""The ContextVar override must not leak past the sweep."""
|
|
from hermes_constants import get_hermes_home_override
|
|
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
_write_profile(tmp_path / "profiles", "research", 12)
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
before = get_hermes_home_override()
|
|
update_cmd._migrate_sibling_profile_configs()
|
|
assert get_hermes_home_override() == before
|