How to Make an Approved Agent Action Execute Exactly Once
An agent pauses for approval, then resumes and runs the tool call it was waiting on. The obvious implementation — the tool function checks "is this approved?" and, if so, does the thing — has a race condition in it, and the race isn't exotic. A user double-clicks approve. A retried webhook fires twice. Two dashboard tabs are open. A worker gets redeployed mid-request and a duplicate picks up the same job. Any of these can call your resume path more than once, concurrently, on the same pending action. If nothing stops that, a refund goes out twice.
This isn't hypothetical
We tested it directly: cloned an open-source LangGraph starter kit that implements human-in-the-loop the textbook way — interrupt() to pause, Command({ resume }) to continue — and fired 8 concurrent resume calls at one paused approval. A real side-effect ledger, not the HTTP response, recorded the truth: 5 of 8 and, on a second run, 8 of 8 duplicate executions of a “cannot be undone” action. Every single caller was told it succeeded. Full method and raw output: the reproduction.
Why "check, then act" doesn't work
The naive version reads the approval status, sees approved, and runs the side effect:
// racy: there's a window between the read and the write
const approval = await db.query("SELECT status FROM approvals WHERE id = $1", [id]);
if (approval.status === "approved") {
await issueRefund(approval.orderId); // ← two callers can both get here
await db.query("UPDATE approvals SET status = 'executed' WHERE id = $1", [id]);
}Two concurrent callers can both read approved before either one writes executed. The check and the act are two separate operations, and the race lives in the gap between them. Adding a check earlier doesn't close the gap — it just moves it.
The fix: let the database do the deciding
Compare-and-set (CAS) collapses the check and the claim into one atomic operation, enforced by the database rather than application logic:
UPDATE approvals
SET executed_at = now()
WHERE id = $1
AND executed_at IS NULL
RETURNING *;Every concurrent caller runs this exact statement. The database serializes the writes: exactly one of them gets a row back — that caller won the race and is now the only one allowed to run the side effect. Everyone else gets zero rows back, atomically, with no window where two callers could both believe they won.
The part almost everyone gets wrong: what happens to the losers
A CAS gate stops double-execution. It does not, by itself, answer a real question: what should the 7 callers who lost the race actually be told? The tempting answer — “return an error, someone else is handling it” — is wrong, and it's wrong in a way that only shows up under load: it tells the calling agent the action failed when it may be about to succeed (or may have already succeeded) a few milliseconds later on the winning caller's connection. An agent that trusts that error will retry, or tell the user something failed when it didn't.
The fix is to make the losing path do a little more work instead of failing fast:
const claimed = await db.query(
`UPDATE approvals SET executed_at = now()
WHERE id = $1 AND executed_at IS NULL RETURNING *`,
[id],
);
if (claimed.rowCount === 0) {
// someone else claimed it — poll briefly for THEIR result instead of failing
const result = await pollForResult(id, { timeoutMs: 5000, intervalMs: 200 });
if (result) return result; // winner finished — replay their outcome, don't re-run
throw new ExecutionInDoubtError(id); // winner never finished in time — fail closed
}
const result = await issueRefund(claimed.orderId);
await recordResult(id, result); // so pollers above can find it
return result;Three things make this correct instead of just less-wrong: the loser never re-runs the side effect; a loser that catches the winner mid-flight gets the real outcome instead of a generic error; and a loser that waits out the timeout without seeing a result fails closed — it does not guess, and it does not run the action itself just because it got tired of waiting.
A short checklist
- The claim is a single atomic statement (CAS, or an equivalent unique-constraint insert) — never a separate read followed by a separate write.
- The column you gate on (
executed_at, an idempotency key, whatever) has a real uniqueness or null-check constraint behind it, not just application-level convention. - Losers poll for the winner's result instead of returning a bare error.
- A loser that never sees a result fails closed — “in doubt, don't run it again” beats “in doubt, run it and hope.”
- Test it under real concurrency —
Promise.allSettledfiring N genuinely simultaneous calls at the same row — not a single-threaded read of the code. The bug above only exists under a race a code review won't catch.
Where this fits
This is one piece of durable human-in-the-loop: the pause has to survive a crash, and the resume has to be exactly-once, and neither one alone is enough. agentFast implements this CAS-plus-poll pattern on every approval-gated tool call, and we test it the same way we tested the counter-example above — a real concurrent race, not a read of the code.
agentFast is the production layer — memory, 50 tools, observability, evals, guardrails, human-in-the-loop — for LangGraph, CrewAI, the Claude & OpenAI Agent SDKs & Vanilla. Own it for life.
Get agentFast — from $89