<?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: Egor Komarov</title>
    <description>The latest articles on DEV Community by Egor Komarov (@odrin).</description>
    <link>https://dev.to/odrin</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%2F4044101%2Fe4f4fde3-fa4d-4545-a0a0-965b63af9f7a.jpg</url>
      <title>DEV Community: Egor Komarov</title>
      <link>https://dev.to/odrin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/odrin"/>
    <language>en</language>
    <item>
      <title>Why AI Coding Agents Need Work Attempts, Leases, and Checkpoints</title>
      <dc:creator>Egor Komarov</dc:creator>
      <pubDate>Tue, 28 Jul 2026 08:34:58 +0000</pubDate>
      <link>https://dev.to/odrin/why-ai-coding-agents-need-work-attempts-leases-and-checkpoints-53k4</link>
      <guid>https://dev.to/odrin/why-ai-coding-agents-need-work-attempts-leases-and-checkpoints-53k4</guid>
      <description>&lt;p&gt;We gave AI agents the ability to write code, run tests, open pull requests, and hand work to each other. Then we asked them to coordinate through a &lt;code&gt;TODO.md&lt;/code&gt; file and a shared chat window.&lt;/p&gt;

&lt;p&gt;That mismatch produces a whole category of bugs that have nothing to do with the model's intelligence.&lt;/p&gt;

&lt;p&gt;An agent picks up a task, gets halfway through, and its context window fills up. Another agent, running in a different terminal, grabs the &lt;em&gt;same&lt;/em&gt; task because nothing told it the work was taken. A third crashes mid-edit and leaves a task marked "in progress" forever — a tombstone no one can clear.&lt;/p&gt;

&lt;p&gt;None of these are reasoning failures. They're &lt;strong&gt;coordination&lt;/strong&gt; failures, and they appear the moment you run more than one agent, or one agent for longer than a single conversation.&lt;/p&gt;

&lt;p&gt;This article is about a data model that addresses them. It's drawn from &lt;a href="https://github.com/Odrin/rhizome-mcp" rel="noopener noreferrer"&gt;rhizome-mcp&lt;/a&gt;, an open-source MCP server I built for this problem, but the ideas are portable. &lt;strong&gt;Work attempts, leases, and checkpoints&lt;/strong&gt; are useful whether or not you ever install it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Agents are unreliable workers. Model them that way.
&lt;/h2&gt;

&lt;p&gt;Distributed systems settled this decades ago: never assume a worker will finish what it starts. Workers die. Networks partition. Processes get OOM-killed. The queue has to stay correct anyway.&lt;/p&gt;

&lt;p&gt;AI coding agents are unreliable workers with three recurring failure modes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;They crash.&lt;/strong&gt; The process is killed, the terminal closes, the laptop sleeps, the API call times out. Whatever the agent was holding is abandoned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They forget.&lt;/strong&gt; Context windows are finite. A long task eventually pushes its own starting instructions out of the window, and the agent loses the thread mid-flight — a failure mode that is unusually frequent and intrinsic to context-window-based agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They collide.&lt;/strong&gt; Two agents — or two runs of one agent, or agents from two different products — work the same repository and pick the same next task, because nothing shared tells them what is already in flight.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The classic tracker model (Jira, Linear, GitHub Issues) was built for &lt;em&gt;human&lt;/em&gt; teams, and it encodes two assumptions that quietly break here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permanent assignee.&lt;/strong&gt; You assign a ticket to a person and it stays theirs until reassigned. An agent has no permanent identity — it's a process that exists for one connection and is then gone. "Assigned to agent-7" means nothing an hour later, when agent-7 no longer exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;In Progress&lt;/code&gt; as a stored state.&lt;/strong&gt; A human moves a card to "In Progress" and moves it back when they're done. An agent that dies in "In Progress" never moves it back. The status becomes a lie the moment the process exits.&lt;/p&gt;

&lt;p&gt;Both assumptions share a root flaw: they treat &lt;em&gt;possession of work&lt;/em&gt; as durable state that the worker is trusted to clean up. Unreliable workers don't clean up.&lt;/p&gt;

&lt;p&gt;So don't store possession as a fact — &lt;strong&gt;derive it from a lease that expires.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Work attempts: possession as a time-boxed lease
&lt;/h2&gt;

