Files
hermes-agent/tests/hermes_cli/test_update_pause_stop_evidence.py
Teknium 7b68039222 fix: unreadable or unwritable accepted-stop evidence keeps a draining gateway owed
The pause record's second evidence reader (_names_request) turned a refused or
malformed planned-stop marker / accepted receipt into False, i.e. "this update
never asked it to stop". Past the request's TTL recovery then dropped the live,
still-draining gateway (drop_never_stopped) and durably retired its debt.
Now only an absent file or a valid one naming something else is "not asked";
refused/malformed is unknown: the entry stays owed (held as draining while its
process lives), and unknown is never resolved into stop_sent, so the next pass
judges it again.

The consumer's receipt (mark_stop_accepted) swallowed its own write failure, so
an accepted stop could leave no durable trace at all. When the home refuses the
new receipt file it now stamps the kept request in place ("accepted", opened r+,
never created); a torn stamp reads malformed, hence unknown, hence owed. An
expired request nobody accepted still expires.
2026-10-06 06:18:39 -07:00

104 lines
5.3 KiB
Python

"""Accepted-stop evidence the pause record cannot read or could not write: the debt stays owed.
The gateway is this test process (a live incarnation that accepted the stop and is draining); the
updater is gone (the record is unowned); every file is real and every refusal is a real one (mode
000, a read-only directory), standing in for a Windows sharing violation or ACL refusal.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from hermes_cli import update_pause_record as pause_record
pytestmark = [
pytest.mark.skipif(sys.platform == "win32", reason="POSIX modes stand in for Windows refusals"),
pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root ignores file modes"),
]
def _accepted_then_orphaned(tmp_path: Path, monkeypatch, *, home_writable: bool) -> tuple[Path, str]:
"""Record a pause of this process, request its stop, let it accept the request while the record
refuses the consumer's checkpoint (read-only record directory), then age the request past its
TTL and orphan the record. Returns ``(marker, pause_id)``."""
from gateway import status
root, home = tmp_path / "root", tmp_path / "root" / "profiles" / "p"
home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
pid = os.getpid()
marker = status._get_planned_stop_marker_path()
token = pause_record.record_pause({"resume_needed": True, "profiles": {"p": pid},
"identities": {str(pid): pause_record.identity(pid)["ct"]}}, None, [])
pause_record.mark_stop_requested(token, [pid], markers={pid: marker})
assert status.write_planned_stop_marker(pid)
root.chmod(0o555)
if not home_writable:
home.chmod(0o555)
try:
assert status.consume_planned_stop_marker_for_self() is True, "premise: the stop was accepted"
finally:
home.chmod(0o755)
root.chmod(0o755)
saved = pause_record.read()["token"]
assert saved["stop_sent"] == [], "premise: no checkpoint landed in the record"
body = json.loads(marker.read_text(encoding="utf-8"))
body["written_at"] = (datetime.now(timezone.utc) - timedelta(seconds=120)).isoformat() # past the TTL
marker.write_text(json.dumps(body), encoding="utf-8")
pause_record.write(saved, owner=pause_record.UNOWNED) # the updater died
return marker, saved["pause_id"]
def _owed_after_recovery(pause_id: str) -> dict | None:
"""Run a launch's recovery; the profiles still owed by the obligation, ``None`` once retired."""
pause_record.recover(["status"])
path = pause_record.record_path()
retired = path.with_suffix(".retired")
ids = json.loads(retired.read_text(encoding="utf-8"))["ids"] if retired.exists() else []
carriers = [pause_record.read(src) for src in (path, *pause_record._claims(path)) if src.exists()]
owed = [body["token"]["profiles"] for body in carriers if body["token"]["pause_id"] == pause_id]
assert bool(owed) != (pause_id in ids), "an obligation is either owed or retired, never both or neither"
return owed[0] if owed else None
@pytest.mark.parametrize("receipt", ["refused", "malformed", "missing"])
def test_an_unreadable_accepted_stop_receipt_keeps_the_draining_gateway_owed(tmp_path, monkeypatch, receipt):
"""R1: a receipt that exists but cannot be read or parsed is unknown evidence: the live draining
gateway stays owed (never restarted over, never retired). A receipt that is genuinely absent
beside an expired, unconsumed request is the control: that stop was never accepted."""
marker, pause_id = _accepted_then_orphaned(tmp_path, monkeypatch, home_writable=True)
accepted = pause_record._accepted_path(marker)
assert accepted.exists(), "premise: the consumer left its receipt"
if receipt == "refused":
accepted.chmod(0)
elif receipt == "malformed":
accepted.write_text("{trunc", encoding="utf-8")
else:
accepted.unlink()
try:
owed = _owed_after_recovery(pause_id)
finally:
if accepted.exists():
accepted.chmod(0o644)
if receipt == "missing":
assert owed is None, "a request nobody accepted must still expire"
return
assert owed == {"p": os.getpid()}, f"a {receipt} receipt retired a draining gateway's restart debt"
# Unknown is judged again, never resolved into "sent": once readable it is a receipt like any other.
assert str(os.getpid()) not in pause_record.read(pause_record._claims(pause_record.record_path())[0])["token"]["stop_sent"]
assert _owed_after_recovery(pause_id) == {"p": os.getpid()}
def test_an_accepted_stop_whose_receipt_cannot_be_written_stays_owed(tmp_path, monkeypatch):
"""Thread 6: the gateway home refuses new files too, so neither the record checkpoint nor the
receipt beside the request can be created; the consumer still accepts the stop. The kept
request itself must carry the acceptance, so the debt survives the request's TTL."""
marker, pause_id = _accepted_then_orphaned(tmp_path, monkeypatch, home_writable=False)
assert not pause_record._accepted_path(marker).exists(), "premise: the receipt could not be created"
assert _owed_after_recovery(pause_id) == {"p": os.getpid()}, "an accepted stop lost its restart debt"