Repository navigation
fix(idempotency): a failed async run stops replaying its receipt (#3245) - #3299
Conversation
…still succeed (#3245) Requirements §10.10 and execution.md §Idempotency describe the replay-time check: ended/gone runs go fresh, a lease_expired FAILED is held for the agent timeout + 5 min after the failure (a heuristic, not a bound), the compare-and-delete that keeps two retries to one new run, and the residuals.
…; narrow failure-stamp read (#3245) db.idempotency_discard_completed_if_execution deletes a completed row only while it still names the given execution, so a slow retry cannot erase a faster retry's fresh record. db.get_execution_failure_stamp reads (status, error, completed_at) by primary key for the replay-liveness check.
… lease_expired hold window (#3245) execution_liveness(execution_id, agent_name) answers whether the run a stored receipt names can still succeed: live / succeeded / ended / gone / maybe_alive / indeterminate. A FAILED whose error starts lease_expired: (the slot reaper's mark) is maybe_alive until completed_at + the agent's timeout + SLOT_TTL_BUFFER; the tag is checked before the stamp or the timeout. Every doubt resolves to indeterminate (do not re-run). discard_replay_of wraps the compare-and-delete and swallows a delete error to None. Guards pin the tag parity with cleanup_service and that every fail_stale_slot_execution writer carries the tag.
_reclaim_ended_receipt runs between begin() and the replay branch in both admission seams (admit_chat_request for /chat, begin_task_idempotency for /task). When the replay would hand back a dispatch receipt whose run ended failed/cancelled/skipped, or whose row is gone, the row is compare-and-deleted and the key claimed again once, so an identical retry dispatches a new run. Live, succeeded, maybe-alive (lease_expired inside its window) and indeterminate runs replay as before; sched: keys and non-receipt snapshots are never checked. Each reclaim and each held replay is logged without the key. Tests cover AC1 on both routes (incl. #3145's pulled /chat receipt written by the real run_pulled_chat_turn), AC2 over the real /task endpoint, the race in both interleavings (and that an unconditional delete would start two runs), retry generations, the gate-first order, and structural pins for the seam wiring and the no-late-writer precondition.
task() and chat() copy the backend's X-Idempotent-Replay header into the body as idempotent_replay: true on a 200 replay, and the 409 in-flight receipt carries it too, so a caller can tell the run it already started from a new one. The client's own timeout-recovery receipts carry no field: absence is not proof of a fresh dispatch.
The server now starts a new run for a verbatim re-send after the run ended
failed or cancelled, so the delegation contract says so, in both
byte-identical copies:
- Confirmed `failed` or `cancelled`: re-send word for word to retry;
same `execution_id` back: `set_reminder`, end your turn.
The second half covers a lease_expired failure, which keeps replaying its
receipt until its hold window ends. MAX_CONTRACT_CHARS is deliberately
raised 1650 -> 1730 (contract now 1,720 chars), and chat_with_agent's lead
drops "(the primary way to use sub-agents)" so its published description
stays inside Claude Code's 2,048-char cap (2,030; 2,066 without the trim).
…ocs (#3245) Requirements §37.5 gains the confirmed-failure rule, the 1,650 -> 1,730 budget raise and the lead trim (chat_with_agent publishes 2,030 chars). The idempotency-keys flow documents the liveness check, the compare-and-delete, the reclaim/hold log lines and the MCP idempotent_replay marker; the MCP and agent-to-agent flows, the Chat API idempotency section (outcome table and a dispatch -> fail -> retry sequence), the MCP FAQ and integration page, and the two agent guides now say a confirmed failed/cancelled run is retried by a word-for-word re-send.
… without dedupe (#3245)
…ipt; name both guarded writers; lease_expired retry note (#3245)
|
@webmixgamer: this PR changes the delegation contract from #3240, so I'd like your review of that part.
|
/review ReportBranch: Intent: after an async run ends
Execution coverage (Step 2.5)
Fix mutation (run locally in a scratch worktree, then restored):
Local runs: Critical Findings (block merge)None. Informational Findings (review required)[I1] Test gaps: no PostgreSQL proof of the new DELETE/SELECT (Confidence: 6/10) Clean Categories
Low confidence (appendix)
Summary
🤖 Generated with Claude Code |
dolho
left a comment
There was a problem hiding this comment.
Approved — /review found no blocking findings; see review comment above.
…ep-both on append-only files
Four user-docs files conflicted with this train's siblings (#3294, #3297, #3299, #3303), which documented their own changes on the same lines. Each hunk keeps both sides' facts once: dev's new text, plus this sync's additions (wired-boundaries list, receipt contract, new FAQ entries, bound-widget field rules). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
merge-train (2026-10-07): merged as part of train #3329 (green). Before the squash, |
Fixes #3245
What was wrong
An async
/task(and a sync/taskor pulled/chatthat timed out) stores a dispatch receipt (async_mode: true, statusaccepted/queued/queued_timeout) as itscompletedidempotency row at dispatch time. Nothing revisited that row, so for 24 h an identical request got the dead run's receipt back even after the run failed. A caller retrying word for word, as the delegation contract tells it to, was stuck. The MCP client also droppedX-Idempotent-Replay, so an MCP caller could not tell a replay from a fresh receipt.What changes
dispatch_admission_service._reclaim_ended_receiptruns right afteridempotency_service.begin(...)in bothadmit_chat_request(/chat) andbegin_task_idempotency(/task). When the stored snapshot is a dispatch receipt, it asksidempotency_service.execution_liveness(...)whether that run can still succeed. Onlyended(failed/cancelled/skipped) andgone(no row) reclaim: the row is compare-and-deleted (db.idempotency_discard_completed_if_execution, only while it still names the dead execution id) and the key is claimed again once.live,succeeded,maybe_aliveandindeterminatereplay as before. The check is read-time only: no terminal writer gains a side effect (bug: recovery paths write execution terminals without closing the paired activity — agent shows as running for up to 2h, then logs a fabricated 120-min failure #1804), and rows written before this fix heal on their next retry.lease_expiredhold. A FAILED that the slot reaper wrote (lease_expired:error prefix) may still report success, so it replays untilcompleted_at + agent timeout + 300 s, then a retry starts fresh.task()andchat()mark a replayed 200 body, and the 409 in-flight receipt, withidempotent_replay: true.Confirmed failed or cancelled: re-send word for word to retry; same execution_id back: set_reminder, end your turn.The contract length pin moves 1650 → 1730 (a deliberate raise). Thechat_with_agentlead drops " (the primary way to use sub-agents)" so its published description stays under Claude Code's 2,048-char cap (2,030).Behaviour change
failed,cancelledorskipped, or whose execution row is gone, now starts a new run. A re-send while the run is queued, running or pending retry, or after it succeeded, still replays the original.lease_expiredfailure keeps replaying through its window (the agent's timeout + 5 min after the failure), then a re-send starts a new run.sched:keys (the scheduler's own) are never reclaimed. A gated skill request is still answered by its gate record first.Coverage notes
/chatqueued_timeoutreceipt (pull pilots): there is a test that writes that receipt through the realrun_pulled_chat_turntimeout branch, fails the run, and admits the identical retry fresh.idempotent_replay(notreplayed), andexecution_livenessis a reusable "can this run still succeed?" helper for it.idempotent_replayis positive evidence only. Its absence does not prove a fresh dispatch: the client's own timeout-recovery receipts carry none.Risks left (documented in
docs/memory/architecture/execution.md§Idempotency)lease_expiredhorizon is a heuristic, not a bound. It is anchored at the failure time (completed_at), because the reaper fires only after the slot TTL from acquire has already run out. Residuals: (1) the agent's restart sweep re-posts persisted results with no age limit, so a restart after the window can still land SUCCESS beside a fresh run; (2) only the two slot-reaper writers carry the tag (AST-pinned); the untagged sweeps go fresh at once; (3) the tag does not mean "async": sync push rows are tagged too, and the hold is conservative protection for every tagged row; (4) the horizon uses the agent's current timeout, and nothing wakes the caller when the window ends (the contract line tells it to set a reminder); (5) a late SUCCESS landing in the milliseconds between the liveness read and the delete can start a fresh run beside it.effect:{execution_id}), and a new attempt has a new id.in_flightwhen its run died keeps answering 409 for 24 h. A safe read-time reclaim needs owner tokens on claims first: bug: an /chat or /task idempotency claim left in_flight by a dead process answers 409 for 24 h #3298.Tests
tests/unit/test_3245_async_replay_after_failure.py(new): the liveness verdict matrix; thelease_expiredtime axis at the boundary (T+899 sreplays,T+900 sandT+901 sfresh) on a fixed clock; the compare-and-delete (a slow retry cannot erase a faster retry's fresh record, both interleavings, plus a teeth case where an unconditional delete starts two runs); both seams; the pulled-/chatreceipt;sched:and gated-skill carve-outs; AC2 end to end over the realexecute_parallel_task(dispatch, fail, identical re-send gets a newexecution_idwith no replay header, a third send while running replays the new id); AST guards for seam wiring, the tag writers and "nothing writes a claim after its receipt".tests/unit/test_ent568_delegation_contract.py: parity, budget and classification with the new line.src/mcp-server/src/task-receipt.test.ts,delegation-contract.test.ts: the replay marker ontask()/chat()/ 409, absent without the header, and kept throughrunAgentChat.devtoo.Local proof
Targeted only, per this PR's verification policy; CI runs the full suites. Local interpreter is Python 3.14 (CI is 3.13).
tests/unitthat exercises the admission seam, the idempotency service/DB layer, the platform prompt or the contract, plus the registry guard), pytest-randomly seeds 1 / 12345 / 99999: 2885 passed / 0 failed, 2884 / 1, 2885 / 0 (5 skipped each). Every failure and error is identical on a cleandevworktree with the same seeds: 20 setup errors intest_3210_room_oversized_reply.py(aconftestimport-order issue, 35/35 alone) and one DNS-dependenttest_736_a2a_outbound_call.pycase on seed 12345.devfail as expected: backend 71 failed / 61 passed (132 passed on the branch); MCP marker tests 4 failed / 36 passed (40 passed on the branch). Reverting only the two seam calls turns 24 tests red on their assertions; dropping the CAS'sexecution_idpredicate turns 5 red, itsstatuspredicate 1.npm test729 / 729,npm run buildexit 0.tests/lint_sys_modules.py,tests/lint_root_test_placement.py: exit 0./verify-local, a live stack, PostgreSQL.Follow-ups
in_flightrows).