&lt;p&gt;An issue is never "assigned." An agent &lt;strong&gt;claims&lt;/strong&gt; it, and the server returns a &lt;strong&gt;work attempt&lt;/strong&gt;: one leased execution of that issue by one session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;claim_issue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;response&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(trimmed)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attempt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"01KY7M1XVRE9SJ5H9JTNRRG5XG"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"issue_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"01KY7M1QMCGG3TEJ4WRSH3QVQN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"work"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lease_expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-23T14:08:47Z"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lease_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OwM1IEE3YD8XUqBwKKl6_D_IJxi9KKGq9f10a0NfxtU"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lease_expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-23T14:08:47Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties separate this from an assignment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lease expires.&lt;/strong&gt; The attempt is valid only until &lt;code&gt;lease_expires_at&lt;/code&gt;. To keep working, the agent calls &lt;code&gt;renew_attempt&lt;/code&gt; — a heartbeat. Stop heartbeating, because you crashed or hung or were killed, and the lease lapses. No cleanup is required from the dead worker. The server can reclaim the attempt from elapsed time alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The token is a capability, not an identity.&lt;/strong&gt; The raw &lt;code&gt;lease_token&lt;/code&gt; is returned exactly once, and &lt;strong&gt;only its hash is stored&lt;/strong&gt; server-side. Every mutating call on that attempt must present &lt;code&gt;attempt_id + lease_token&lt;/code&gt;. Whoever holds the token owns the attempt — no login, no registration. A brand-new session can resume an attempt if it kept the token, which is exactly what handoffs need, and the server never has to establish &lt;em&gt;who&lt;/em&gt; the caller is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Possession is enforced by the database, not by convention.&lt;/strong&gt; A partial unique index allows at most one active attempt row per issue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_one_active_attempt_per_issue&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;work_attempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issue_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two agents racing to claim the same issue: one &lt;code&gt;INSERT&lt;/code&gt; succeeds, the other hits a constraint violation and is told the issue is taken. The invariant doesn't depend on an application-level check with a TOCTOU gap — the table itself cannot hold two active attempts for one issue.&lt;/p&gt;

&lt;p&gt;One subtlety matters here. A lease can lapse in wall-clock time while its row still reads &lt;code&gt;status = 'active'&lt;/code&gt;. Before inserting a new attempt, the claim transaction materializes any lapsed active attempt as &lt;code&gt;expired&lt;/code&gt;. The partial unique index then protects the remaining race window: after expiry processing, only one concurrent insert can succeed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The attempt lifecycle
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[*]      -- claim_issue ------------------------&amp;gt; [active]
[active] -- renew_attempt (heartbeat) ----------&amp;gt; [active]
[active] -- finish_attempt (done / review) -----&amp;gt; [completed]   --&amp;gt; [*]
[active] -- finish_attempt (failed) ------------&amp;gt; [failed]      --&amp;gt; [*]
[active] -- finish_attempt (handoff) -----------&amp;gt; [interrupted] --&amp;gt; [*]
[active] -- lease lapses (no heartbeat) --------&amp;gt; [expired]     --&amp;gt; [*]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;expired&lt;/code&gt; is the only terminal state reached &lt;em&gt;without the agent doing anything&lt;/em&gt;. That's the point.&lt;/p&gt;




&lt;h2&gt;
  
  
  The key insight: &lt;code&gt;in_progress&lt;/code&gt; is not a status
&lt;/h2&gt;

&lt;p&gt;This is the decision everything else hangs on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;in_progress&lt;/code&gt; is not a stored issue status. It is computed.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An issue's &lt;em&gt;stored&lt;/em&gt; status is one of &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;ready&lt;/code&gt;, &lt;code&gt;blocked&lt;/code&gt;, &lt;code&gt;review&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;, &lt;code&gt;cancelled&lt;/code&gt;. There is no &lt;code&gt;in_progress&lt;/code&gt; value in that column. The server derives an &lt;strong&gt;effective status&lt;/strong&gt; at read time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Issue stored status: ready
        |
        +-- no active leased attempt
        |       -&amp;gt; effective status: ready
        |
        +-- active leased attempt
                -&amp;gt; effective status: in_progress
                         |
                         +-- lease expires
                                 -&amp;gt; attempt: expired
                                 -&amp;gt; effective status: ready
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that bottom branch again, because it's the whole argument. When the lease expires, nothing has to &lt;em&gt;reset&lt;/em&gt; the issue. The stored status was &lt;code&gt;ready&lt;/code&gt; the entire time; only the derived view changed, and it changes back on its own.&lt;/p&gt;

