fix(update): the receipt survives a mixed sys.modules graph and a lost write is visible

`hermes update` writes its receipt from the PRE-pull interpreter after the post-pull
module purge. `_receipt_dir()` resolved the home through `hermes_cli.config`, so the
write re-executed the pulled `config.py` against whatever was still cached — on a pull
that added a symbol to a root-level module (`utils.file_signature`, `base_url_origin`)
that import raised and the whole receipt was dropped. The failure was logged at DEBUG,
which the updater's INFO log discards, so an activation run left no receipt while the
following no-op run wrote one, and `latest.json` kept pointing at an older run.

- `_receipt_dir()` uses `hermes_constants.get_hermes_home` (purge-protected, stdlib-only).
- A failed write prints `⚠ Update receipt not written: <exc>` and logs at WARNING.

Refs #112465, #112558 (Finding A). Both new tests are red on origin/main.
This commit is contained in:
teknium1
2026-09-16 10:05:47 -07:00
committed by Teknium
parent 26e9205653
commit 1259b140c9
2 changed files with 67 additions and 3 deletions

View File

@@ -117,7 +117,12 @@ class UpdateReceipt:
def _receipt_dir() -> Path:
from hermes_cli.config import get_hermes_home
# ``hermes_constants``, never ``hermes_cli.config``: the receipt is written by the PRE-pull
# interpreter after the post-pull module purge, so a ``hermes_cli.config`` import here
# re-executes the pulled config.py against whatever is still cached — an ImportError on a
# symbol the pull added dropped the whole receipt (#112465, #112558). ``hermes_constants``
# is protected from the purge and stdlib-only.
from hermes_constants import get_hermes_home
return get_hermes_home() / "logs" / "update_receipts"
@@ -182,8 +187,11 @@ def finalize_update_receipt(outcome: str, fleet: list | None = None, stop_reason
(directory / "latest.json").write_text(body, encoding="utf-8")
_prune_old_receipts(directory)
return path
except Exception as exc: # pragma: no cover - defensive
logger.debug("Could not write update receipt: %s", exc)
except Exception as exc:
# Visible, not debug: a run that pulled code and left no receipt is exactly the run
# operators need to post-mortem, and INFO-level logs discard debug (#112465, #112558).
logger.warning("Could not write update receipt (%s): %s", outcome, exc)
print(f" ⚠ Update receipt not written: {exc}")
return None

View File

@@ -145,6 +145,62 @@ def test_purge_preserves_active_update_receipt(tmp_path, monkeypatch):
post_purge_receipt._current = None
def test_receipt_write_survives_a_mixed_module_graph(tmp_path, monkeypatch, capsys):
"""#112465 / #112558: the activation run wrote NO receipt while the no-op run did. The
receipt is written by the pre-pull interpreter after the purge; resolving the receipt dir
through ``hermes_cli.config`` re-executed the pulled config.py against a stale top-level
``utils`` (``from utils import file_signature`` → ImportError) and the whole write was
swallowed at debug level. The receipt path must not depend on any purgeable module, and a
write failure must be visible."""
import hermes_cli
import hermes_cli.update_receipt as receipt
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Pre-pull world: a `utils` without the symbols the pulled config.py imports, and the
# purged (evicted + unbound) hermes_cli.config so any import of it re-executes source.
real_utils = sys.modules.get("utils")
sys.modules["utils"] = types.ModuleType("utils")
evicted = {k: sys.modules.pop(k) for k in list(sys.modules) if k.startswith("hermes_cli.config")}
stale_attr = vars(hermes_cli).pop("config", None)
receipt._current = None
try:
receipt.begin_update_receipt()
receipt.record_step("git_pull", True)
path = receipt.finalize_update_receipt("partial")
finally:
receipt._current = None
sys.modules.pop("utils", None)
if real_utils is not None:
sys.modules["utils"] = real_utils
for k in [k for k in sys.modules if k.startswith("hermes_cli.config")]:
del sys.modules[k]
sys.modules.update(evicted)
if stale_attr is not None:
hermes_cli.config = stale_attr
assert path is not None and path.is_file(), "receipt lost to the mixed sys.modules graph"
assert path.parent == tmp_path / "logs" / "update_receipts"
assert json.loads(path.read_text(encoding="utf-8"))["outcome"] == "partial"
def test_receipt_write_failure_is_visible(tmp_path, monkeypatch, capsys):
"""A begun-but-unwritten receipt is the run operators must post-mortem; the failure was
logged at DEBUG only, indistinguishable from "no receipt expected" (#112465)."""
import hermes_cli.update_receipt as receipt
def _boom():
raise OSError("disk says no")
monkeypatch.setattr(receipt, "_receipt_dir", _boom)
receipt._current = None
try:
receipt.begin_update_receipt()
assert receipt.finalize_update_receipt("success") is None
finally:
receipt._current = None
assert "receipt not written: disk says no" in capsys.readouterr().out
def test_purge_leaves_prefix_lookalikes_alone():
# `gateway_foo` starts with the string prefix "gateway" but is NOT the
# gateway package — the root-segment check must spare it.