fix(issue-5): bind the auto-completion decision atomically to its attempt

Two findings from round 2, both reproduced before being accepted.

R2 -- attribution was compared before the terminal write and nothing tied the
two together. A job could complete, be re-dispatched and claimed again in that
window, and evidence from the first attempt then satisfied a status check and
finished the second one. Reproduced with real transitions: committed, with the
registry already on iteration 2.

This corrects my own earlier reasoning, and I want to be plain about it. Last
round I argued that the attempt guard could live entirely in the evidence layer
because that is where attempt identity lives. That holds for comparing evidence
to a dispatch, and it does not hold for atomicity: a precondition can only be
atomic with the commit if the transaction can read it under its own lock. So the
identity now lives in the record.

It is an opaque dispatch token, not the iteration counter. That distinction is
the part of the earlier rebuttal that still stands: iteration already means the
loop round, and comparing a dispatch attempt against it produces a guard that is
vacuous when they agree and wrong when they diverge. A token has no competing
meaning. It is minted when an attempt begins -- claim and redispatch -- and the
gate passes the token it decided against, so any intervening re-dispatch refuses
the commit.

R4 -- the intent identity was being emitted but nothing consumed it. A replayed
notification is re-published with a fresh wire sequence, so the monotonic guard
cannot recognise it; to that guard a retry looks new. The subscriber now
deduplicates on the stable intent identity, leaving the sequence guard as the
rule for ordinary events. Across a restart the disk fallback resolves terminal
status and a duplicate terminal is refused as an idempotent ack, so a replay
after restart cannot double-apply either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-06 23:21:11 +09:00
1 parent d87674679d
commit 01b465f50c
5 files changed
+246 -6

No files matched your search