&lt;p&gt;Under this model, a crashed worker cannot leave a durable &lt;code&gt;in_progress&lt;/code&gt; status behind. The stuck-ticket problem is eliminated by the data model rather than repaired by a separate status-reset workflow.&lt;/p&gt;

&lt;p&gt;Compare with the stored-status approach, where a crash leaves a real &lt;code&gt;in_progress&lt;/code&gt; value that something must notice and reset. That "something" is where the bugs live: the cleanup never runs, or runs too aggressively, or races with a live worker.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lazy expiry and background cleanup
&lt;/h2&gt;

&lt;p&gt;Leases lapse in wall-clock time, but a database row doesn't update itself. Expiry is materialized two ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lazy expiry.&lt;/strong&gt; Operations that care about claimability — &lt;code&gt;claim_issue&lt;/code&gt;, &lt;code&gt;list_issues&lt;/code&gt;, &lt;code&gt;get_work_context&lt;/code&gt; — evaluate lease validity as they run and treat a lapsed lease as expired on the spot. The paths that matter are correct without waiting for a cleanup cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A background sweep.&lt;/strong&gt; A periodic loop finds active attempts past their lease, marks them &lt;code&gt;expired&lt;/code&gt;, appends an event, and releases the uniqueness constraint — preserving the attempt's notes and results. Nothing is deleted; the failed attempt stays queryable as execution history.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Expiry &lt;strong&gt;never rewrites the issue's stored status.&lt;/strong&gt; It removes the active lock, nothing more. Whether the issue becomes workable again is decided by its own stored state and blockers, exactly as if the attempt had never happened.&lt;/p&gt;

&lt;p&gt;An expired attempt cannot be resumed — its lease is dead. A fresh claim starts a new attempt, and thanks to checkpoints, it doesn't start from zero.&lt;/p&gt;




&lt;h2&gt;
  
  
  Checkpoints: so "resume" doesn't mean "restart"
&lt;/h2&gt;

&lt;p&gt;Recovering the &lt;em&gt;slot&lt;/em&gt; is half the problem. The other half is recovering the &lt;em&gt;work&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;An agent that spent forty minutes and half its context window exploring a codebase has built expensive state. If the next attempt rediscovers all of it, you pay for the same work twice.&lt;/p&gt;

&lt;p&gt;During an attempt, an agent writes &lt;strong&gt;attempt notes&lt;/strong&gt; in four kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;progress&lt;/code&gt; — an ordinary status update&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;finding&lt;/code&gt; — a notable technical discovery&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;warning&lt;/code&gt; — a risk or problem worth flagging&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;checkpoint&lt;/code&gt; — a &lt;strong&gt;restartable summary&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checkpoint is the one that matters. It's the agent writing a brief for its own successor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;save_attempt_note&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"checkpoint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Repository layer is implemented and tested. Claim transaction is stubbed."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Implement the claim transaction with BEGIN IMMEDIATE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Add the one-active-attempt concurrency test"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"important"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When any agent later requests context for that issue, the server assembles a bounded package that &lt;strong&gt;prefers the most recent checkpoint over the full note history&lt;/strong&gt;, plus the previous attempt's result summary and next steps. A new session — possibly a different agent product — starts from a tight brief instead of a cold read.&lt;/p&gt;

