Founder pricing: Pro at $9.99/mo forever for the first 50 members. 18 of 50 left Claim yours →
All posts

Background jobs and AI coding agents: at least once means maybe twice

The SanuDesk team 5 min read
background jobs queues ai agents

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.

Run your own fleet of agents

SanuDesk is a free desktop app that tiles your Claude Code and Codex sessions and gives every project a Kanban board your agents work from.