Background jobs and AI coding agents: at least once means maybe twice
The task was small: when a customer's subscription renews, credit their
account with bonus usage. The agent added a listener on Cashier's
WebhookReceived event, checked for invoice.payment_succeeded, and
called $user->addCredits(500). Four lines. One test webhook fired from
the Stripe CLI, one green assertion, shipped that afternoon.
Then the first of the month arrives and a batch of subscriptions renew
within the same few minutes. One handler runs long enough that Stripe
doesn't get its response back in time. Per Stripe's own webhook
documentation, that's expected, not a
bug: "webhook endpoints might occasionally receive the same event more
than once." The listener fires again, for the same invoice.
$user->addCredits(500) runs a second time.
Nobody wrote a bug. The listener did exactly what it was told, twice.
Why a background job that ran once in dev can run twice in production
A local test proves a handler is correct: given this payload, it does the right thing. It says nothing about how many times production will hand that same payload to the same handler. Engineers call the second question at-least-once delivery: the guarantee is that an event arrives, not that it arrives exactly once. A quick manual test in the Stripe CLI or a single queued dispatch only ever exercises the first question.
Stripe is explicit that duplicates aren't a corner case: event delivery retries automatically for up to three days with exponential backoff whenever an endpoint doesn't answer fast, and separately, "in some cases, two separate Event objects are generated and sent" for the same underlying change. Their own best-practice advice is to hand the payload to a queue so the endpoint can answer instantly. That's the right move, and it adds a second place the same work can run twice: the queue's own retry behavior now sits on top of Stripe's.
Two guardrails that look complete, and the gap between them
Laravel ships real protection for exactly this shape of problem. Each piece covers one part of it and leaves the rest for whoever writes the job to notice:
| Mechanism | Protects against | Misses |
|---|---|---|
ShouldBeUnique |
A second dispatch while one instance is still queued | Nothing once that instance starts running, or after a fresh retry gets dispatched |
WithoutOverlapping |
Two workers running the same key at the same time | You have to choose the key and add the middleware; a plain job has neither |
Default tries |
A job with no middleware attempts once, then stops | Add WithoutOverlapping without raising it, and a released job silently never runs again |
None of these are broken. ShouldBeUnique does exactly what its name
says, and so does WithoutOverlapping. An agent asked to "credit the user
on renewal" has no reason to reach for either: the four-line version
passes its test, and neither guardrail is the default on a plain queued
job.
What happens when two agents queue jobs against the same worker
Run more than one agent at a time and this gap widens before anyone notices it. One session adds the credit listener this week. A second, working a different card, adds a nightly job that recalculates loyalty tiers from the same credit history. Neither agent's task mentions the other's code, so neither adds a lock key that accounts for both. The recalculation job reads balances mid-retry and either undercounts or double-counts the very credits still being disputed.
It's the same coordination gap a clean git merge can still hide: two changes that merge without conflict and still contradict each other once they run. Worktrees isolate a checkout. They don't isolate a queue, a cache lock, or a customer's balance, the same way a feature flag stays shared state no branch boundary protects.
Where background jobs go wrong, and what they really cost
"It worked in the CLI test" proves the payload, not the count. stripe trigger or a manual dispatch fires a handler exactly once. Production
volume and retry backoff are the part no local test naturally exercises.
A double-credit is invisible until someone reconciles it. Nothing throws. The job succeeded, twice. The first sign is usually a support ticket or a monthly number that doesn't match, the same quiet failure shape as an uncontracted migration: correct code, wrong number of times.
Raising tries to fix missed retries can double the count instead. A
job that isn't idempotent and now gets three attempts instead of one
doesn't get three chances to succeed. It gets three chances to run.
The fix nobody schedules is checking the listeners written before
anyone was looking. Adding ShouldBeUnique is a one-line change once
you know a job needs it. Nobody goes back and audits the ones shipped
before that.
Give the job an owner before it ships twice
SanuDesk is a desktop app built around exactly that gap between a job
that passed its test and one that's safe to run more than once. Your
Claude Code, Codex and Gemini sessions tile into one grid, so a new
listener on a webhook event is something you read beside the diff that
added it, not something you reconstruct from a support ticket later. Work
arrives through a Kanban board: "credit the user on renewal" is a card,
and the review lane is where "does this need ShouldBeUnique" gets asked
before the card moves, not after the first double-credit. Because
SanuDesk is bring-your-own-model, that review can run on a cheaper model
than the one that wrote the listener. A recurring sweep, grepping every
queued job for a missing uniqueness or overlap guard, becomes a Loop,
journaled per run instead of an audit everyone means to get to. The grid
and board are in the free plan (see pricing).
Check one listener before your agent adds the next
Open the last background job or webhook listener an agent wrote for you.
Ask it one question a passing test never answers: if this ran twice for
the same input right now, would anything break? If you're not sure,
that's the job to add ShouldBeUnique or WithoutOverlapping to today,
before the first of next month makes the question urgent.
Download SanuDesk free to give every listener a review lane before it ships, or see how the grid, the board and Loops fit together on the features page.