&lt;p&gt;This also makes &lt;em&gt;deliberate&lt;/em&gt; handoffs first-class. An agent that finishes with &lt;code&gt;outcome: interrupted&lt;/code&gt; and &lt;code&gt;interruption_reason_code: context_limit&lt;/code&gt; isn't failing. It's tapping out cleanly and leaving the baton where the next runner can find it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this looks like end to end
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;14:00 — Agent A claims ISSUE-42.
14:05 — Agent A saves a checkpoint after completing repository analysis.
14:08 — Agent A crashes and stops renewing the lease.
14:13 — The lease expires.
14:14 — Agent B claims ISSUE-42.
14:14 — Agent B receives Agent A's latest checkpoint and next steps.
14:30 — Agent B completes the issue.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue was never manually reassigned and never stored a stale &lt;code&gt;in_progress&lt;/code&gt; status. The first attempt became historical execution data, while the second attempt continued from its last useful checkpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  Token-efficient context
&lt;/h2&gt;

&lt;p&gt;Traditional APIs can afford to return generous JSON responses. Agent-facing APIs often cannot. Every unnecessary field consumes tokens, costs money, and competes with the model's working context. A sufficiently bloated response can contribute directly to the same failure mode the tracker is trying to prevent: the agent loses the thread.&lt;/p&gt;

&lt;p&gt;So the tool contract is built to return &lt;em&gt;the least data that is still sufficient&lt;/em&gt;, with budgets that are enforced and tested rather than aspirational.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compact list projections.&lt;/strong&gt; &lt;code&gt;list_issues&lt;/code&gt; returns identifiers, title, classification, and computed status fields — but &lt;strong&gt;not&lt;/strong&gt; the free-text &lt;code&gt;description&lt;/code&gt; and &lt;code&gt;acceptance_criteria&lt;/code&gt; bodies. In the project's own test fixture, a 100-issue page measures &lt;strong&gt;≈46 KB&lt;/strong&gt; compact versus &lt;strong&gt;≈582 KB&lt;/strong&gt; full: a ~12× difference over the same issues, driven entirely by whether unbounded bodies are included. An integration test asserts the compact response stays under 64 KB regardless of body size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graphs exclude free text at the SQL layer.&lt;/strong&gt; Graph queries select &lt;code&gt;NULL AS description, NULL AS acceptance_criteria&lt;/code&gt;, dropping the unbounded fields &lt;em&gt;before&lt;/em&gt; traversal. Response size scales with a &lt;code&gt;max_nodes&lt;/code&gt; bound you control, not with how verbose your issue bodies are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search returns snippets, never documents.&lt;/strong&gt; Full-text search (SQLite FTS5) returns a bounded snippet per hit, truncated in SQL. You get a pointer and a preview, then fetch the one thing you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delta sync instead of re-reading state.&lt;/strong&gt; Every mutation appends to an event log with a monotonic ID. &lt;code&gt;get_changes(since_event_id)&lt;/code&gt; returns only what happened since you last looked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One bounded context package.&lt;/strong&gt; &lt;code&gt;get_work_context&lt;/code&gt; returns a single curated bundle: the issue body, unresolved blockers, active decision summaries, the latest checkpoint, the previous attempt's next steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson generalizes past this one server. &lt;strong&gt;When your API consumer is an LLM, response shape is part of your performance budget.&lt;/strong&gt; Omit bodies by default, make expensive fields opt-in, ship a delta feed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why SQLite fits this workload
&lt;/h2&gt;

&lt;p&gt;A local SQLite database may sound modest compared with a networked database, but it matches this workload unusually well.&lt;/p&gt;

&lt;p&gt;The workload is one developer's fleet of agents on one machine: no multi-node requirement, no cross-region replication, no thousands of concurrent writers. What it does demand is &lt;strong&gt;atomic, crash-safe multi-table writes&lt;/strong&gt;. Claiming an issue must, in one indivisible step, create the attempt, record the issue version and event position it started from, and append the &lt;code&gt;attempt_started&lt;/code&gt; event. A partial write there would corrupt the coordination model.&lt;/p&gt;

