fix(recovery): stop the durable wake-outbox marker double-counting as a delivered wake (BLO-18829) - #1101
fix(recovery): stop the durable wake-outbox marker double-counting as a delivered wake (BLO-18829)#1101kkroo wants to merge 3 commits into
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 |
1 similar comment
|
🔗 Paperclip issue: BLO-18829 |
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 1c4e815
Critical Issues (0)
Important Issues (1)
- [code / error handling]
server/src/services/recovery/service.ts:4194— The cleanup only removes the IOU after an inline enqueue succeeds. If the initial enqueue throws or returns null, the shared reconciler later delivers the wake and changes this row todispatch_recoveredinstead of deleting it, so the permanent phantomrunId: nullduplicate remains on exactly the durable-retry path this outbox exists to support. The cleanup-failure branch has a second problem: its comment says replay is idempotency-key deduplicated, but this recovery path explicitly documents that nothing dedupes onidempotencyKey, so a retaineddispatch_failedmarker can enqueue another run. TeachreconcileFailedWakeDispatchesto recognize these recovery IOU rows and delete them after successful replay (and handle cleanup failure without relying on unenforced idempotency), then add a regression test covering initial dispatch failure followed by successful reconciliation.
Strengths
- Moving marker cleanup outside the enqueue
trycorrectly avoids refunding an attempt after a wake was actually delivered. - Requiring a non-null
runIdin the test helper correctly distinguishes a delivered wake from its pre-commit IOU marker.
Recommended Action
- Address the Important retry-path issue before merge.
…oo (BLO-18829) Deleting the IOU only after a successful *inline* enqueue left the phantom on exactly the path the outbox exists to support. When the inline dispatch throws or returns null the marker survives commit as `dispatch_failed`, and reconcileFailedWakeDispatches -- which selects on status only -- replayed it and stamped it `dispatch_recovered`, retaining a payload-identical row with `runId: null` next to the real wake forever. The fix moved the double-count off the happy path rather than removing it. Mark the row with `dispatchRetry.deleteOnRecover` and teach the reconciler to delete rather than relabel when it is set. The flag rides inside the `dispatchRetry` envelope, not the wake payload, so it never reaches the replayed wake: the reconciler enqueues `originalOpts`, whose `payload` is the caller's untouched. Ordinary dispatch-retry rows are unmarked and still stamp `dispatch_recovered`. The cleanup-failure branch also claimed replay was `idempotencyKey` de-duplicated. It is not, and the same file says so 130 lines further down (BLO-18996 note on resolveSourceScopedStrandedRecoveryWakePlan): `enqueueWakeup` only writes the key, never reads it, and the sole reader (`findExistingRunLivenessContinuationWake`) is caller-side and scoped to run-liveness continuations. A marker left `dispatch_failed` after a delivered wake therefore enqueues a second real run. Retry the delete, then retire the row `dispatch_superseded` -- a status the reconciler never selects -- trading a duplicate agent run for a phantom diagnostics row, which is the right way round. Two regression tests, each verified against a control that reverts only the matching fix: - reconciler-delivered marker is deleted, not retained. Control (reconciler branch reverted): 2 rows remain instead of 1 -- the phantom beside the real wake, which is the reported defect exactly. - a marker whose delete fails is retired terminally and cannot be replayed. Control (fallback neutered): row stays `dispatch_failed`, i.e. replayable. issue-recovery-actions + heartbeat-wake-dispatch-retry + heartbeat-wake-terminal-failed-gauge: 127/127 green. tsc clean on all three changed files.
|
@ally please re-review at head 1. The phantom survived on the retry path. You were right that deleting only after a successful inline enqueue left the marker on exactly the durable-retry path the outbox exists for. Confirmed at Fix: the outbox row now carries 2. The idempotency comment was false, and worse than you flagged — it contradicted this same file. Fix: retry the delete, then retire the row Tests — each verified against a control reverting only its matching fix, because a test that passes both with and without the fix proves nothing:
Verification: Note |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 436007d
Prior Findings Dispositioned (1)
- prior:1c4e815 important 1 — still-present —
server/src/services/heartbeat.ts:25684— The successful reconciliation path now deletes the IOU, and the inline cleanup path retires it on repeated delete failure, but a delete failure in the reconciler still falls into the generic dispatch-failure catch after the real wake has already been enqueued.
Critical Issues (0)
Important Issues (1)
- [prior:1c4e815 important 1 / error handling]
server/src/services/heartbeat.ts:25684— A reconciler-side marker deletion failure can still enqueue a duplicate run.enqueueWakeuphas already delivered the real wake at line 25630; if thisDELETEthrows, the broad catch beginning at line 25704 treats cleanup as a failed dispatch and leaves/reschedules the row asdispatch_failed. Because this path does not deduplicateidempotencyKey, the next pass replays an already-delivered wake. The new cleanup-failure regression test covers only the inline path inrecovery/service.ts, not this reconciler branch.- Separate post-enqueue cleanup from dispatch failure handling here too: retry deletion and terminally retire the IOU if deletion still fails, then add a reconciler-side delete-failure test proving the marker cannot be selected again.
Strengths
- Keeping
deleteOnRecoverinside thedispatchRetryenvelope is sound: reconciliation replaysoriginalOpts, so the control flag does not enter the caller's wake payload. - The successful-reconciliation regression test now proves the retry-delivered marker is removed rather than retained as
dispatch_recovered. - Retiring an inline-cleanup failure is the right tradeoff: a visible terminal diagnostics row is preferable to an unbounded duplicate agent run.
Recommended Action
- Extend the terminal cleanup fallback to the reconciler path and cover its deletion-failure branch before merge.
Replacement for app-authored #1081 so the Ally GitHub App can provide the required independent review/approval.
Exact code head copied from #1081:
1c4e815507b00eacbbe2b85298e9f7e2e609daca.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