fix(recovery): stop the durable wake-outbox marker double-counting as a delivered wake (BLO-18829) - #1081
Conversation
…ed (BLO-18829) The durable wake outbox writes its IOU row into agent_wakeup_requests carrying the real wake's agentId, reason and payload, and previously retained it as dispatch_recovered after a successful post-commit dispatch. Because the marker is payload-identical to the wake it stands in for, every reader that does not filter on status counted it as a second delivered wake.
…sweep luck (BLO-18829) The raced-escalation test asserted attemptCount === 8, assuming all 8 concurrent sweeps escalate. Atomic escalation is one advisory-lock-serialized transaction and is slower, so later sweeps run their candidate query after the issue is already blocked and legitimately no-op. Probed: escalations, sweeps that saw the issue and attemptCount all agree, so no bookkeeping is lost -- assert that 1:1 correspondence instead of a scheduling coincidence.
|
🔗 Paperclip issue: BLO-18829 |
|
@ally please review at head 1c4e815 — focus on three things:
|
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
|
Superseded by #1101, which carries the exact same head under independent PR author kkroo so Ally can provide the required App approval. |
Thinking Path
Linked Issues or Issue Description
Refs #820 — this branches off its head
ef69a8716and targets its branch rather than pushing to it directly, the same way #1045 was landed.Refs BLO-18829 (
Stranded-escalation side effects escape when the expectedStatus CAS loses the race).Searched the open PR list for overlap: #1031 (
enlist plugin outbox writes in caller tx) is a different outbox in a different table, and the other openrecoveryPRs (#1065, #1048, #1035, #972, #960, #875) touch unrelated paths. No duplicate.What Changed
dispatchDurableRecoveryWakeOutboxRow— delete the marker on success instead of flipping it todispatch_recovered. The marker is an IOU, not a wake; once the debt is paid it has no reader. Durability is untouched: still inserted pessimistically inside the escalation transaction, still rolls back on a lost CAS, still survives a failed dispatch asdispatch_failedforreconcileFailedWakeDispatches.try. Onetrypreviously wrapped both theenqueueWakeupcall and the status flip, so a flip failure after a successful delivery returneddelivered: false— refunding an attempt that was actually spent. A cleanup failure is now logged and still reported as delivered, because it was.expectSourceScopedStrandedRecoveryActionnow requiresrunIdin its wake lookup. The helper already polled viawaitForValue, but the marker satisfied its payload predicate, so it latched onto a wake that was merely owed. With dispatch moved after commit, "the issue isblocked" no longer implies "the wake exists".attemptCount === escalatedTotalrather than a hard-coded8.Verification
Run locally against embedded Postgres at #820's head
ef69a8716plus these two commits:The three
BLO-18829outbox tests pass unchanged — they assert the failure path (dispatch_failedsurvives a thrown dispatch, a null dispatch stays retryable, a lost CAS rolls the row back), none of which this touches.Note on CI here:
pr.ymltriggerspull_requestonly onbranches: [master], soGeneral tests (server *)will not run on this PR — same caveat as #1045. The real signal appears once this lands in #820, which does target master.Before relaxing the
attemptCountassertion I probed rather than assumed, because "edit the test to match the code" is exactly how the original CAS defect shipped green:All three agree — zero CAS misses, zero lost bookkeeping. Atomic escalation is advisory-lock-serialized and therefore slower, so later sweeps run their candidate query once the issue is already
blocked, which is not a stranded candidate, and they legitimately no-op. Bisected too: the assertion passes ate06e2599band fails at0a8b93dc9, so it is a consequence of the atomicity commit, not of this change.attemptCount === escalatedTotalstates AC-2's actual property — one reused action, every escalation counted exactly once — where8was describing scheduling luck on the old non-atomic path.Risks
Low, and narrower than the status quo. Three things worth a reviewer's attention:
dispatch_recoveredaudit trail for this path. Deliberate: the real wake row is the audit record, and the marker's whole purpose ends at delivery. I checked every consumer ofdispatch_recovered—reconcileFailedWakeDispatchesselects onstatusonly (neverreason), and the github-webhook idempotency lists are scoped byidempotencyKey, which the marker suffixes with:outbox. Nothing reads a marker after delivery.enqueueWakeupand theDELETE) is unchanged in width — it previously sat betweenenqueueWakeupand the status flip. Replay goes throughoriginalOpts.idempotencyKey, which de-dupes.agent_wakeup_requestsread sites: the wake-budget, suppression and productivity-review paths use positive status allowlists (queued/deferred_issue_execution/claimed/…) and were never affected. The one genuinely corrupted production read wasgetWakeDiagnostics(issues.ts), which has no status predicate and matches onpayload->>'issueId'— this fixes it.Model Used
Claude Opus 4.5 (
claude-opus-4-5), 1M context, extended thinking, with tool use and code execution — run as the Paperclip CTO agent (claude_k8sadapter). Test runs, thePROBEinstrumentation and thegit bisectwere executed against embedded Postgres in the agent workspace, not inferred.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatedispatch_recoveredflip is rewrittenGeneral tests (server *)cannot run on this base; see Verification🤖 Generated with Claude Code