cd /news/ai-agents/why-ai-coding-agents-need-work-attem… · home topics ai-agents article
[ARTICLE · art-76637] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Why AI Coding Agents Need Work Attempts, Leases, and Checkpoints

A developer built an open-source MCP server called rhizome-mcp that introduces work attempts, leases, and checkpoints to solve coordination failures in AI coding agents. The system uses expiring leases and capability tokens instead of permanent assignments, preventing bugs where multiple agents grab the same task or leave tasks stuck in progress.

read13 min views1 publishedJul 28, 2026

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 TODO.md

file and a shared chat window.

That mismatch produces a whole category of bugs that have nothing to do with the model's intelligence.

An agent picks up a task, gets halfway through, and its context window fills up. Another agent, running in a different terminal, grabs the same 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.

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

This article is about a data model that addresses them. It's drawn from rhizome-mcp, an open-source MCP server I built for this problem, but the ideas are portable. Work attempts, leases, and checkpoints are useful whether or not you ever install it.

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.

AI coding agents are unreliable workers with three recurring failure modes:

The classic tracker model (Jira, Linear, GitHub Issues) was built for human teams, and it encodes two assumptions that quietly break here.

Permanent assignee. 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.

** In Progress as a stored state.** 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.

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

So don't store possession as a fact — derive it from a lease that expires.

An issue is never "assigned." An agent claims it, and the server returns a work attempt: one leased execution of that issue by one session.

// claim_issue -> response (trimmed)
{
  "attempt": {
    "id": "01KY7M1XVRE9SJ5H9JTNRRG5XG",
    "issue_id": "01KY7M1QMCGG3TEJ4WRSH3QVQN",
    "kind": "work",
    "status": "active",
    "lease_expires_at": "2026-07-23T14:08:47Z"
  },
  "lease_token": "OwM1IEE3YD8XUqBwKKl6_D_IJxi9KKGq9f10a0NfxtU",
  "lease_expires_at": "2026-07-23T14:08:47Z"
}

Three properties separate this from an assignment.

The lease expires. The attempt is valid only until lease_expires_at

. To keep working, the agent calls renew_attempt

— 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.

The token is a capability, not an identity. The raw lease_token

is returned exactly once, and only its hash is stored server-side. Every mutating call on that attempt must present attempt_id + lease_token

. 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 who the caller is.

Possession is enforced by the database, not by convention. A partial unique index allows at most one active attempt row per issue:

CREATE UNIQUE INDEX idx_one_active_attempt_per_issue
ON work_attempts(issue_id)
WHERE status = 'active';

Two agents racing to claim the same issue: one INSERT

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.

One subtlety matters here. A lease can lapse in wall-clock time while its row still reads status = 'active'

. Before inserting a new attempt, the claim transaction materializes any lapsed active attempt as expired

. The partial unique index then protects the remaining race window: after expiry processing, only one concurrent insert can succeed.

[*]      -- claim_issue ------------------------> [active]
[active] -- renew_attempt (heartbeat) ----------> [active]
[active] -- finish_attempt (done / review) -----> [completed]   --> [*]
[active] -- finish_attempt (failed) ------------> [failed]      --> [*]
[active] -- finish_attempt (handoff) -----------> [interrupted] --> [*]
[active] -- lease lapses (no heartbeat) --------> [expired]     --> [*]

expired

is the only terminal state reached without the agent doing anything. That's the point.

in_progress

is not a status This is the decision everything else hangs on:

in_progress

is not a stored issue status. It is computed.

An issue's stored status is one of open

, ready

, blocked

, review

, done

, cancelled

. There is no in_progress

value in that column. The server derives an effective status at read time:

Issue stored status: ready
        |
        +-- no active leased attempt
        |       -> effective status: ready
        |
        +-- active leased attempt
                -> effective status: in_progress
                         |
                         +-- lease expires
                                 -> attempt: expired
                                 -> effective status: ready

Read that bottom branch again, because it's the whole argument. When the lease expires, nothing has to reset the issue. The stored status was ready

the entire time; only the derived view changed, and it changes back on its own.

Under this model, a crashed worker cannot leave a durable in_progress

status behind. The stuck-ticket problem is eliminated by the data model rather than repaired by a separate status-reset workflow.

Compare with the stored-status approach, where a crash leaves a real in_progress

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.

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

claim_issue

, list_issues

, get_work_context

— 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.expired

, 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.Expiry never rewrites the issue's stored status. 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.

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.

Recovering the slot is half the problem. The other half is recovering the work.

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.

During an attempt, an agent writes attempt notes in four kinds:

progress

— an ordinary status updatefinding

— a notable technical discoverywarning