&lt;p&gt;SQLite serializes writers and provides the transactional guarantees this coordination model needs, without requiring a separate database service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;journal_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- readers don't block the writer&lt;/span&gt;
&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;busy_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- wait for the lock instead of erroring&lt;/span&gt;
&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;synchronous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NORMAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- strong process-crash safety with a good speed/durability trade-off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WAL mode&lt;/strong&gt; lets readers proceed while a write is in flight, so &lt;code&gt;list_issues&lt;/code&gt; and &lt;code&gt;get_work_context&lt;/code&gt; reads never block the agent committing a claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serialized writes&lt;/strong&gt; are a feature here: SQLite gives you exactly the guarantee the one-active-attempt invariant needs. Write transactions stay short — no model calls, no filesystem scans, no graph building inside a transaction — and &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; takes the writer lock up front for known-write operations, avoiding mid-transaction upgrade deadlocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded retry&lt;/strong&gt; handles genuine contention: on &lt;code&gt;SQLITE_BUSY&lt;/code&gt;/&lt;code&gt;SQLITE_LOCKED&lt;/code&gt;, retry the whole transaction with backoff (≈25 ms, 75 ms, 200 ms). Domain and constraint failures are never retried.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;NORMAL&lt;/code&gt; is appropriate for this local developer-tool workload and protects well against application crashes. Deployments that prioritize the strongest available power-loss guarantees can choose &lt;code&gt;FULL&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;The pure-Go driver (&lt;code&gt;modernc.org/sqlite&lt;/code&gt;, no CGO) keeps the whole thing a single static binary with no system dependencies.&lt;/p&gt;




&lt;h2&gt;
  
  
  Optimistic concurrency and idempotency
&lt;/h2&gt;

&lt;p&gt;Physical write safety isn't enough; concurrent agents also produce &lt;em&gt;logical&lt;/em&gt; races. Two mechanisms cover them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimistic concurrency.&lt;/strong&gt; Meaningful issue mutations require an &lt;code&gt;expected_version&lt;/code&gt;. If the issue changed since you read it, the write fails with &lt;code&gt;VERSION_CONFLICT&lt;/code&gt; instead of silently clobbering someone else's change. No pessimistic locks, no transactions held open across think-time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency keys.&lt;/strong&gt; Mutations accept an optional idempotency key. Replaying the same key with the same request returns the original stored result instead of performing the work twice; replaying it with a &lt;em&gt;different&lt;/em&gt; request is rejected with &lt;code&gt;IDEMPOTENCY_CONFLICT&lt;/code&gt;. Agents retry constantly — flaky networks, ambiguous timeouts, an unclear response — so retries have to be safe by construction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dogfooding
&lt;/h2&gt;

&lt;p&gt;The project runs on this model. rhizome-mcp's own backlog isn't in GitHub Issues or a Markdown file — it lives in a rhizome-mcp database, and every unit of work was created, claimed, executed, and finished through the MCP server by coding agents.&lt;/p&gt;

&lt;p&gt;At the time of writing, the project database contains &lt;strong&gt;100 issues, 94 of them completed&lt;/strong&gt; — including the VS Code extension, the npm and Open VSX publishing pipelines, the SQLite concurrency layer, and the features described in this article. Agents claimed work, hit context limits, wrote checkpoints, handed off, occasionally crashed and had leases reclaimed. The backlog stayed coherent.&lt;/p&gt;

&lt;p&gt;That loop surfaced real bugs, too: a bundled-binary resolution path that silently reported success instead of failing hard, validation errors that named the wrong field, a status board that tried to open a temp file the editor's trust rules forbade. Dogfooding turns "it should work" into "it did, and here's the commit from when it didn't."&lt;/p&gt;




&lt;h2&gt;
  
  
  How this compares to other approaches
&lt;/h2&gt;

&lt;p&gt;Each alternative below is good at the problem it was designed for. The question is which problem that is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Markdown / &lt;code&gt;TODO.md&lt;/code&gt; handoff.&lt;/strong&gt; Zero setup and human-readable, but it offers no atomic claim, no lease, no crash recovery — and merge conflicts arrive as soon as two agents edit it. Workable for a single agent in a single session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosted-tracker MCP wrappers (Linear, Jira, GitHub Issues).&lt;/strong&gt; Genuinely useful when humans and agents share one project-management tool. They inherit the human-team model, though: permanent assignees, stored statuses, no lease or heartbeat primitive, plus network latency and rate limits on every call. There's no built-in notion of "this agent holds this task for fifteen minutes and auto-releases if it dies." You'd build the lease model on top, against an API that wasn't designed for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Knowledge-graph "memory" servers.&lt;/strong&gt; These give agents durable &lt;em&gt;facts&lt;/em&gt; — entities and relationships that survive a session. Valuable and complementary, but they model memory, not execution: no claiming, no lease, no attempt lifecycle. Memory and coordination are different problems.&lt;/p&gt;

