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:
@@ -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__)
|
||||
|
||||
45
hermes_cli/stale_modules.py
Normal file
45
hermes_cli/stale_modules.py
Normal 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
|
||||
94
tests/hermes_cli/test_stale_root_module_bridge.py
Normal file
94
tests/hermes_cli/test_stale_root_module_bridge.py
Normal 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")
|
||||
Reference in New Issue
Block a user