claimDelivery (added deliberately, with a good comment explaining why) stops two sweeps from
starting the same delivery. But the two writes that END an attempt are unguarded, so a slow
attempt that outlives its claim can still corrupt the row after another attempt has finished with
it.
The gap
The claim is a CAS that pushes next_attempt_at forward by CLAIM_HOLD_MS (5 min):
UPDATE … SET next_attempt_at = ?2 WHERE id = ?1 AND status = 'pending' AND next_attempt_at IS ?4
Both terminal writes then key on id alone — no status guard, no claim identity:
-- markDelivered (:161)
UPDATE … SET status='delivered', attempts = attempts + 1, … WHERE id = ?1
-- markAttemptFailed (:176)
UPDATE … SET status='pending', attempts = ?2, next_attempt_at = ?3, … WHERE id = ?1
attempts is also read from the row the sweep SELECTed (connections.ts:391 passes
row.attempts), so it is a stale absolute value, not an increment.
Three consequences
1. A delivered event is resurrected and delivered again.
t=0 sweep A claims D, starts the consumer (run_pipeline → Workflow). The attempt stalls.
t=5m the hold expires, D is due, sweep B claims and succeeds → markDelivered.
t=6m attempt A finally throws → markAttemptFailed → status='pending', due in ~1 min.
t=7m sweep C delivers it a third time.
This is precisely what the module promises it prevents: "the consumer's irreversible work
(creating a site, spending tokens) can't happen twice." site-builder builds and bills twice;
site-deploy deploys twice.
2. A dead-lettered delivery is silently marked delivered. Reverse order — the failing attempt
dead-letters D, then the slow successful attempt calls markDelivered on it. The row leaves
status='dead', so it vanishes from ?status=dead and from replay. A real failure disappears.
3. attempts can stall, so MAX_ATTEMPTS is never reached. Two overlapping attempts both write
attempts = <same stale value> + 1. A delivery that keeps overlapping never advances past N+1 and
therefore never dead-letters — it retries forever, which is the opposite of the bounded
at-least-once contract.
Suggested shape
The codebase already solves this exact class three times, all with a guarded terminal write:
setWorkCardProgress — guarded on status = 'running' so a late progress write can't resurrect
a finished card.
releaseSessionDriver — scoped to driver_id so a late release can't free someone else's claim.
AgentDO.alarm's saveJob — re-reads and matches startedAt before writing.
Give the claim an identity (a claim token column, or CAS on the exact next_attempt_at the
claimer wrote) and have BOTH terminal writes require it plus status = 'pending'. Make attempts
a relative increment (attempts = attempts + 1) rather than a stale absolute.
Verification
- A late
markAttemptFailed against a row already delivered changes nothing (0 rows).
- A late
markDelivered against a row already dead changes nothing.
- Two overlapping attempts increment
attempts twice, so the dead-letter bound still holds.
- Mutation-check: remove the guard and each test must fail — otherwise it isn't reproducing it.
Note on scope
The 5-minute re-execution window itself (consequence 1's first half) looks intentional — the
comment says a died-mid-flight attempt "simply becomes due again", trading possible re-execution
for recoverability. That trade is defensible and is not what this issue asks to change. The
corruption caused by the late writer is not part of that trade.
claimDelivery(added deliberately, with a good comment explaining why) stops two sweeps fromstarting the same delivery. But the two writes that END an attempt are unguarded, so a slow
attempt that outlives its claim can still corrupt the row after another attempt has finished with
it.
The gap
The claim is a CAS that pushes
next_attempt_atforward byCLAIM_HOLD_MS(5 min):Both terminal writes then key on id alone — no status guard, no claim identity:
attemptsis also read from the row the sweep SELECTed (connections.ts:391passesrow.attempts), so it is a stale absolute value, not an increment.Three consequences
1. A delivered event is resurrected and delivered again.
t=0sweep A claims D, starts the consumer (run_pipeline→ Workflow). The attempt stalls.t=5mthe hold expires, D is due, sweep B claims and succeeds →markDelivered.t=6mattempt A finally throws →markAttemptFailed→status='pending', due in ~1 min.t=7msweep C delivers it a third time.This is precisely what the module promises it prevents: "the consumer's irreversible work
(creating a site, spending tokens) can't happen twice." site-builder builds and bills twice;
site-deploy deploys twice.
2. A dead-lettered delivery is silently marked delivered. Reverse order — the failing attempt
dead-letters D, then the slow successful attempt calls
markDeliveredon it. The row leavesstatus='dead', so it vanishes from?status=deadand from replay. A real failure disappears.3.
attemptscan stall, so MAX_ATTEMPTS is never reached. Two overlapping attempts both writeattempts = <same stale value> + 1. A delivery that keeps overlapping never advances past N+1 andtherefore never dead-letters — it retries forever, which is the opposite of the bounded
at-least-once contract.
Suggested shape
The codebase already solves this exact class three times, all with a guarded terminal write:
setWorkCardProgress— guarded onstatus = 'running'so a late progress write can't resurrecta finished card.
releaseSessionDriver— scoped todriver_idso a late release can't free someone else's claim.AgentDO.alarm'ssaveJob— re-reads and matchesstartedAtbefore writing.Give the claim an identity (a claim token column, or CAS on the exact
next_attempt_attheclaimer wrote) and have BOTH terminal writes require it plus
status = 'pending'. Makeattemptsa relative increment (
attempts = attempts + 1) rather than a stale absolute.Verification
markAttemptFailedagainst a row alreadydeliveredchanges nothing (0 rows).markDeliveredagainst a row alreadydeadchanges nothing.attemptstwice, so the dead-letter bound still holds.Note on scope
The 5-minute re-execution window itself (consequence 1's first half) looks intentional — the
comment says a died-mid-flight attempt "simply becomes due again", trading possible re-execution
for recoverability. That trade is defensible and is not what this issue asks to change. The
corruption caused by the late writer is not part of that trade.