— a risk or problem worth flaggingcheckpoint

— a The checkpoint is the one that matters. It's the agent writing a brief for its own successor.

// save_attempt_note
{
  "kind": "checkpoint",
  "content": "Repository layer is implemented and tested. Claim transaction is stubbed.",
  "next_steps": [
    "Implement the claim transaction with BEGIN IMMEDIATE",
    "Add the one-active-attempt concurrency test"
  ],
  "important": true
}

When any agent later requests context for that issue, the server assembles a bounded package that prefers the most recent checkpoint over the full note history, 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.

This also makes deliberate handoffs first-class. An agent that finishes with outcome: interrupted

and interruption_reason_code: context_limit

isn't failing. It's tapping out cleanly and leaving the baton where the next runner can find it.

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.

The issue was never manually reassigned and never stored a stale in_progress

status. The first attempt became historical execution data, while the second attempt continued from its last useful checkpoint.

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.

So the tool contract is built to return the least data that is still sufficient, with budgets that are enforced and tested rather than aspirational.

list_issues

returns identifiers, title, classification, and computed status fields — but description

and acceptance_criteria

bodies. In the project's own test fixture, a 100-issue page measures NULL AS description, NULL AS acceptance_criteria

, dropping the unbounded fields max_nodes

bound you control, not with how verbose your issue bodies are.get_changes(since_event_id)

returns only what happened since you last looked.get_work_context

returns a single curated bundle: the issue body, unresolved blockers, active decision summaries, the latest checkpoint, the previous attempt's next steps.The lesson generalizes past this one server. When your API consumer is an LLM, response shape is part of your performance budget. Omit bodies by default, make expensive fields opt-in, ship a delta feed.

A local SQLite database may sound modest compared with a networked database, but it matches this workload unusually well.

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 atomic, crash-safe multi-table writes. Claiming an issue must, in one indivisible step, create the attempt, record the issue version and event position it started from, and append the attempt_started

event. A partial write there would corrupt the coordination model.

SQLite serializes writers and provides the transactional guarantees this coordination model needs, without requiring a separate database service.

PRAGMA journal_mode = WAL;   -- readers don't block the writer
PRAGMA busy_timeout = 5000;  -- wait for the lock instead of erroring
PRAGMA foreign_keys = ON;
PRAGMA synchronous = NORMAL; -- strong process-crash safety with a good speed/durability trade-off

list_issues

and get_work_context

reads never block the agent committing a claim.BEGIN IMMEDIATE

takes the writer lock up front for known-write operations, avoiding mid-transaction upgrade deadlocks.SQLITE_BUSY

/SQLITE_LOCKED

, retry the whole transaction with backoff (≈25 ms, 75 ms, 200 ms). Domain and constraint failures are never retried.NORMAL

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 FULL

instead.

The pure-Go driver (modernc.org/sqlite

, no CGO) keeps the whole thing a single static binary with no system dependencies.

Physical write safety isn't enough; concurrent agents also produce logical races. Two mechanisms cover them.

Optimistic concurrency. Meaningful issue mutations require an expected_version

. If the issue changed since you read it, the write fails with VERSION_CONFLICT

instead of silently clobbering someone else's change. No pessimistic locks, no transactions held open across think-time.

Idempotency keys. 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 different request is rejected with IDEMPOTENCY_CONFLICT

. Agents retry constantly — flaky networks, ambiguous timeouts, an unclear response — so retries have to be safe by construction.

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.

At the time of writing, the project database contains 100 issues, 94 of them completed — 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.

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."

Each alternative below is good at the problem it was designed for. The question is which problem that is.

Markdown / TODO.md handoff. 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.

Hosted-tracker MCP wrappers (Linear, Jira, GitHub Issues). 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.

Knowledge-graph "memory" servers. These give agents durable facts — 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.

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.

STRICT

tables, CHECK

-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.sleep()

. The architecture is hexagonal: domain and application logic behind ports, with MCP, CLI, and SQLite as adapters.

npx rhizome-mcp

Or install the binary and wire up a client:

curl -fsSL https://raw.githubusercontent.com/Odrin/rhizome-mcp/main/scripts/install.sh | sh
rhizome-mcp init
rhizome-mcp connect claude   # or: codex | vscode | json

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

in_progress

, never store it.Give your agents a queue that expects them to fail, and they'll coordinate like a system instead of colliding like a crowd.

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.

The project is open source under Apache-2.0, and bug reports, design discussions, and contributions are welcome: github.com/Odrin/rhizome-mcp.

── more in #ai-agents 4 stories · sorted by recency
── more on @rhizome-mcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/why-ai-coding-agents…] indexed:0 read:13min 2026-07-28 ·