Tacnode™
Back to Blog
AI Engineering

Race Conditions Between AI Agents

Race conditions between AI agents corrupt shared state. The four shapes the bug takes, a test for whether you have one, and SQL fixes, cheapest to strongest.

Haitao Wang
Haitao Wang
Engineering
11 min read
Two race lanes, one for a chat agent and one for an email agent, both starting from the same read of 150 and finishing at different times, with the later finisher’s write of 195 overwriting the stored total while the true total is 235.

TL;DR: Race conditions between AI agents happen when two agents read the same mutable state, reason, and write, and the result depends on which one finishes first. Agents make the classic bug worse because the time between reading and writing is a model call, seconds rather than microseconds. The fix is to coordinate the write: a conditional update, a lock or a serializable transaction around the check and the act. That only works on state the coordinating store actually holds.

The bug report says the agent hallucinated. The refund agent approved a refund that put the customer over the monthly cap, and the cap check is right there in its tool definition. You replay the trace and the agent behaves perfectly: it reads this month’s refund total, sees room under the cap, and approves an amount that fits. The arithmetic is fine.

What the trace doesn’t show is the second agent. A chat agent and an email agent were both handling the same customer, both read the same total, and both approved. Each decision was correct against what it read. Together they were wrong.

That’s a race condition, and it looks like a reasoning failure because the logs of each agent are clean. This post covers why agents race more than ordinary services, the four shapes the bug takes, how to tell whether you have one, and the fixes that actually close it.

What Is a Race Condition Between AI Agents?

A race condition between AI agents occurs when two or more agents read and modify the same shared state concurrently, and the outcome depends on the order in which they finish. Each agent’s decision is correct against what it read, but the combined result is one no serial ordering of the agents would have produced.

The general definition is in the race condition glossary entry. What changes with agents is how often the window opens and how wide it is.

Why Agents Race More Than Services Do

Ordinary services race too. Agents race more for four reasons.

The window is a model call. A service reads a value and writes back a few lines of code later. An agent reads a value, puts it in a prompt, reasons, maybe calls another tool, and then writes. The authors of Atomicity for Agents note that a single LLM reasoning step typically takes several seconds. A read that’s correct when it happens can be stale long before the write.

Timing is nondeterministic. The same agent takes different amounts of time on the same task depending on the tokens it generates. Two agents that start a second apart can finish in either order.

Retries repeat side effects. Agent frameworks retry on timeouts and tool errors. A tool call that succeeded but timed out on the response gets executed twice.

Agents don’t know about each other. A chat agent and an email agent built by different teams share a customer, not a codebase. Neither was designed with the other in mind.

The Four Shapes

Most agent races fall into one of four shapes.

Check-then-act gets its own post: Agentic TOCTOU. Double execution is usually solved with idempotency keys. The inconsistent read isn’t a race between writers, but it causes the same symptom, a decision against a version of state that never existed, so it belongs on the list.

ShapeWhat happensAgent example
Lost updateTwo agents read a value, each computes a new one, the second write overwrites the firstTwo agents append notes to the same account summary; one set of notes disappears
Check-then-actAn agent checks a condition, then acts after another agent changed itTwo agents each see room under a refund cap and both approve
Double executionThe same action runs twice because of a retry or two agents picking up one taskA payment tool call times out, is retried, and pays twice
Inconsistent readAn agent reads related values from systems at different momentsBalance from one system, pending holds from another, three seconds apart

One Agent Can Race Itself

You don’t need two agents for the lost update. Model APIs let one response ask for several tool calls at once. OpenAI’s function-calling guide says “The model may choose to call multiple functions in a single turn,” and offers parallel_tool_calls: false to stop it (OpenAI docs). Anthropic’s docs say “By default, Claude may call multiple tools in a single response,” and leave execution to you: “The API doesn’t prescribe an execution order” (Claude docs).

If your runtime runs those calls concurrently, two writes from one agent’s single turn race exactly like writes from two agents. If both calls read, modify and write the same account summary and the runtime starts them together with asyncio.gather, one update can be lost, and neither call reports an error.

The same Anthropic page gives the right default: “Tools with side effects, shared state, or ordering requirements might be better run sequentially.” Run reads in parallel. Run writes to shared state one at a time, or make each write conditional so the order stops mattering.

A Worked Example: The Refund Cap

An illustrative scenario with constructed numbers. A customer’s refunds this month total $150. The cap is $200. Two agents handle the same customer at the same time.

text
t = 0.0 s   Chat agent  reads refunds_this_month = 150   (cap 200, room 50)
t = 0.3 s   Email agent reads refunds_this_month = 150   (cap 200, room 50)
t = 6.2 s   Chat agent  approves 40, writes refunds_this_month = 190
t = 8.9 s   Email agent approves 45, writes refunds_this_month = 195

Two bugs in one trace. The customer received $85 against $50 of room, which is the check-then-act race. And the stored total says 195 when the true total is 235, because the email agent overwrote the chat agent’s update, which is the lost update. The second bug hides the first: the next agent to read sees room that doesn’t exist.

The lost update hides the overspend: after the email agent’s write, the stored total is 40 lower than what was actually paid.