+173 -1
View File
@@ -304,8 +304,13 @@ def _ready_job(tmp_path, mam_sandbox, *, backend="claude-code", opt_in=True,
payload = item.get("payload") or {}
if item.get("source_kind") != "herdr" and "job_id" in payload:
payload["job_id"] = job_id
# The dispatch record carries the record's own token for this attempt, which
# is what ties the evidence to the attempt that is currently running.
from lib_py.delegation import engine as _engine
token = _load(reg, job_id).get(_engine.DISPATCH_TOKEN_FIELD)
store.record_dispatch(job_id, dispatch["attempt_id"], dispatch["agent_session_uuid"],
dispatch["pane_process_generation"], workspace=str(mam_sandbox))
dispatch["pane_process_generation"], workspace=str(mam_sandbox),
dispatch_token=token)
store.mark_execution_anchor(job_id, workspace=str(mam_sandbox))
report = _report(tmp_path, body)
@@ -782,3 +787,170 @@ def test_o2_the_manifest_records_an_absolute_report_path(tmp_path, monkeypatch):
assert Path(manifest["report_path"]).is_absolute()
monkeypatch.chdir(REPO_ROOT)
assert finalize.verify_manifest(path.parent, "job1").ok, "verification survives a chdir"
# ==============================================================================
# 7. Cross-review round 2 regressions (job 4cfc1649)
#
# R2 (codex): attribution was checked before the terminal commit but nothing
# bound the two, so a job could complete, be re-dispatched and
# claimed again in between, and stale evidence finished the new
# attempt.
# R4 (codex): the intent identity was emitted but the real subscriber ignored
# it, so a replay with a fresh wire sequence was accepted twice.
# ==============================================================================
def test_r2_a_redispatch_between_the_check_and_the_commit_is_refused(
mam_sandbox, tmp_path, monkeypatch
):
"""The exact interleaving: complete, re-dispatch, claim again, then let the
original commit proceed. Evidence for attempt 1 must not finish attempt 2."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.delegation import engine
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
real_transition = engine.transition
fired = {}
def racing(job, request="merge", **kwargs):
if request == "completed" and kwargs.get("source") == "auto_complete" and not fired:
fired["yes"] = True
engine.transition = real_transition
real_transition(job, "completed", registry_dir=reg)
real_transition(job, "redispatch", registry_dir=reg,
source="registry_cli", fields={"iteration": 2})
real_transition(job, "claim", registry_dir=reg)
engine.transition = racing
return real_transition(job, request, **kwargs)
monkeypatch.setattr(engine, "transition", racing)
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
monkeypatch.setattr(engine, "transition", real_transition)
record = _load(reg, job_id)
assert result.committed is False and "dispatch token" in result.reason
assert record["status"] == "running", "the new attempt must still be running"
assert record["iteration"] == 2
assert "completed_by" not in record
def test_r2_evidence_from_a_superseded_dispatch_is_refused(mam_sandbox, tmp_path, monkeypatch):
"""Even without a race: evidence recorded against an older token is stale."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.delegation import engine
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
# a full re-dispatch mints a new token; the recorded evidence keeps the old
engine.transition(job_id, "completed", registry_dir=reg)
engine.transition(job_id, "redispatch", registry_dir=reg,
source="registry_cli", fields={"iteration": 2})
engine.transition(job_id, "claim", registry_dir=reg)
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed is False and "superseded dispatch" in result.reason
def test_r2_a_job_without_a_dispatch_token_is_refused(mam_sandbox, tmp_path, monkeypatch):
"""Nothing to bind the decision to is a refusal, not a pass."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.delegation import engine, mqtt_common
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
record = _load(reg, job_id)
record.pop(engine.DISPATCH_TOKEN_FIELD, None)
mqtt_common._atomic_write_record(job_id, reg, record)
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed is False and "no dispatch token" in result.reason
def test_r2_a_new_attempt_mints_a_new_token(mam_sandbox, tmp_path, monkeypatch):
monkeypatch.chdir(mam_sandbox)
from lib_py.delegation import engine
reg = _reg(tmp_path)
job_id = _make_job(reg)
engine.transition(job_id, "claim", registry_dir=reg)
first = _load(reg, job_id)[engine.DISPATCH_TOKEN_FIELD]
engine.transition(job_id, "progress", registry_dir=reg, fields={"note": "x"})
assert _load(reg, job_id)[engine.DISPATCH_TOKEN_FIELD] == first, (
"an ordinary transition must not mint a new attempt"
)
engine.transition(job_id, "completed", registry_dir=reg)
engine.transition(job_id, "redispatch", registry_dir=reg,
source="registry_cli", fields={"iteration": 2})
assert _load(reg, job_id)[engine.DISPATCH_TOKEN_FIELD] != first
def test_r2_the_token_precondition_is_enforced_by_the_engine(mam_sandbox, tmp_path, monkeypatch):
monkeypatch.chdir(mam_sandbox)
from lib_py.delegation import engine
reg = _reg(tmp_path)
job_id = _make_job(reg)
engine.transition(job_id, "claim", registry_dir=reg)
before = _fingerprint(Path(reg))
stale = engine.transition(job_id, "completed", registry_dir=reg,
expected_dispatch_token="not-the-current-token")
assert stale.committed is False and "dispatch token changed" in stale.reason
assert _fingerprint(Path(reg)) == before, "a refused precondition must not write"
token = _load(reg, job_id)[engine.DISPATCH_TOKEN_FIELD]
assert engine.transition(job_id, "completed", registry_dir=reg,
expected_dispatch_token=token).committed
def test_r4_the_subscriber_drops_a_replayed_notification_intent():
"""A replay is re-published with a fresh wire sequence, so the monotonic
guard cannot see it; the stable identity is what must be deduplicated."""
import types
from lib_py.delegation import job_subscriber
watcher = job_subscriber._Watcher({"j1"}, {"j1": None}, {"j1": 0})
def deliver(wire_seq, intent_seq):
payload = {"schema_version": 1, "seq": wire_seq, "job_id": "j1",
"event": "completed", "timestamp": "t", "detail": "d",
"data": {"notification_intent": {"seq": intent_seq,
"event": "completed",
"timestamp": "T"}}}
watcher.on_message(None, None, types.SimpleNamespace(
topic="python/mqtt/jobs/j1/events",
payload=json.dumps(payload).encode()))
deliver(1, 7)
deliver(2, 7) # the same intent, retried
assert watcher.events.qsize() == 1, "a replayed intent must not be acted on twice"
deliver(3, 8) # a genuinely different notification
assert watcher.events.qsize() == 2
def test_r4_events_without_an_intent_are_unaffected():
"""The existing sequence guard remains the rule for ordinary events."""
import types
from lib_py.delegation import job_subscriber
watcher = job_subscriber._Watcher({"j1"}, {"j1": None}, {"j1": 0})
def deliver(wire_seq):
payload = {"schema_version": 1, "seq": wire_seq, "job_id": "j1",
"event": "progress", "timestamp": "t", "detail": "d", "data": {}}
watcher.on_message(None, None, types.SimpleNamespace(
topic="python/mqtt/jobs/j1/events",
payload=json.dumps(payload).encode()))
deliver(1)
deliver(2)
assert watcher.events.qsize() == 2
deliver(2) # non-increasing: still dropped by the sequence guard
assert watcher.events.qsize() == 2