Found while fixing #13617, filed rather than folded in — a residual that fix deliberately does
not close.
What
#13617 fixed the SEQUENTIAL failure: a replica resuming from a run-state snapshot it had gone
stale on. The resume path now reads the shared sys_automation_run row.
It does not close the CONCURRENT one. AutomationEngine.resumeInternal guards against a
duplicate resume with this.resuming, an in-process Set. Across replicas there is no such
guard: two decisions for the same run arriving at the same moment on two replicas can both read
the same fresh row, both pass their own idempotency check, both call forgetSuspendedRun, and
both traverse forward — running the downstream side effects twice.
Why this is separate from #13617
The reported repro there is strictly sequential (human approvals seconds apart, one decision at
a time), and its ~60ms timing is the resume rebuilding the node it had just left, not two
concurrent approvals. So the authoritative read is the whole fix for that card, and this is a
different failure with a different remedy.
Why it needs a decision, not just a patch
Closing it needs a compare-and-set on the run’s advance so a loser learns it lost, and the two
obvious places to put it both widen a contract:
- an optimistic-version column on
sys_automation_run, checked on the consume — a schema change;
- a conditional delete on the
SuspendedRunStore interface (delete only if still parked at node N), which every implementation then owes.
Either is a contract question rather than an implementation detail, which is why this is a card
and not a follow-up commit. Worth sizing against real exposure first: it needs two decisions on
one run inside one resume window, so a flow with a single approver per level is not obviously
reachable, while parallel/any-of approvers and automated approve calls are.
Generated by Claude Code
Generated by Claude Code
Found while fixing #13617, filed rather than folded in — a residual that fix deliberately does
not close.
What
#13617 fixed the SEQUENTIAL failure: a replica resuming from a run-state snapshot it had gone
stale on. The resume path now reads the shared
sys_automation_runrow.It does not close the CONCURRENT one.
AutomationEngine.resumeInternalguards against aduplicate resume with
this.resuming, an in-processSet. Across replicas there is no suchguard: two decisions for the same run arriving at the same moment on two replicas can both read
the same fresh row, both pass their own idempotency check, both call
forgetSuspendedRun, andboth traverse forward — running the downstream side effects twice.
Why this is separate from #13617
The reported repro there is strictly sequential (human approvals seconds apart, one decision at
a time), and its
~60mstiming is the resume rebuilding the node it had just left, not twoconcurrent approvals. So the authoritative read is the whole fix for that card, and this is a
different failure with a different remedy.
Why it needs a decision, not just a patch
Closing it needs a compare-and-set on the run’s advance so a loser learns it lost, and the two
obvious places to put it both widen a contract:
sys_automation_run, checked on the consume — a schema change;SuspendedRunStoreinterface (delete only if still parked at node N), which every implementation then owes.Either is a contract question rather than an implementation detail, which is why this is a card
and not a follow-up commit. Worth sizing against real exposure first: it needs two decisions on
one run inside one resume window, so a flow with a single approver per level is not obviously
reachable, while parallel/any-of approvers and automated approve calls are.
Generated by Claude Code
Generated by Claude Code