Step chart of the refund total over nine seconds. The stored total and the true total both start at 150 and both rise to 190 at 6.2 seconds. At 8.9 seconds the true total rises to 235 while the stored total drops to 195, leaving a 40-dollar gap the next agent can’t see.

Do You Actually Have One?

Before adding locks everywhere, apply two tests.

Contention, not tenancy. Do two actors write the same state inside the window, or does each agent work on its own? A thousand agents serving a thousand customers, each touching only its customer’s records, is partitioning. It needs capacity, not coordination.

Unmergeable state. When two writers hit this state at once, what resolves the conflict today? If the state is code, git resolves it. If it’s a single row updated in place through an operational database, the row lock resolves it. If it’s money, a quota, a budget, a counter or a schema, and the answer is “nothing, we hope the pipeline keeps up,” you have a race worth fixing.

How to Test for an Agent Race

Races rarely show up in unit tests, because unit tests run one agent at a time, and in staging the timing seldom lines up. Make it line up on purpose.

Stub the model, keep the tools. Replace the model call with a stub that returns a fixed decision after a fixed delay. That keeps the read-to-write window and removes the nondeterminism.

Force the interleaving. Start two agents on the same customer, hold both at a barrier after their reads, then release them. Afterward, assert the invariant: refunds at or under the cap, and a stored total equal to the sum of approved refunds.

Run every order. PostgreSQL tests its own concurrent behavior this way. Its isolation tester exists because these tests “require running multiple interacting transactions,” and “for each permutation of the session steps” it runs setup, the steps in that order, then teardown (isolation tester README). Two agents with one read step and one write step each have six possible orderings. Test all six.

Log what each write was based on. In production, store the version or value each agent read next to every write it makes. A write whose read is older than the row’s previous write is a race you can find with a query instead of by replaying traces.

Fixes, From Cheapest to Strongest

Serialize per key. Route all actions for one customer through a single queue consumer. Simple and effective, but it caps throughput per key and only works if every agent goes through the queue.

Do the arithmetic in the write. Replace read-compute-write with an update that computes from the current value. This removes the lost update.

sql
UPDATE refund_totals
SET    total = total + 40
WHERE  customer_id = 4417
  AND  total + 40 <= cap
RETURNING total;
-- zero rows returned means the cap would be exceeded: decline

This also closes the check-then-act race for this one counter, because the check (total + 40 <= cap) and the write are the same statement.

Optimistic concurrency. When the logic can’t fit in one statement, store a version number, read it with the data, and make the write conditional on it.

sql
UPDATE accounts
SET    status = 'escalated', version = version + 1
WHERE  account_id = 4417 AND version = 12;
-- zero rows: someone else wrote first; re-read and re-decide

Lock the row. SELECT ... FOR UPDATE inside a transaction blocks other writers until you commit. Correct, but holding a lock across a multi-second model call stalls every other agent waiting on that row. Read, release, reason, then re-check under lock.

Serializable transactions. PostgreSQL’s Serializable isolation level makes concurrent transactions behave as if they ran one after another. When two conflict, one is rolled back with a serialization error and the application retries it. This covers checks that span several rows, such as “total refunds across all of this customer’s orders,” which a single conditional update can’t express.

What Write Coordination Doesn’t Cover

Every fix above works on state the coordinating store holds. Two limits remain.

The external system of record. If the refund ledger lives in a payment processor’s system, your database can’t serialize writes to it. You can hold a reservation in a store you control, call the external system, then confirm or release it. That coordinates your agents; it doesn’t change the external ledger’s own rules.

The read side. An agent rarely decides from one value. It reads a balance from one system, a risk score from a pipeline and similar past cases from a vector store. Even with a perfectly serialized write, the decision was made on context assembled from three moments. The context gap entry describes this failure in general.

Where Tacnode Fits

The Tacnode Context Lake™ holds the decision context agents share: what they read before acting and what they decided. That splits into two cases, and the guarantee differs between them.

State Tacnode holds. Refund totals your agents maintain, per-agent limits, holds, task claims and agent actions can be written to Tacnode as the system of record. Then the fixes above run with full ACID transactions and serializable isolation by default: two agents approving against the same cap at the same moment can’t both commit; one sees the conflict and retries against the new total.

State your systems hold. Data that lives in your own ledger or CRM arrives by change data capture. Tacnode does not enforce anything inside that ledger. What it does is give each agent’s check an internally coherent view: raw rows and derived values, such as this month’s refund total, computed against the same set of ingested events, so the agent isn’t combining reads from different moments.

Each decision’s output is written back and becomes context for the next one. If your agents only retrieve documents and never contend on state, you don’t need any of this; pgvector handles retrieval well.

FAQ

AI AgentsRace ConditionConcurrencyMulti-Agent SystemsTransactions
Haitao Wang

Written by Haitao Wang

Nearly two decades at Microsoft, including the SQL Server engine. Leads engineering for the Tacnode Context Lake™.

Fresh Context Newsletter

The argument behind the posts — what breaks when AI acts on stale data, from the team building the Context Lake. About twice a month.

Subscribe on LinkedIn

Ready to see Tacnode Context Lake™ in action?

Book a demo and discover how Tacnode can power your AI-native applications.

Book a Demo