feat(issue-5): report finalization manifest and side-effect evidence
The agent declares that its work is finished; the reader re-derives the hash from the file rather than trusting the number in the declaration. A manifest the watcher wrote itself would attest only that the watcher saw a stable file, which is the self-certifying evidence shape rejected earlier in this issue. Finalizing promotes a staged report by atomic rename before writing the manifest, so a reader cannot observe a half-written report as final. Rewriting the report afterwards invalidates the declaration immediately, because the recomputed hash no longer matches -- a new body needs a new finalization. A job that declared side effects must show evidence for every one of them. Partial evidence is treated as none: the work is not known to be complete. publish_event gains --finalize-report, --side-effect and --attempt-id, and does the finalization before publishing. A `completed` event whose report is still being written would invite exactly the partial adoption the contract forbids, so a failure there fails the publish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
1 parent
9a827b3caa
commit
3ea5757cfe
3 files changed
+327
No files matched your search
@@ -138,3 +138,136 @@ def test_x05_the_explicit_event_path_is_untouched_by_default(mam_sandbox, tmp_pa
|
||||
record = _load(reg, job_id)
|
||||
assert record["status"] == "completed"
|
||||
assert "completed_by" not in record, "an explicit completion carries no auto marker"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2. finalize — declared by the agent, re-derived by the reader
|
||||
# ==============================================================================
|
||||
|
||||
def _report(tmp_path, body="done\n", staged=False):
|
||||
d = tmp_path / "claude-reports"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = d / "report-final.md"
|
||||
(path.with_name(path.name + ".tmp") if staged else path).write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_finalize_promotes_a_staged_report_atomically(tmp_path):
|
||||
"""A reader must never see a half-written report as finalized."""
|
||||
from lib_py.loop import finalize
|
||||
|
||||
path = _report(tmp_path, "final body\n", staged=True)
|
||||
assert not path.exists() and path.with_name(path.name + ".tmp").exists()
|
||||
manifest = finalize.finalize_report(path, "job1", 1)
|
||||
assert path.exists() and not path.with_name(path.name + ".tmp").exists()
|
||||
assert manifest["sha256"] == finalize.sha256_of(path)
|
||||
|
||||
|
||||
def test_finalize_verifies_against_the_file_not_the_declaration(tmp_path):
|
||||
"""The hash is recomputed, so a forged manifest cannot certify itself."""
|
||||
from lib_py.loop import finalize
|
||||
|
||||
path = _report(tmp_path)
|
||||
finalize.finalize_report(path, "job1", 1)
|
||||
assert finalize.verify_manifest(path.parent, "job1").ok
|
||||
|
||||
manifest_file = finalize.manifest_in(path.parent)
|
||||
forged = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
forged["sha256"] = "0" * 64
|
||||
manifest_file.write_text(json.dumps(forged), encoding="utf-8")
|
||||
verdict = finalize.verify_manifest(path.parent, "job1")
|
||||
assert not verdict.ok and "changed after finalization" in verdict.reason
|
||||
|
||||
|
||||
def test_finalize_is_invalidated_by_a_rewritten_report(tmp_path):
|
||||
"""A new body needs a new finalization; the old hash is void immediately."""
|
||||
from lib_py.loop import finalize
|
||||
|
||||
path = _report(tmp_path, "first\n")
|
||||
finalize.finalize_report(path, "job1", 1)
|
||||
assert finalize.verify_manifest(path.parent, "job1").ok
|
||||
path.write_text("rewritten\n", encoding="utf-8")
|
||||
verdict = finalize.verify_manifest(path.parent, "job1")
|
||||
assert not verdict.ok and "changed after finalization" in verdict.reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs,fragment", [
|
||||
({"job_id": "other"}, "belongs to job"),
|
||||
({"expected_attempt": 9}, "belongs to attempt"),
|
||||
])
|
||||
def test_finalize_rejects_a_manifest_for_a_different_job_or_attempt(tmp_path, kwargs, fragment):
|
||||
from lib_py.loop import finalize
|
||||
path = _report(tmp_path)
|
||||
finalize.finalize_report(path, "job1", 1)
|
||||
args = {"job_id": "job1"}
|
||||
args.update(kwargs)
|
||||
verdict = finalize.verify_manifest(path.parent, **args)
|
||||
assert not verdict.ok and fragment in verdict.reason
|
||||
|
||||
|
||||
def test_finalize_requires_evidence_for_every_declared_side_effect(tmp_path):
|
||||
"""Partial evidence is no evidence: the work is not known to be complete."""
|
||||
from lib_py.loop import finalize
|
||||
|
||||
path = _report(tmp_path)
|
||||
finalize.finalize_report(path, "job1", 1,
|
||||
side_effects=finalize.parse_side_effects(["git_push=abc123"]))
|
||||
ok = finalize.verify_manifest(path.parent, "job1", expected_artifacts=["git_push"])
|
||||
assert ok.ok
|
||||
missing = finalize.verify_manifest(path.parent, "job1",
|
||||
expected_artifacts=["git_push", "api_call"])
|
||||
assert not missing.ok and "api_call" in missing.reason
|
||||
|
||||
|
||||
def test_finalize_rejects_a_malformed_side_effect_pair():
|
||||
from lib_py.loop import finalize
|
||||
with pytest.raises(ValueError):
|
||||
finalize.parse_side_effects(["nokey"])
|
||||
with pytest.raises(ValueError):
|
||||
finalize.parse_side_effects(["kind="])
|
||||
|
||||
|
||||
def test_finalize_missing_manifest_is_a_refusal_not_a_crash(tmp_path):
|
||||
from lib_py.loop import finalize
|
||||
d = tmp_path / "empty"
|
||||
d.mkdir()
|
||||
verdict = finalize.verify_manifest(d, "job1")
|
||||
assert not verdict.ok and "no completion manifest" in verdict.reason
|
||||
|
||||
|
||||
def test_publish_event_finalizes_before_it_publishes(mam_sandbox, tmp_path, monkeypatch):
|
||||
"""The CLI surface an agent actually calls."""
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import publish_event
|
||||
from lib_py.loop import finalize
|
||||
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
record = _load(reg, job_id)
|
||||
record["broker"] = {"host": "127.0.0.1", "port": 65432, "tls": False}
|
||||
Path(reg, job_id + ".json").write_text(json.dumps(record), encoding="utf-8")
|
||||
|
||||
path = _report(tmp_path, "the report\n", staged=True)
|
||||
rc = publish_event.main([
|
||||
"--registry-dir", reg, "--job", job_id, "--event", "completed",
|
||||
"--attempts", "1", "--finalize-report", str(path),
|
||||
"--side-effect", "git_push=deadbeef",
|
||||
])
|
||||
assert rc == 2, "the broker is unreachable, but finalization still happened"
|
||||
assert path.exists(), "the staged report was promoted"
|
||||
verdict = finalize.verify_manifest(path.parent, job_id)
|
||||
assert verdict.ok
|
||||
assert verdict.manifest["side_effects"][0]["kind"] == "git_push"
|
||||
|
||||
|
||||
def test_publish_event_reports_a_bad_side_effect_instead_of_publishing(mam_sandbox, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(mam_sandbox)
|
||||
from lib_py.delegation import publish_event
|
||||
reg = _reg(tmp_path)
|
||||
job_id = _make_job(reg)
|
||||
path = _report(tmp_path)
|
||||
rc = publish_event.main([
|
||||
"--registry-dir", reg, "--job", job_id, "--event", "completed",
|
||||
"--finalize-report", str(path), "--side-effect", "malformed",
|
||||
])
|
||||
assert rc == 1
|
||||
Reference in new issue
Block a user