feat(issue-5): auto-completion gate and durable pending notification
The gate does not re-decide anything. Phase 2a already decides, fail-closed and against contract-pinned vectors; this supplies real evidence to that decision and commits only if three independent gates agree: the global switch is on, the backend has observed evidence that it reaches a quiescent state, and the job itself opted in. Default is off. The backend allowlist is evidence-based rather than optimistic. Inferring "finished" from stillness requires stillness to have been observed, and only three of six backends have that. An operator may narrow the list; a name that was never observed is dropped rather than trusted, so it cannot be widened by configuration. Everything else holds. An unreadable manifest, a report rewritten after finalization, missing evidence for a declared side effect, a policy that raises: all refuse. "Could not decide" is never "done". A late explicit terminal wins, because the boundary refuses the inferred one -- that is the boundary working, not an error in the gate. Notification intent is committed in the same atomic write as the state, so a crash between committing and publishing leaves a record that still says the world has not been told. Recovery re-sends; duplicates are already dropped by sequence. A failed send deliberately leaves the intent pending, because an unsent notification that looks sent is worse than one that is retried. Two fixes this surfaced. Settling used a request that terminal records refuse, which made an owed notification impossible to ever settle; settling is now a status-free bookkeeping request, valid in any state. And the phase 2a guard against shell references was matching hashlib.sha256 as a substring, so it now requires a word boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
1 parent
3ea5757cfe
commit
803ba43884
5 files changed
+513
-4
No files matched your search
@@ -271,3 +271,249 @@ def test_publish_event_reports_a_bad_side_effect_instead_of_publishing(mam_sandb
|
||||
"--finalize-report", str(path), "--side-effect", "malformed",
|
||||
])
|
||||
assert rc == 1
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. autocomplete — the gate, and everything that must stop it
|
||||
# ==============================================================================
|
||||
|
||||
def _ready_job(tmp_path, mam_sandbox, *, backend="claude-code", opt_in=True,
|
||||
artifacts=None, body="report\n"):
|
||||
"""A job in exactly the state that should auto-complete, plus its evidence."""
|
||||
from lib_py.loop import finalize, store
|
||||
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg, agent=backend, expected_artifacts=artifacts or [])
|
||||
from lib_py.delegation import engine
|
||||
engine.transition(job_id, "claim", registry_dir=reg)
|
||||
if opt_in:
|
||||
engine.transition(job_id, "progress", registry_dir=reg,
|
||||
fields={"auto_complete": True})
|
||||
|
||||
scenario = _scenario()
|
||||
dispatch = scenario["dispatch_context"]
|
||||
dispatch["job_id"] = job_id
|
||||
for item in scenario["timeline"]:
|
||||
env = item.get("collector_attribution")
|
||||
if env:
|
||||
env["job_id"] = job_id
|
||||
# A non-herdr payload is a legitimate identifier source, so it has to
|
||||
# agree too. Leaving the fixture's original id here is a contradiction,
|
||||
# and the policy correctly refuses it.
|
||||
payload = item.get("payload") or {}
|
||||
if item.get("source_kind") != "herdr" and "job_id" in payload:
|
||||
payload["job_id"] = job_id
|
||||
store.record_dispatch(job_id, dispatch["attempt_id"], dispatch["agent_session_uuid"],
|
||||
dispatch["pane_process_generation"], workspace=str(mam_sandbox))
|
||||
store.mark_execution_anchor(job_id, workspace=str(mam_sandbox))
|
||||
|
||||
report = _report(tmp_path, body)
|
||||
finalize.finalize_report(report, job_id, dispatch["attempt_id"],
|
||||
side_effects=finalize.parse_side_effects(
|
||||
["{}=ref".format(a) for a in (artifacts or [])]))
|
||||
return job_id, reg, scenario, report.parent
|
||||
|
||||
|
||||
def test_gate_commits_when_every_condition_holds(mam_sandbox, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
|
||||
from lib_py.loop import autocomplete
|
||||
|
||||
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
|
||||
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
|
||||
workspace=str(mam_sandbox), report_dir=report_dir)
|
||||
assert result.committed, result.reason
|
||||
record = _load(reg, job_id)
|
||||
assert record["status"] == "completed"
|
||||
assert record["completed_by"] == "auto_complete", "an inferred terminal must be marked"
|
||||
assert record["pending_notification"]["event"] == "completed"
|
||||
|
||||
|
||||
def test_g4_default_off_commits_nothing_and_leaves_the_registry_untouched(
|
||||
mam_sandbox, tmp_path, monkeypatch
|
||||
):
|
||||
"""G4: with the flag unset, the registry must not change at all."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.loop import autocomplete
|
||||
|
||||
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
|
||||
before = _fingerprint(Path(reg))
|
||||
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
|
||||
workspace=str(mam_sandbox), report_dir=report_dir)
|
||||
assert result.committed is False
|
||||
assert "disabled" in result.reason
|
||||
assert _fingerprint(Path(reg)) == before, "the default must write nothing"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", ["agy", "hermes", "opencode", "unknown-agent"])
|
||||
def test_g9_a_backend_without_quiescence_evidence_never_auto_completes(
|
||||
mam_sandbox, tmp_path, monkeypatch, backend
|
||||
):
|
||||
"""Inferring 'finished' from stillness needs stillness to have been observed."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
|
||||
from lib_py.loop import autocomplete
|
||||
|
||||
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox, backend=backend)
|
||||
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
|
||||
workspace=str(mam_sandbox), report_dir=report_dir)
|
||||
assert result.committed is False and "quiescent-state evidence" in result.reason
|
||||
assert _load(reg, job_id)["status"] == "running"
|
||||
|
||||
|
||||
def test_the_allowlist_can_be_narrowed_but_never_widened(monkeypatch):
|
||||
from lib_py.loop import autocomplete
|
||||
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE_BACKENDS", "claude")
|
||||
assert autocomplete.allowed_backends() == ("claude",)
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE_BACKENDS", "claude,hermes,opencode")
|
||||
assert autocomplete.allowed_backends() == ("claude",), "unobserved backends are dropped"
|
||||
|
||||
|
||||
def test_a_job_that_did_not_opt_in_is_not_auto_completed(mam_sandbox, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
|
||||
from lib_py.loop import autocomplete
|
||||
|
||||
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox, opt_in=False)
|
||||
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
|
||||
workspace=str(mam_sandbox), report_dir=report_dir)
|
||||
assert result.committed is False and "opt in" in result.reason
|
||||
|
||||
|
||||
def test_g5_a_rewritten_report_stops_the_gate(mam_sandbox, tmp_path, monkeypatch):
|
||||
"""G5: the manifest is checked against the file, so a late edit disqualifies."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
|
||||
from lib_py.loop import autocomplete
|
||||
|
||||
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
|
||||
(report_dir / "report-final.md").write_text("rewritten after finalization\n", encoding="utf-8")
|
||||
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
|
||||
workspace=str(mam_sandbox), report_dir=report_dir)
|
||||
assert result.committed is False and "changed after finalization" in result.reason
|
||||
assert _load(reg, job_id)["status"] == "running"
|
||||
|
||||
|
||||
def test_g5_missing_side_effect_evidence_stops_the_gate(mam_sandbox, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
|
||||
from lib_py.loop import autocomplete, finalize
|
||||
|
||||
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox,
|
||||
artifacts=["git_push"])
|
||||
# re-declare without the evidence the job said it would produce
|
||||
finalize.finalize_report(report_dir / "report-final.md", job_id, 1, side_effects=[])
|
||||
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
|
||||
workspace=str(mam_sandbox), report_dir=report_dir)
|
||||
assert result.committed is False and "git_push" in result.reason
|
||||
|
||||
|
||||
def test_d7_a_policy_that_cannot_decide_holds(mam_sandbox, tmp_path, monkeypatch):
|
||||
"""'Could not decide' is never 'done'."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
|
||||
from lib_py.loop import autocomplete, policy
|
||||
|
||||
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
|
||||
|
||||
def explode(_scenario):
|
||||
raise RuntimeError("evidence is contradictory")
|
||||
|
||||
monkeypatch.setattr(policy, "decide", explode)
|
||||
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
|
||||
workspace=str(mam_sandbox), report_dir=report_dir)
|
||||
assert result.committed is False and "policy error" in result.reason
|
||||
assert _load(reg, job_id)["status"] == "running"
|
||||
|
||||
|
||||
def test_a_late_explicit_terminal_beats_the_gate(mam_sandbox, tmp_path, monkeypatch):
|
||||
"""If the agent already reported, the boundary refuses and that is correct."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
|
||||
from lib_py.delegation import mqtt_common
|
||||
from lib_py.loop import autocomplete
|
||||
|
||||
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
|
||||
mqtt_common.update_job_status(job_id, reg, _source="publish_event", status="error")
|
||||
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
|
||||
workspace=str(mam_sandbox), report_dir=report_dir)
|
||||
assert result.committed is False
|
||||
record = _load(reg, job_id)
|
||||
assert record["status"] == "error", "the explicit report wins"
|
||||
assert "completed_by" not in record
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 4. notifier — the intent is durable, and recovery re-sends it
|
||||
# ==============================================================================
|
||||
|
||||
def test_the_notification_intent_is_committed_with_the_state(mam_sandbox, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import engine, notifier
|
||||
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
engine.transition(job_id, "claim", registry_dir=reg)
|
||||
engine.transition(job_id, "completed", registry_dir=reg, notify="completed")
|
||||
record = _load(reg, job_id)
|
||||
assert record["status"] == "completed"
|
||||
intent = notifier.pending(job_id, reg)
|
||||
assert intent and intent["event"] == "completed" and intent["seq"] == record["transition_seq"]
|
||||
|
||||
|
||||
def test_flush_publishes_then_settles(mam_sandbox, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import engine, notifier
|
||||
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
engine.transition(job_id, "claim", registry_dir=reg)
|
||||
engine.transition(job_id, "completed", registry_dir=reg, notify="completed")
|
||||
|
||||
sent = []
|
||||
assert notifier.flush(job_id, reg, publish=lambda j, i, r: sent.append(i) or 0)
|
||||
assert len(sent) == 1 and sent[0]["event"] == "completed"
|
||||
assert notifier.pending(job_id, reg) is None
|
||||
# settling is idempotent: a second flush has nothing to do
|
||||
assert notifier.flush(job_id, reg, publish=lambda *a: 1 / 0)
|
||||
|
||||
|
||||
def test_a_failed_send_leaves_the_intent_pending(mam_sandbox, tmp_path, monkeypatch):
|
||||
"""An unsent notification that looks sent is worse than one that is retried."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import engine, notifier
|
||||
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
engine.transition(job_id, "claim", registry_dir=reg)
|
||||
engine.transition(job_id, "completed", registry_dir=reg, notify="completed")
|
||||
|
||||
assert notifier.flush(job_id, reg, publish=lambda *a: 2) is False
|
||||
assert notifier.pending(job_id, reg) is not None
|
||||
assert notifier.flush(job_id, reg, publish=lambda *a: (_ for _ in ()).throw(OSError())) is False
|
||||
assert notifier.pending(job_id, reg) is not None
|
||||
assert _load(reg, job_id)["status"] == "completed", "the state stays committed regardless"
|
||||
|
||||
|
||||
def test_g7_restart_recovery_resends_every_owed_notification(mam_sandbox, tmp_path, monkeypatch):
|
||||
"""G7: the crash window between committing and publishing."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import engine, notifier
|
||||
|
||||
reg = _reg(tmp_path)
|
||||
owed = []
|
||||
for _ in range(3):
|
||||
job_id = _make_job(reg)
|
||||
engine.transition(job_id, "claim", registry_dir=reg)
|
||||
engine.transition(job_id, "completed", registry_dir=reg, notify="completed")
|
||||
owed.append(job_id)
|
||||
settled = _make_job(reg)
|
||||
engine.transition(settled, "claim", registry_dir=reg)
|
||||
|
||||
assert sorted(notifier.jobs_with_pending(reg)) == sorted(owed)
|
||||
|
||||
sent = []
|
||||
results = notifier.recover(reg, publish=lambda j, i, r: sent.append(j) or 0)
|
||||
assert all(results.values()) and sorted(sent) == sorted(owed)
|
||||
assert notifier.jobs_with_pending(reg) == [], "nothing is owed after recovery"
|
||||
Reference in new issue
Block a user