&lt;p&gt;These aren't mutually exclusive. A hosted tracker may remain the right source of truth for product planning and human collaboration. A lease-based execution layer solves a narrower problem: coordinating unreliable agent workers while they perform the work.&lt;/p&gt;




&lt;h2&gt;
  
  
  How rhizome-mcp implements the model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime.&lt;/strong&gt; Go, single static binary, CGO-free. Startup resolves the project, opens SQLite, applies embedded checksummed migrations, verifies WAL and foreign keys, then serves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protocol.&lt;/strong&gt; Model Context Protocol over JSON-RPC 2.0, via the official &lt;a href="https://github.com/modelcontextprotocol/go-sdk" rel="noopener noreferrer"&gt;MCP Go SDK&lt;/a&gt;. &lt;strong&gt;Stdio&lt;/strong&gt; is the primary transport; an opt-in &lt;strong&gt;loopback-only HTTP&lt;/strong&gt; transport (strict Host/Origin validation, no auth, not for remote exposure) serves local clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage.&lt;/strong&gt; One SQLite database per project, &lt;code&gt;STRICT&lt;/code&gt; tables, &lt;code&gt;CHECK&lt;/code&gt;-constrained enums, an append-only event log as the audit and delta-sync backbone, FTS5 as a rebuildable search projection. The database lives outside the repository; the repo holds only a two-field pointer file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution model.&lt;/strong&gt; Issues, work attempts, attempt notes and checkpoints, bounded work context, and a review workflow pinned to an exact issue version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Determinism.&lt;/strong&gt; All time comes from an injected clock, so lease expiry, heartbeats, and cleanup are tested with a controllable fake clock rather than &lt;code&gt;sleep()&lt;/code&gt;. The architecture is hexagonal: domain and application logic behind ports, with MCP, CLI, and SQLite as adapters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# zero-install trial&lt;/span&gt;
npx rhizome-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or install the binary and wire up a client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/Odrin/rhizome-mcp/main/scripts/install.sh | sh
rhizome-mcp init
rhizome-mcp connect claude   &lt;span class="c"&gt;# or: codex | vscode | json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A VS Code extension provides one-click MCP registration and a status board; other install channels are listed in the repository.&lt;/p&gt;

&lt;h3&gt;
  
  
  Links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source, documentation, and the full MCP tool catalog:&lt;/strong&gt; &lt;a href="https://github.com/Odrin/rhizome-mcp" rel="noopener noreferrer"&gt;github.com/Odrin/rhizome-mcp&lt;/a&gt; (Apache-2.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model Context Protocol:&lt;/strong&gt; &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;modelcontextprotocol.io&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Takeaways, even if you never install it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Treat agents as unreliable workers.&lt;/strong&gt; Any coordination layer that assumes otherwise will surface those failures as bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't store possession — lease it.&lt;/strong&gt; A renewable lease with a heartbeat makes reclamation automatic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive &lt;code&gt;in_progress&lt;/code&gt;, never store it.&lt;/strong&gt; Possession becomes a computed property of a live lease, so stale state has nowhere to persist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkpoint restartable state.&lt;/strong&gt; Recovering the slot is cheap; recovering the work is what saves time and tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response shape is a performance budget when the client is an LLM.&lt;/strong&gt; Omit bodies by default, make heavy fields opt-in, ship a delta feed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Give your agents a queue that expects them to fail, and they'll coordinate like a system instead of colliding like a crowd.&lt;/p&gt;




&lt;p&gt;I'm especially interested in feedback from developers running multiple coding agents against the same repository. Compatibility reports from Copilot, Claude Code, Codex, and other MCP clients are particularly useful, as are examples of coordination failures this model does not yet handle well.&lt;/p&gt;

&lt;p&gt;The project is open source under Apache-2.0, and bug reports, design discussions, and contributions are welcome: &lt;a href="https://github.com/Odrin/rhizome-mcp" rel="noopener noreferrer"&gt;github.com/Odrin/rhizome-mcp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>tooling</category>
      <category>go</category>
    </item>
  </channel>
</rss>
