fix(update): bridge stale utils so pre-handoff upgrades finish restart

Upgrades from ≤v2026.9.14 keep a cached root utils without file_signature
(narrow package-prefix purge). The post-pull gateway restart then imports
fresh hermes_cli.config and dies with ImportError, failing the daily
Install & Update E2E hermes-update legs. Drop the incomplete utils cache
before that import so the one pre-handoff upgrade can complete.

Co-authored-by: Samik Bandyopadhyay <callsamik@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-09-18 02:59:41 +00:00
committed by Teknium
parent df82862b6d
commit a39dfffd65
3 changed files with 143 additions and 0 deletions

View File

@@ -28,8 +28,12 @@ from hermes_cli.colors import Colors, color
from hermes_cli import managed_scope
from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul
from hermes_cli.secret_prompt import masked_secret_prompt
from hermes_cli.stale_modules import drop_stale_root_modules
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home, get_process_hermes_home # noqa: F401
# Drop a pre-pull ``utils`` cache (narrow purge left root modules) before the
# import below — see hermes_cli.stale_modules. Bridge for ≤v2026.9.14 → HEAD.
drop_stale_root_modules()
from utils import atomic_replace, atomic_yaml_write, fast_safe_load, file_signature
logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,45 @@
"""Heal mixed ``sys.modules`` after an in-place checkout update.
Pre-reexec updaters (Hermes ≤ v2026.9.14) purged only package prefixes
(``hermes_cli``, ``gateway``, ``tools``, ``tui_gateway``, ``agent``) and left
root modules like ``utils`` cached in the updater process. The post-pull
gateway-restart phase then imports new ``hermes_cli.gateway`` /
``hermes_cli.config`` into that process; those need symbols the stale
``utils`` lacks (``file_signature``), and ``hermes update`` exits 1 with
``gateway auto-restart failed: cannot import name 'file_signature' from
'utils'``.
Post-swap hand-off (``hermes_cli.update_handoff``) makes this class dead for
updaters that already include it. This module is the bridge for the one
upgrade from a pre-handoff release onto a tree that needs new root symbols:
freshly imported ``hermes_cli`` code drops the incomplete cache before
importing ``utils``.
"""
from __future__ import annotations
import sys
from typing import Mapping, Sequence
# Root modules the narrow purge left behind, keyed by attributes that must
# exist on the on-disk copy after this release. Extend when a new root-level
# symbol would otherwise break the pre-handoff upgrade path.
_ROOT_MODULE_REQUIRED_ATTRS: dict[str, tuple[str, ...]] = {
"utils": ("file_signature",),
}
def drop_stale_root_modules(
required: Mapping[str, Sequence[str]] | None = None,
) -> list[str]:
"""Drop cached root modules missing required attrs. Returns dropped names."""
checks = _ROOT_MODULE_REQUIRED_ATTRS if required is None else required
dropped: list[str] = []
for name, attrs in checks.items():
mod = sys.modules.get(name)
if mod is None:
continue
if any(not hasattr(mod, attr) for attr in attrs):
sys.modules.pop(name, None)
dropped.append(name)
return dropped

View File

@@ -0,0 +1,94 @@
"""Bridge for pre-reexec ``hermes update``: stale root ``utils`` must not kill restart.
The daily Install & Update E2E (and every field upgrade from ≤v2026.9.14 onto a
tree that added ``utils.file_signature``) failed with::
Update incomplete — gateway auto-restart failed: cannot import name
'file_signature' from 'utils' (.../hermes-agent/utils.py)
Mechanism (matches the scheduled E2E hermes-update legs):
1. The updater process is still the pre-pull interpreter (no post-swap hand-off
yet — that landed after v2026.9.14).
2. Its narrow ``_purge_stale_hermes_modules`` only evicts package prefixes, so
root ``utils`` stays cached without ``file_signature``.
3. The restart phase then does ``from hermes_cli.gateway import ...``, which
loads fresh ``hermes_cli.config``, whose ``from utils import file_signature``
hits the stale cache.
``hermes_cli.stale_modules.drop_stale_root_modules`` + the call at config import
time is the bridge: freshly imported hermes_cli code drops the incomplete
cache before importing utils. Post-swap hand-off makes the class dead for
updaters that already include it; this keeps the one upgrade from a
pre-handoff release green.
"""
from __future__ import annotations
import importlib
import sys
import types
import pytest
def _import_fresh_consumer(name: str, source: str) -> types.ModuleType:
"""Run ``source`` as a brand-new module body (first import on a stale process)."""
mod = types.ModuleType(name)
mod.__file__ = f"{name}.py"
sys.modules.pop(name, None)
exec(compile(source, mod.__file__, "exec"), mod.__dict__)
sys.modules[name] = mod
return mod
def test_drop_stale_root_modules_evicts_utils_missing_file_signature(monkeypatch):
import utils
from hermes_cli.stale_modules import drop_stale_root_modules
assert hasattr(utils, "file_signature")
monkeypatch.delattr(utils, "file_signature")
assert "utils" in sys.modules
dropped = drop_stale_root_modules()
assert dropped == ["utils"]
assert "utils" not in sys.modules
def test_drop_stale_root_modules_leaves_complete_utils_alone():
import utils
from hermes_cli.stale_modules import drop_stale_root_modules
assert hasattr(utils, "file_signature")
before = sys.modules["utils"]
assert drop_stale_root_modules() == []
assert sys.modules["utils"] is before
def test_naive_consumer_still_dies_on_stale_utils(monkeypatch):
"""Control: without the heal, the ImportError the E2E saw still fires."""
import utils
monkeypatch.delattr(utils, "file_signature")
with pytest.raises(ImportError, match=r"cannot import name 'file_signature' from 'utils'"):
_import_fresh_consumer(
"stale_utils_bridge_control",
"from utils import file_signature\n",
)
def test_fresh_config_import_heals_stale_utils_missing_file_signature(monkeypatch):
"""Restart-phase shape: hermes_cli.* purged, root utils stale, config re-imported."""
import utils
monkeypatch.delattr(utils, "file_signature")
for name in list(sys.modules):
if name == "hermes_cli.config" or name.startswith("hermes_cli.config."):
sys.modules.pop(name, None)
if name == "hermes_cli.stale_modules" or name.startswith("hermes_cli.stale_modules."):
sys.modules.pop(name, None)
config = importlib.import_module("hermes_cli.config")
assert hasattr(config, "file_signature")
assert callable(config.file_signature)
assert hasattr(sys.modules["utils"], "file_signature")