AI SystemsSystem Design, simply · AI 25
Multi-agent orchestration: 15 of 30 jobs duplicated on average
Multi-agent orchestration simulation: workers duplicated 15 of 30 jobs per trial on average. Atomic claims with leases and one dispatcher each produced zero.

Drafted with AI assistance, then checked and edited by a human engineer · How we write
Multi-agent orchestration prevents competing workers from claiming the same open job by using an atomic claim or one dispatcher. In our simulation on 2026-11-13, both approaches produced zero duplicate completions across 100 trials each; uncoordinated workers duplicated 15 of 30 jobs per trial on average.
Why two agents take the same job
Atomic claim: one operation that checks a job is still open and marks it taken at the same time, so no other agent can slip in between. Lease: a claim that expires after a set time unless it is renewed.
AI agents that share a task list take the same job twice when the code says "find an open task, then mark it taken". Between the read and the write, a second agent can read the same task as open and start it too.
What the simulation measured
The script labs/ai-25/simulate-three-agents-duplicate-work.mjs (Node 22, standard library only) runs 3 simulated agents over 30 jobs. Each job stands in for an LLM call and takes a seeded 50 to 300 virtual ms; no model is called. In the naive mode an agent waits 10 to 60 ms between reading "open" and writing "taken", a setting of the simulation. In every trial, agent 2 crashes halfway through its third job. Each mode runs 100 seeded trials with the same job lengths.
| Mode | Jobs finished more than once (per trial) | Unfinished jobs (per trial) | Crashed job finished after | Counted claim/dispatch time per finished job |
|---|---|---|---|---|
| No coordination (check, then act) | 15 of 30 | 0.19 | only by chance | 0 ms |
| Atomic claim + lease | 0 | 0 | 650.2 ms (avg) | 2.11 ms |
| One orchestrator | 0 | 0 | 724.1 ms (avg) | 2.07 ms |
These virtual timings exclude polling, heartbeat traffic and result saves.

