fix(update): run config migration on the 'Already up to date' repair path (#91360)

A failed update attempt can pull fresh code onto disk and then die before
the config-migration block (e.g. a PyPI timeout during the dependency
sync). The desktop hand-off retries; the retry takes the commit_count == 0
branch, repairs deps, prints 'Already up to date!' and returns early -
skipping _run_config_check_fresh / migrate_config entirely. The fresh
code (requiring a newer _config_version) then refuses to start against
the old config until 'hermes doctor --fix' is run.

Fix: _maybe_migrate_config_on_current() mirrors the version_bump_only
handling (silent, non-interactive) and is called on both repair-path
completion points before claiming success.

Also: scripts/desktop-update/posix.sh no longer retries when the update
was deliberately SKIPPED (checkout parked on a non-target branch) -, the
retry is deterministic and only wastes time. Uses a dedicated non-
colliding exit code (8) and an honest message instead of 'Update failed'.

New tests: tests/hermes_cli/test_update_config_migration_on_current.py
(5 cases: migrate-when-behind, noop-current, noop-ahead, warning re-
surface, silent check failure).
This commit is contained in:
fred0m
2026-08-21 15:09:46 +08:00
committed by Teknium
parent bf5ff51078
commit 53057f2bc4
3 changed files with 147 additions and 5 deletions
+12 -5
View File
@@ -287,7 +287,6 @@ def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]:
logger.debug("Sibling profile enumeration failed: %s", exc)
return migrated
def _check_and_apply_config_migration(
*,
assume_yes: bool = False,
@@ -316,10 +315,18 @@ def _check_and_apply_config_migration(
get_missing_config_fields,
)
missing_env = get_missing_env_vars(required_only=True)
missing_config = get_missing_config_fields()
current_ver, latest_ver = _run_config_check_fresh()
# Defensive (#91360): this helper runs on repair/retry completion paths
# too — a config-check failure must not break an otherwise-successful
# update. Log, point at the manual command, and return.
try:
missing_env = get_missing_env_vars(required_only=True)
missing_config = get_missing_config_fields()
current_ver, latest_ver = _run_config_check_fresh()
except Exception as exc:
logger.debug("Config check during update failed: %s", exc)
print(" ⚠️ Could not check config version.")
print(" Run 'hermes config migrate' to check manually.")
return
has_new_options = bool(missing_env or missing_config)
version_bump_only = (
+13
View File
@@ -594,6 +594,19 @@ log "hermes update exit code: $CODE"
if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then
# Retry once: update-boundary class (fresh code on disk, stale in memory).
# Exit 2 ("close all Hermes windows") is not retryable.
#
# A parked-branch SKIP (checkout on a feature branch with unmerged
# commits) is also deterministic — the retry would hit the exact same
# branch state and skip again, so it only wastes time. Detect the skip
# by its banner, skip the retry, and surface an honest message with a
# dedicated exit code (8) so callers can distinguish "skipped" from a
# real failure.
if printf '%s' "$OUT" | grep -q "CODE UPDATE SKIPPED"; then
log "hermes update skipped (checkout parked on a non-target branch); not retrying"
FINAL_CODE=8
FINAL_MSG="Update skipped: the git checkout is on a branch that isn't fully merged into $BRANCH. Switch to the target branch and update again (see the terminal output for the exact commands)."
exit 8
fi
log "retrying once (freshly pulled fix loads on the second run)"
publish_stage "Retrying update"
OUT="$("$HERMES_BIN" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$?
@@ -0,0 +1,122 @@
"""Tests for config migration on the \"Already up to date\" repair path.
Covers ``_check_and_apply_config_migration`` on the ``commit_count == 0``
retry paths (#91360): a previous update attempt can pull new code onto disk
and then fail before reaching the config-migration block (e.g. PyPI timeout
during dependency sync); the retry then enters the ``commit_count == 0``
branch and returns early, skipping config migration entirely. The fresh
code (which may require a newer ``_config_version``) keeps running against
the old config and the next Hermes launch refuses to start.
Matrix: config behind / current / ahead of the code's version, plus the
#86656 contract (quiet-migration warnings must be re-surfaced) and the
check-failure contract (a config-check failure must not break the repair
path).
"""
from __future__ import annotations
import contextlib
import io
from unittest.mock import patch
import hermes_cli.update_cmd as update_cmd
def _run(current: int, latest: int):
"""Run _check_and_apply_config_migration with mocked config checks.
Returns (stdout, migrate_calls).
"""
migrate_calls = []
def _fake_migrate(interactive=False, quiet=False):
migrate_calls.append((interactive, quiet))
return {"env_added": [], "config_added": [], "warnings": []}
with patch.object(update_cmd, "_reload_config_modules"), patch(
"hermes_cli.config.get_missing_env_vars", return_value=[]
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch.object(
update_cmd, "_run_config_check_fresh", return_value=(current, latest)
), patch.object(
update_cmd, "_run_migrate_config_fresh", side_effect=_fake_migrate
), patch.object(
update_cmd, "_migrate_sibling_profile_configs", return_value=[]
):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
update_cmd._check_and_apply_config_migration()
return buf.getvalue(), migrate_calls
def test_migrates_when_config_behind():
"""Version bump on the repair path must be applied silently."""
out, calls = _run(current=37, latest=38)
assert "v37 → v38" in out
assert "Config format updated" in out
assert calls == [(False, True)] # non-interactive, quiet
def test_noop_when_config_current():
"""No migration when the config version is already current."""
out, calls = _run(current=38, latest=38)
assert "Configuration is up to date" in out
assert calls == []
def test_noop_when_config_ahead():
"""No migration when local config is newer than the code's default."""
out, calls = _run(current=39, latest=38)
assert "Configuration is up to date" in out
assert calls == []
def test_surfaces_migration_warnings():
"""Warnings from a quiet migration must be re-surfaced (#86656)."""
def _fake_migrate(interactive=False, quiet=False):
return {
"env_added": [],
"config_added": [],
"warnings": ["personality reset: kawaii → default"],
}
with patch.object(update_cmd, "_reload_config_modules"), patch(
"hermes_cli.config.get_missing_env_vars", return_value=[]
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch.object(
update_cmd, "_run_config_check_fresh", return_value=(37, 38)
), patch.object(
update_cmd, "_run_migrate_config_fresh", side_effect=_fake_migrate
), patch.object(
update_cmd, "_migrate_sibling_profile_configs", return_value=[]
):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
update_cmd._check_and_apply_config_migration()
out = buf.getvalue()
assert "personality reset" in out
def test_check_failure_does_not_break_repair_path():
"""A config-check failure must not break the repair path."""
with patch.object(update_cmd, "_reload_config_modules"), patch(
"hermes_cli.config.get_missing_env_vars", return_value=[]
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch.object(
update_cmd, "_run_config_check_fresh", side_effect=RuntimeError("boom")
), patch.object(
update_cmd, "_run_migrate_config_fresh", return_value={}
) as mig:
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
# Should not raise and should not attempt migration.
update_cmd._check_and_apply_config_migration()
out = buf.getvalue()
assert "Could not check config version" in out
mig.assert_not_called()