refactor(update): the purge scan has no fallback; trim to two invariants
The checkout root is where the update just pulled into, so "root unreadable" cannot happen after a successful pull — drop the OSError fallback and the static five-name tuple it fell back to (the tuple was the drift that caused the bug). Drop the phantom `hermes_cli.hermes_logging` protection entry: no such module exists; the real `hermes_logging` is root-level and now protected by name. Keep two tests: the stale root `utils` scenario (red on base) and the hermes_logging protection.
This commit is contained in:
@@ -98,7 +98,7 @@ from hermes_cli.update_cmd_git import ( # noqa: F401
|
||||
_sync_with_upstream_if_needed)
|
||||
from hermes_cli.update_cmd_maint import ( # noqa: F401
|
||||
_PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE,
|
||||
_STALE_PURGE_EXCLUDED_TOP_LEVEL, _STALE_PURGE_PREFIXES, _STALE_PURGE_PROTECTED,
|
||||
_STALE_PURGE_EXCLUDED_TOP_LEVEL, _STALE_PURGE_PROTECTED,
|
||||
_UPDATE_RUNTIME_RELOAD_MODULES, _clear_stale_sqlite_sidecars,
|
||||
_ensure_acp_launcher, _ensure_fhs_path_guard, _finish_dashboard_update_cleanup,
|
||||
_format_time_ago, _post_update_sqlite_runtime_status, _print_bundled_skills_sync_report,
|
||||
|
||||
@@ -25,10 +25,6 @@ logger = logging.getLogger("hermes_cli.update_cmd")
|
||||
|
||||
_UPDATE_RUNTIME_RELOAD_MODULES = "hermes_constants", "tools.environments.local", "tools.lazy_deps"
|
||||
|
||||
#: Fallback for the purge's top-level names when the checkout scan below cannot run; the
|
||||
#: live set comes from ``_stale_purge_prefixes()``.
|
||||
_STALE_PURGE_PREFIXES = "hermes_cli", "gateway", "tools", "tui_gateway", "agent"
|
||||
|
||||
#: Owned by the checkout but never purged: pytest resolves fixtures through the identity of
|
||||
#: its own already-imported test modules, and evicting them mid-session breaks that.
|
||||
_STALE_PURGE_EXCLUDED_TOP_LEVEL = frozenset({"tests"})
|
||||
@@ -38,9 +34,7 @@ _STALE_PURGE_EXCLUDED_TOP_LEVEL = frozenset({"tests"})
|
||||
#: ``hermes_logging`` is protected for a different reason: its queue listener, handler list and
|
||||
#: ``_logging_initialized`` flag are module globals, so a fresh copy starts a SECOND
|
||||
#: QueueListener over the same log files while the first one keeps running.
|
||||
_STALE_PURGE_PROTECTED = frozenset({
|
||||
"hermes_cli", "hermes_cli.main", "hermes_cli.hermes_logging", "hermes_logging",
|
||||
})
|
||||
_STALE_PURGE_PROTECTED = frozenset({"hermes_cli", "hermes_cli.main", "hermes_logging"})
|
||||
|
||||
#: The updater's own module family (``update_cmd*``, ``update_receipt``, ``update_inventory``,
|
||||
#: ``update_lock``, ...) is protected as a prefix: these hold per-run state — the open receipt
|
||||
@@ -102,18 +96,15 @@ def _stale_purge_prefixes() -> frozenset:
|
||||
|
||||
Scanned, not listed: a hardcoded tuple stops covering each newly added top-level module
|
||||
without anything failing, and the symbol that breaks the next update is in whichever one
|
||||
drifted out — ``utils`` gaining ``base_url_origin`` was the field case.
|
||||
drifted out — ``utils`` gaining ``base_url_origin`` / ``file_signature`` were the field cases.
|
||||
"""
|
||||
from hermes_cli.update_cmd import _m
|
||||
names = set(_STALE_PURGE_PREFIXES)
|
||||
try:
|
||||
for entry in Path(_m().PROJECT_ROOT).iterdir():
|
||||
if entry.suffix == ".py" and entry.is_file():
|
||||
names.add(entry.stem)
|
||||
elif (entry / "__init__.py").is_file():
|
||||
names.add(entry.name)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not scan the checkout for purge prefixes: %s", exc)
|
||||
names = set()
|
||||
for entry in Path(_m().PROJECT_ROOT).iterdir():
|
||||
if entry.suffix == ".py" and entry.is_file():
|
||||
names.add(entry.stem)
|
||||
elif (entry / "__init__.py").is_file():
|
||||
names.add(entry.name)
|
||||
return frozenset(names) - _STALE_PURGE_EXCLUDED_TOP_LEVEL
|
||||
|
||||
|
||||
|
||||
@@ -174,16 +174,6 @@ def test_purge_keeps_plan_record_class_identity():
|
||||
assert after is before
|
||||
|
||||
|
||||
def test_purge_prefixes_cover_checkout_top_level_modules():
|
||||
# The static tuple listed 5 packages and no top-level module, so `utils`,
|
||||
# `hermes_constants` and friends stayed cached through every update.
|
||||
prefixes = update_cmd._stale_purge_prefixes()
|
||||
for name in ("utils", "hermes_constants", "hermes_bootstrap", "plugins", "providers"):
|
||||
assert name in prefixes, f"{name} is not covered by the purge"
|
||||
for name in ("hermes_cli", "gateway", "tools", "tui_gateway", "agent"):
|
||||
assert name in prefixes
|
||||
|
||||
|
||||
def test_stale_top_level_utils_scenario_end_to_end():
|
||||
"""The 2026-09-12 field failure: `hermes update` from a pre-`base_url_origin`
|
||||
checkout kept the old top-level `utils` cached, and the restart phase's import of
|
||||
@@ -221,32 +211,3 @@ def test_purge_protects_hermes_logging():
|
||||
sys.modules.pop("hermes_logging", None)
|
||||
if real is not None:
|
||||
sys.modules["hermes_logging"] = real
|
||||
|
||||
|
||||
def test_purge_spares_the_tests_package():
|
||||
# pytest resolves fixtures through the identity of already-imported test modules.
|
||||
sentinel = _fake_module("tests.hermes_cli._purge_probe")
|
||||
sys.modules["tests.hermes_cli._purge_probe"] = sentinel
|
||||
try:
|
||||
cli_main._purge_stale_hermes_modules()
|
||||
assert sys.modules.get("tests.hermes_cli._purge_probe") is sentinel
|
||||
finally:
|
||||
sys.modules.pop("tests.hermes_cli._purge_probe", None)
|
||||
|
||||
|
||||
def test_purge_prefixes_follow_project_root(tmp_path, monkeypatch):
|
||||
# The scan must read main.PROJECT_ROOT, not this module's own __file__: tests and the
|
||||
# installer both relocate the checkout, and a divergent root silently purges nothing.
|
||||
(tmp_path / "zzz_probe.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "zzz_pkg").mkdir()
|
||||
(tmp_path / "zzz_pkg" / "__init__.py").write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", tmp_path)
|
||||
|
||||
prefixes = update_cmd._stale_purge_prefixes()
|
||||
assert {"zzz_probe", "zzz_pkg"} <= prefixes
|
||||
assert set(update_cmd._STALE_PURGE_PREFIXES) <= prefixes
|
||||
|
||||
|
||||
def test_purge_prefixes_fall_back_when_root_unreadable(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", tmp_path / "does-not-exist")
|
||||
assert update_cmd._stale_purge_prefixes() == frozenset(update_cmd._STALE_PURGE_PREFIXES)
|
||||
|
||||
Reference in New Issue
Block a user