node labs/ai-25/simulate-three-agents-duplicate-work.mjs.Duplicates showed up in all 100 naive trials, with 16.32 extra completed runs per trial (a job finished three times counts twice there). The naive mode also left the crashed agent's job marked "taken" forever in 19% of trials; in the other trials it got done only because a second agent happened to be running it already.
Claim the job in one step
To remove the gap, an agent asks the shared store one question: "if job 7 is still open, mark it mine". The store answers yes or no in a single operation, and an agent that loses the race hears no and picks the next job. In the simulation that happened 0.6 times per trial, at a cost of one 2 ms round trip each time.
This is the competing consumers pattern: many workers pull from one queue, and each message goes to one consumer. In a database the claim is a conditional update, shown below. If your agents also publish events to each other, publish/subscribe is the related pattern where every subscriber gets every message instead.
A lease lets a crashed job move on
If the agent holding a plain claim crashes, the job stays claimed forever. So each claim gets a time limit, here 500 ms (a setting). When the lease runs out, another agent may claim the job. In the simulation the crashed agent's job was finished on average 650.2 virtual ms after the crash, and no job was left unfinished.
Message queues use the same idea. In Amazon SQS a received message is hidden from other consumers for a visibility timeout, 30 seconds by default, and comes back if it is not deleted in time. AWS suggests a heartbeat that extends the timeout while work continues, and notes that with at-least-once delivery a message can still arrive more than once. A lease cuts duplicates sharply, but some can still get through.
When a frozen agent wakes up late
Leases bring their own failure. An agent can pause longer than its lease (a long garbage collection, a slow network, a laptop lid closed), lose the job to another agent without knowing it, then wake up and save its old result. This run never hit that case: 0 results were rejected, because every job finished inside its lease.
The defence is to check the claim number at the moment of saving. Each claim gets a number that only goes up (claim #41, then #42). The save is a conditional write: "store this result only if the job's current claim is still #41". If agent 3 has since claimed it as #42, the late save from #41 is refused. Martin Kleppmann's post on distributed locking calls these fencing tokens and explains why the storage itself has to reject the lower number. Here the claim counter belongs to one job row, and the same database checks it when saving. A separate resource must enforce fencing itself; counters from different jobs cannot order writes to a shared resource.
The claim number protects the saved result, and a retry can still run the work again. Side effects that leave your system, such as emails or payments, need their own idempotency key, which we covered in AI agent crash recovery.
Multi-agent orchestration with one dispatcher
You can also stop agents choosing work at all, the way a lead agent hands parts to helpers. One orchestrator holds the queue and gives each job to exactly one agent. Agents send a heartbeat; when one goes quiet for 500 ms, its job goes back to the front of the queue. In the simulation this also finished every job exactly once, though there live agents always heartbeat on time. In production, delayed heartbeats can trigger reassignment while the original worker still runs, so the dispatcher also needs claim tokens checked when results are saved.
What we saw when we ran it
We ran node labs/ai-25/simulate-three-agents-duplicate-work.mjs on a Mac on 2026-11-13. It plays 3 simulated agents against 30 jobs, 100 seeded trials per mode, in virtual milliseconds with no real AI calls.
The printed output of the run, one row per mode with the naive check-then-act mode on top.
In the naive row, on average 15 of 30 jobs finished more than once per trial, with duplicates in 100% of trials. The atomic claim with a lease and the orchestrator both showed 0 duplicate jobs. Their counted claim or dispatch time was about 2.1 ms per finished job (2.11 ms and 2.07 ms), a figure that leaves out polling, heartbeat traffic and result saves.
For all that repeated work, the naive mode still left the crashed agent's job unfinished in 19% of trials, and its average wall time per trial was about 4660 ms against about 3027 ms for the claim mode. With a lease, the crashed job finished on average 650.2 ms after the crash.
The Competing Consumers pattern page in the Azure Architecture Center.
It describes the shape we tested: many workers pull from one queue and each message goes to one consumer.
The claim and the save, in SQL
Both checks fit in a plain PostgreSQL table. The claim is one UPDATE with the condition in its WHERE clause; the command tag reports how many rows changed, and 0 means another agent won. The save is a second conditional UPDATE that names the claim number it holds.
-- claim-and-save.sql: one table, two conditional writes
CREATE TABLE jobs (
id int PRIMARY KEY,
status text NOT NULL DEFAULT 'open', -- open, claimed, done
owner text,
claim bigint NOT NULL DEFAULT 0, -- only ever goes up
lease_until timestamptz,
result jsonb
);
-- claim job 7 for agent A1: open, or claimed with an expired lease
UPDATE jobs
SET status = 'claimed', owner = 'A1', claim = claim + 1,
lease_until = now() + interval '500 milliseconds'
WHERE id = 7
AND (status = 'open' OR (status = 'claimed' AND lease_until < now()))
RETURNING claim; -- no row back: someone else has it
-- save the result only if our claim (say 41) is still the current one
UPDATE jobs
SET status = 'done', result = '{"ok": true}'
WHERE id = 7 AND status = 'claimed' AND claim = 41;
-- UPDATE 0 means a newer claim took over: drop this result
-- renew the lease in a fresh transaction while the job runs
UPDATE jobs
SET lease_until = now() + interval '500 milliseconds'
WHERE id = 7 AND status = 'claimed' AND claim = 41
AND lease_until > now()
RETURNING claim;
We have not run this SQL; it shows the shape, and the simulation keeps its store in memory. Use short, separate transactions at Read Committed, and commit the claim before starting work. A competing update waits, then rechecks the predicate after the winner commits and gets no row back (transaction isolation). now() stays fixed for the whole transaction (time functions), so a long job renews its lease in a fresh transaction. If renewal returns no row, stop processing under that claim. At Repeatable Read or Serializable, retry the whole transaction after a serialization failure.
What this test does not show
- It is a simulation on a virtual clock with no model calls, not a benchmark of any AI product or agent framework.
- The duplicate count depends on the gap, job length and start jitter, all set in
CONFIG. Change them and the 15 changes. The shape of the result holds; the exact number is illustrative. - The atomic step is free here because the simulator runs on one thread. A real system needs a store that provides it.
- The lease is longer than the longest job, so no renewal is modelled. Real long jobs must renew it.
- The orchestrator never crashes in this run. In production it needs a standby or durable state.
Our take
Both fixes ended duplicates in our run, so pick by the shape of your system:
| Atomic claim + lease | One orchestrator | |
|---|---|---|
| Who picks the job | each agent | the orchestrator |
| Needs | a store with compare-and-set | one extra service that must stay up |
| Crash handling | lease runs out | missed heartbeats |
| Fits | many equal workers | a planner splitting one task into parts |
This article describes general patterns with a small simulation. It does not describe any company's private systems. Numbers come from one run on 2026-11-13 with assumed timings.
Frequently asked questions
Why do AI agents do the same task twice?
If each agent looks for an open task and only later marks it taken, a second agent can see the same task as open in between. Both start it. The fix is to claim the task in one atomic step, or to let one orchestrator hand out every task.
What is a lease in multi-agent systems?
A lease is a claim with a time limit. If the agent holding it crashes, the lease runs out and another agent can claim the job, so work is not stuck forever. A long job has to renew its lease before it expires.
Is an orchestrator better than letting agents claim tasks?
Both finished every job exactly once in our simulation. An orchestrator is simpler for the agents but is one more component that must stay up; claims need a shared store that supports an atomic compare-and-set.
Sources
- Competing Consumers pattern (Azure Architecture Center)learn.microsoft.com
- Amazon SQS visibility timeoutdocs.aws.amazon.com
- How to do distributed locking, Martin Kleppmann (2016)martin.kleppmann.com
- PostgreSQL documentation: UPDATEpostgresql.org
- PostgreSQL 18 documentation: Transaction Isolationpostgresql.org
- PostgreSQL 18 documentation: Date/Time Functionspostgresql.org