diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9a02f6cde0..12dfd11566 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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__) diff --git a/hermes_cli/stale_modules.py b/hermes_cli/stale_modules.py new file mode 100644 index 0000000000..2132da71ab --- /dev/null +++ b/hermes_cli/stale_modules.py @@ -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 diff --git a/tests/hermes_cli/test_stale_root_module_bridge.py b/tests/hermes_cli/test_stale_root_module_bridge.py new file mode 100644 index 0000000000..3065a47c3c --- /dev/null +++ b/tests/hermes_cli/test_stale_root_module_bridge.py @@ -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")