Coordinating a fleet of LLM agents on one codebase
You point three AI coding agents at the same repository. One is Claude, one is Codex, one is some local model. They all want to be helpful. They all start editing.
A single agent never hits this. The moment you run a fleet, it becomes the dominant failure mode.
Both agents read foo.rs. Both make a change. Both write it back. The second write wins and the first agent’s work silently vanishes. In database terms this is the lost update problem; in everyday terms it’s two people editing the same Google Doc with “suggesting mode” off.
ContextNest’s job is to be the memory and coordination substrate for exactly this situation: many heterogeneous agents, one shared working tree. So we had to solve it. This post walks through how, and names the classical technique behind each piece so you can go read the original papers if you want the deep version.
Why not just let the agents talk it out?
The obvious idea: have the agents message each other. “Hey, I’m about to edit foo.rs, hold off.” There’s even a published protocol for it: tap (arxiv:2606.14445), a file-based message channel between Claude and Codex sharing a codebase.
The problem is that a message channel is not concurrency control. It tells agents what others are doing; it does not prevent two of them from acting at the same instant. And it turns out LLMs are genuinely bad at negotiating their way out of a contention deadlock on their own.
GRPO Does Not Close the Multi-Agent Coordination Gap (arxiv:2606.07845) ran LLM agents on the classic dining-philosophers problem. Left to themselves, the agents either collide or, worse, converge on a degenerate “everybody do nothing” truce so nobody conflicts and nobody makes progress. Reinforcement learning did not reliably fix it.
The lesson we took: don’t ask the agents to coordinate themselves. Give them an external arbiter, a referee they all check with before they act. That is a deliberate, load-bearing design choice, and the rest of this post is the referee.
The fix: an advisory lease plane
The referee is a small HTTP service inside ContextNest. Before an agent edits a path, it asks for a lease on that path. A lease is a time-bounded, scoped claim: “I, agent A, intend to write src/foo.rs for the next 120 seconds.” The service either grants it or tells the agent to wait.
This is a central lock manager, the same shape as Google’s Chubby (Burrows, OSDI 2006). One process holds the authoritative picture of who has claimed what. We chose this over the fully distributed alternatives (Ricart-Agrawala, Maekawa’s quorums) on purpose. Those solve coordination without a central point, at the cost of a lot of message-passing and subtle correctness proofs. For a fleet of agents that already share one localhost substrate, a single in-memory registry is simpler, faster, and easier to reason about. We are explicitly not building a multi-host distributed lock. That is listed as out of scope.
The code is in src/api/coord.rs. The whole thing is small enough to read in one sitting.
Piece 1: scopes and the read/write conflict rule
Two leases conflict only if they could actually interfere. A read and a read never interfere; any number of agents can read foo.rs at once. A write interferes with everything that overlaps it.
requester wants...
READ WRITE
held ┌──────────┬──────────┐
READ │ OK │ CONFLICT │
├──────────┼──────────┤
WRITE │ CONFLICT │ CONFLICT │
└──────────┴──────────┘
This is the readers-writers problem (Courtois, Heymans & Parnas, 1971): many readers or one writer, never both. In coord.rs it’s one line of intent:
fn conflicts(held: &Lease, req_paths: &[String], req_mode: LeaseMode) -> bool {
let write_involved = held.mode == LeaseMode::Write || req_mode == LeaseMode::Write;
write_involved && sets_overlap(&held.paths, req_paths)
}
“Overlap” is path-prefix aware. A lease on src/api/ covers src/api/coord.rs but a lease on src/ap does not (we check the boundary is a real /, not a coincidental prefix). That is the seed of the fine-grained-scope work. Eventually scopes can be globs or even individual functions, so two agents editing different functions in the same file don’t block each other unnecessarily.
Piece 2: leases expire (self-healing)
What happens when an agent crashes mid-edit while holding a lease? If leases were permanent locks, that path would be frozen forever. One dead agent would deadlock the whole fleet.
So leases are time-bounded. Every lease carries an expires_at. Before any decision, the registry sweeps out everything that has expired:
fn sweep_expired(leases: &mut Vec<Lease>, now: DateTime<Utc>) {
leases.retain(|l| l.expires_at > now);
}
This is the original insight behind leases (Gray & Cheriton, SOSP 1989): a lock with a built-in expiry is self-healing. A crashed holder’s claim heals itself when the clock runs out. Healthy agents keep their claim alive by renewing (PUT /coord/lease/{id}/renew) as long as they’re still working. Default TTL is 120 seconds, capped at one hour.
Piece 3: fairness — priority inheritance
Leases get a priority. A higher-priority agent (say, a human-initiated hotfix) should win contention over a background refactor bot. But naive priority introduces a classic trap.
A low-priority agent L holds foo.rs. A high-priority agent H wants it and has to wait on L. Then a medium-priority agent M arrives. Medium outranks low, so naive priority lets M jump the queue ahead of L. But H is also stuck behind L, so M effectively jumps ahead of H too. Priority inversion: the important task waits longest.
The fix is priority inheritance (Sha, Rajkumar & Lehoczky, 1990). While a high-priority agent H is blocked waiting on a low-priority holder L, L temporarily inherits H’s priority. Now a medium agent M can’t slip ahead, because L is effectively high-priority for the duration of the wait. The moment L releases, its priority drops back.
Piece 4: deadlock — wound-wait
Two agents can still tie themselves in a knot. A holds X and wants Y. B holds Y and wants X. Each waits for a resource the other holds. Cycle. Deadlock.
The registry maintains a wait-for graph and, on each queueing decision, checks whether the new wait would close a cycle. If it would, it breaks the cycle deterministically: the wound-wait rule says the lower-priority requester loses. Its acquire returns { status: "abort", reason: "deadlock" } and it backs off and retries later. The higher-priority party keeps its place.
Wound-wait (Rosenkrantz, Stearns & Lewis, 1978) is one of the two classic timestamp-ordering schemes for deadlock prevention. The key property is determinism: given the same priorities, the same agent always loses, so the system cannot ping-pong. Crucially we only ever abort a requester. We never preempt an agent that already holds a lease, because yanking a lock out from under an agent mid-edit would reintroduce the lost-update bug we started with.
Piece 5: the enforcement spectrum (advisory → strict)
Here’s the pragmatic part. By default the lease plane is advisory: it warns, it doesn’t forbid. Why? Because the integration point is Claude Code’s PreToolUse hook, a fire-and-forget gate that runs before every tool call. ContextNest exposes it at POST /api/v1/cc/pretool. By default it always returns permissionDecision: "allow" and, if there’s a conflicting lease, attaches a human-readable nudge that Claude sees in-context:
WAIT before editing src/foo.rs. Agent A holds a write lease (priority 5), ETA ~80s. Reason: hotfix.
Advisory mode trusts a cooperating fleet, agents that honour the warning. For the cases where you can’t trust that (a migration file, the WAL schema, a release branch), there’s an opt-in strict mode. A lease acquired with strict: true makes the gate return permissionDecision: "deny" for other agents on that scope. Claude Code honours a hard deny, so the contended edit is actually prevented, not just discouraged.
This spectrum matters. The default stays frictionless and never surprises an agent that didn’t opt in. The strict escalation is there for when the cost of a collision is high.
Piece 6: observability — why did my edit stall?
The lease state lives in memory and is intentionally ephemeral. It’s coordination, not durable data, and a restart should start clean. But “my agent mysteriously waited 90 seconds” needs a paper trail. So the registry keeps a bounded contention audit log, a ring buffer of “who waited on whom, for how long, and how it resolved”, plus counters (coord_leases_held, coord_queue_depth, coord_deadlocks_broken, coord_ttl_expirations) exposed at GET /coord/metrics and GET /coord/audit?since=10m. Post-hoc you can answer “what was the fleet contending over at 14:05” without the lease state itself having to survive a restart.
The punchline: we built this with a swarm
The later phases of this system (priority inheritance, deadlock detection, strict mode, the audit log) were implemented by a heterogeneous swarm of agents running different models, Opus, Kimi, MiniMax, each in its own git worktree, working in parallel on the same module.
We used a fleet of AI agents editing one codebase to build the thing that keeps a fleet of AI agents from clobbering one codebase.
Git worktrees gave each agent an isolated working tree (so their builds and edits couldn’t physically collide), and the coordination plane is what keeps their logical changes from colliding when they integrate. The medium was the message.
What we deliberately did NOT build
Naming the non-goals is half the design.
- Cross-machine coordination. One localhost substrate per fleet. Going multi-host would force the distributed mutual-exclusion algorithms we chose to avoid.
- Automatic merge (CRDT / OT). Leases prevent concurrent writes; they don’t merge them. If two agents legitimately need the same region, resolving the diff is still their job.
- Trusting advisory warnings unconditionally. That’s the cooperating-fleet assumption. Strict mode is the answer for when it doesn’t hold.
The whole thing in one breath
Six small, well-understood ideas, leases, readers-writers, a central lock manager, priority inheritance, wound-wait, and a hook-based enforcement gate, composed into a coordination plane that a swarm of LLM agents can lean on instead of trying (and failing) to negotiate among themselves.
The 30-year-old papers were there the whole time. We just had to recognise that “the producer is an LLM” doesn’t change the concurrency-control story; it makes the classical answer more important, not less.
References
- Gray, C. & Cheriton, D. (1989). Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency. SOSP. The self-healing time-bounded lock.
- Courtois, P. J., Heymans, F. & Parnas, D. L. (1971). Concurrent Control with “Readers” and “Writers”. CACM. The read/write conflict matrix.
- Burrows, M. (2006). The Chubby Lock Service for Loosely-Coupled Distributed Systems. OSDI. The central lock-manager pattern.
- Sha, L., Rajkumar, R. & Lehoczky, J. (1990). Priority Inheritance Protocols: An Approach to Real-Time Synchronization. IEEE TC. Fixing priority inversion.
- Rosenkrantz, D. J., Stearns, R. E. & Lewis, P. M. (1978). System Level Concurrency Control for Distributed Database Systems. ACM TODS. Wound-wait / wait-die deadlock prevention.
- Ricart, G. & Agrawala, A. (1981); Maekawa, M. (1985). The distributed mutual-exclusion algorithms we deliberately avoided.
- GRPO Does Not Close the Multi-Agent Coordination Gap (arxiv:2606.07845). Evidence that LLMs can’t reliably self-coordinate and RL doesn’t fix it. External arbiter needed.
- tap: A File-Based Protocol for Heterogeneous LLM Agents (arxiv:2606.14445). A message channel, but no concurrency control. The gap this plane fills.
Implementation in SourceShift/ContextNest: src/api/coord.rs (lease registry), src/api/cc_hooks.rs (the PreToolUse gate), and the design epic at docs/roadmap/epics/agent-coordination.md.
Comments
Sign in with GitHub to leave a comment. Threads live on SourceShift/blog-comments — moderated.