<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: nischita sadanand</title>
    <description>The latest articles on DEV Community by nischita sadanand (@nischita_sadanand_cb1c37a).</description>
    <link>https://dev.to/nischita_sadanand_cb1c37a</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2967174%2F776c73d4-a2a5-44e5-8f5a-595b52831bc1.jpg</url>
      <title>DEV Community: nischita sadanand</title>
      <link>https://dev.to/nischita_sadanand_cb1c37a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nischita_sadanand_cb1c37a"/>
    <language>en</language>
    <item>
      <title>Two Agents, One Task: The Race Condition Hiding in Your Multi-Agent System</title>
      <dc:creator>nischita sadanand</dc:creator>
      <pubDate>Wed, 17 Jun 2026 00:26:38 +0000</pubDate>
      <link>https://dev.to/nischita_sadanand_cb1c37a/two-agents-one-task-the-race-condition-hiding-in-your-multi-agent-system-3ja2</link>
      <guid>https://dev.to/nischita_sadanand_cb1c37a/two-agents-one-task-the-race-condition-hiding-in-your-multi-agent-system-3ja2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb9aitlcevjgec4wf7old.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb9aitlcevjgec4wf7old.png" alt=" " width="800" height="655"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Description&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The race condition and the vanishing-task bug that show up the moment you run more than one AI agent against a shared queue — and the decades-old fix that's quietly missing from modern agent tooling.&lt;/p&gt;

&lt;p&gt;You wire up a few agents to chew through a queue of work. It runs. You ship it. Then one day the same email goes out twice, or a task you know you queued never finishes, and there's no error anywhere to explain it. Welcome to the two hardest bugs in distributed work — and they show up the moment you run more than one&lt;br&gt;
agent against a shared queue.&lt;/p&gt;

&lt;p&gt;This is a walkthrough of those two bugs, why the obvious fixes don't work, and the small, boring mechanism that does. The mechanism isn't new — it's how databases and job queues have worked for decades — but it's quietly missing from a lot of the agent tooling being written right now, because that tooling is mostly built by people thinking about prompts, not about what happens when a process dies.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Bug 1: the same task runs twice&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's the naive way every multi-agent system starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pythontask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_next_pending&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# read a pending task
&lt;/span&gt;&lt;span class="nf"&gt;mark_in_progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# claim it
&lt;/span&gt;&lt;span class="nf"&gt;do_the_work&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. It is fine — with one agent. With two, picture the timeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agent A runs get_next_pending() and reads task 42.&lt;/li&gt;
&lt;li&gt;Agent B runs get_next_pending() one microsecond later and also reads task 42, because A hasn't marked it yet.&lt;/li&gt;
&lt;li&gt;Both mark it in progress. Both do the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The work runs twice. If the work is "call the payments API" or "send the email," you now have a duplicate charge or a duplicate message, and nothing logged an error, because from each agent's point of view nothing went wrong. The root cause is that "read a pending task" and "mark it taken" are two&lt;br&gt;
separate operations, and anything can happen between them. This is a classic race condition, and you cannot fix it by being careful in application code. No amount of "check again before marking" closes the gap — it just makes the window smaller. The fix has to make read-and-mark a single, indivisible step.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Bug 2: the task that silently vanishes&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Suppose you fix bug 1 and tasks are claimed cleanly. Now an agent claims task 42, marks it in progress, and then — mid-work — the process is OOM-killed, or the LLM call hangs forever, or the container restarts. &lt;/p&gt;

&lt;p&gt;Task 42 is now marked "in progress." Forever. No agent is progressing it. It will never be completed, and no one will tell you. The work is simply gone. &lt;/p&gt;

&lt;p&gt;The instinct is: "I'll add a sweeper that retries tasks stuck in progress too long." But how long is too long? You genuinely cannot tell, from the outside, the difference between an agent that died and an agent that's just slow on a hard task. Retry too eagerly, and you've reintroduced bug 1 — now the slow-but-alive agent and the retry agent both run task 42. Retry too late, or never, and you're back to vanishing tasks. No fixed timeout's correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mechanism: leases with heartbeats&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix for both bugs is the same idea, and it's the same one a locker that auto-releases an unclaimed reservation uses, or that a DHCP server uses to hand out IP addresses: don't grant ownership forever. Grant a lease — ownership for a bounded window that the holder must actively renew.&lt;/p&gt;

&lt;p&gt;Three pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Atomic claim&lt;/strong&gt;. Claiming a task — moving it from "pending" to "leased by me" — happens as one indivisible operation. In the implementation, this lives in a single Redis Lua script, because Redis runs a Lua script start-to-finish with nothing else interleaved:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="n"&gt;lua&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LPOP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;-- take from pending&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'task:'&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="s1"&gt;':token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="c1"&gt;-- stamp owner&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZADD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;-- record expiry&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LPOP and the ownership stamp can't be split apart, so two agents physically&lt;br&gt;
cannot receive the same task. Bug 1, closed — not made less likely, closed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Heartbeats distinguish slow from dead&lt;/strong&gt;. A leased task carries an expiry time. While an agent works, it periodically renews the lease (a heartbeat), pushing the expiry forward. A slow-but-alive agent keeps heartbeating and keeps its task. A dead agent stops heartbeating — and that, finally, is a reliable signal of death, because it's the agent actively asserting "still alive" rather than us guessing from the outside.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The reaper requeues expired leases&lt;/strong&gt;. A background loop finds leases whose expiry has passed and returns those tasks to the pending pool. The crashed agent's task becomes claimable again, automatically, and gets picked up by a healthy agent. Bug 2, closed.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ownership token matters for the edge case: when a dead agent's task is reaped and reassigned, the original agent might briefly come back to life and try to complete "its" task. The completion is rejected because its token no longer matches the current owner — so a zombie can't clobber the agent that legitimately took over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One design decision worth naming&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a task is reaped, does it go to the back of the pending queue or the front? The implementation puts it at the front. The reasoning: a task whose agent just died has already waited; sending it to the back of a long queue delays it twice. Prompt retry of already-attempted work is usually what you want. It's a small choice, but it's the kind of thing that separates a library someone thought about from one that just wraps a queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this is worth a library&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;None of these mechanisms are novel — leasing is decades old. The point is that they're easy to get subtly wrong and quietly missing from a lot of current agent frameworks, which handle the happy path and stop. The hard part isn't the idea; it's making the claim genuinely atomic, getting the token check right so zombies can't double-complete, and proving it under contention. (The&lt;br&gt;
accompanying implementation ships a test that fires 20 workers at 200 tasks and asserts every task is claimed exactly once — that test is the real deliverable; the rest is plumbing.)&lt;/p&gt;

&lt;p&gt;If you run multiple agents against shared work, you will hit both of these bugs. Better to borrow a forty-year-old solution than to rediscover it in production at 2am.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Implementation is open source: &lt;a href="https://github.com/nischita44/agent-lease" rel="noopener noreferrer"&gt;agent-lease&lt;/a&gt; on GitHub.&lt;br&gt;
Corrections and counterpoints welcome — especially if you've solved this differently. How are you handling crash recovery in your agent systems?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>distributedsystems</category>
      <category>llm</category>
      <category>python</category>
    </item>
  </channel>
</rss>
