← back/blog/concurrency-control

the Room Protocol · concurrency

Concurrency control for a shared plan: distributed locks and per-agent cursors

The whole promise of multi-agent work is agents running in parallel. The moment they do, they can corrupt each other's state. Here's how the Room Protocol stays consistent under concurrency — and why I reached for a Redis lock instead of the fancier options.

12 min read·shreyaspadmakiran.com

In the overview I argued agents should coordinate through a shared room instead of chatting. There's a catch buried in that idea. If the value of multi-agent development is that agents work at the same time, then the shared state they coordinate through is, by definition, under concurrent access. And concurrent access to mutable shared state is where quiet, awful bugs live.

This post is about three of those bugs and the three mechanisms that kill them: a distributed lock for the plan, per-agent cursors for events, and TTL-based presence. None of the mechanisms is exotic. What's worth your time is why each one, over the alternatives that look more sophisticated on paper.

Act I — The lost update

One JSON document, two writers

The plan is a single JSON blob per room: a list of tasks with status, owner, and dependencies. Agents change it with read-modify-write. Read the plan, flip one task to in_progress, write the whole thing back. That is the textbook setup for a lost update.

Agent A reads the plan. Agent B reads the same plan a millisecond later. A marks task 1 done and writes. B marks task 2 done and writes — over A's version, which B never saw. Task 1 silently reverts to pending. No error, no crash. An agent just quietly loses work, and the first anyone notices is when something gets built twice or not at all.

Lost update without a lock versus serialized writes with a lockLeft: agent A and agent B read the plan concurrently, both write, and B's write clobbers A's. Right: the lock serializes them so both updates survive.without a lockA: readB: readA: write task1 ✓B: write task2 (clobbers A)task1 silently reverts · work lostwith the plan lockA: acquire → RMW → releaseB: acquire … (waits)B: acquire → RMW → releaseboth updates survive · serializedcost: one extra round trip + a short wait
Read-modify-write on shared state without serialization loses writes. The fix is to make the read, the modify, and the write one atomic critical section.

The obvious fixes, and why I skipped them

There are three respectable ways to solve this, and I want to be explicit about why none of them fit before showing the one that did.

A real database transactionPostgres with row-level locks or SERIALIZABLE would nail this. But it means running a relational database next to the coordination server — a second stateful system to provision, back up, and pay for. The whole design goal was one store. Adding Postgres just to serialize a rare write is a lot of operational weight for a small problem.
Optimistic concurrency (CAS)Store a version number, write only if the version hasn't changed, retry on conflict. Clean, and lock-free. But under real contention it turns into a retry storm, and the retry logic plus the conflict-merge ends up as complex as a lock without being easier to reason about. Optimistic wins when conflicts are rare and cheap to redo; plan writes are neither guaranteed.
CRDTsConflict-free replicated data types merge concurrent edits without coordination — beautiful for collaborative documents. Massive overkill here. The plan isn't a rich-text doc with character-level merges; it's a small task list edited a few times a minute. Paying CRDT complexity and metadata overhead to avoid a lock I take once a minute is the wrong trade.

The deciding facts: plan writes are infrequent (a handful a minute, not thousands a second), the contended object is one small document, and I already have Redis, which gives me atomic operations for free. When writes are rare and you already own an atomic primitive, a pessimistic lock is the simplest thing that is obviously correct. Simple and obviously correct beats clever every time in infrastructure you have to trust.

“I already have Redis, which gives me atomicity for free” is the whole argument. The lock is just one Redis command used honestly.

Act II — A lock that can't deadlock

SET NX PX, and why both flags matter

The lock is a single Redis SET with two options:

await redis.set(lockKey, agentId, { nx: true, px: ttlMs });
// nx  → set only if the key does NOT exist  → exactly one winner
// px  → auto-expire after ttlMs               → a dead holder can't block forever

nx is what makes it a lock: the operation is atomic inside Redis, so if ten agents fire it at once, exactly one gets "OK" and the rest get nothing. No race, no coordination protocol, no consensus round. px is what makes it a lock I can trust in production: it sets an expiry, so if the agent that holds it crashes, disconnects, or just wanders off, the lock releases itself. A lock without a TTL is a deadlock waiting for its first crash.

Around that primitive is a small acquire-run-release wrapper with bounded, backed-off retries. Every plan mutation runs inside it:

async function withPlanLock(roomId, fn) {
  const lockId = `server:${nanoid()}`;
  let acquired = false;

  for (let attempt = 0; attempt < 5; attempt++) {
    acquired = await acquireLock(roomId, "plan", lockId, 10_000);
    if (acquired) break;
    await sleep(150 * (attempt + 1));   // 150ms, 300, 450, 600 …
  }
  if (!acquired) throw new Error("Plan is locked, retry in a moment.");

  try { return await fn(); }            // the read-modify-write
  finally { await releaseLock(roomId, "plan", lockId); }
}

Three details earn their place here. The retry loop is bounded — five tries, then it gives up and tells the caller to retry, rather than hanging forever. The backoff grows, so two agents colliding don't synchronize into a tight retry loop hammering the same key. And release is in a finally, so the lock comes back even if the work throws. Release also checks ownership before deleting, so an agent can only free a lock it actually holds:

const current = await redis.get(lockKey);
if (current === agentId) { await redis.del(lockKey); return true; }
return false;   // someone else holds it (or it already expired) — don't touch

That ownership check prevents the classic distributed-lock footgun: agent A's lock expires under load, agent B acquires it, then A finishes late and blindly deletes the lock — freeing B's lock out from under it. Checking the holder before deleting closes that window. It's not a fully fenced lock (that would need monotonic tokens), but for a single-Redis coordination server where the TTL is generous relative to the work, it's the right amount of rigor.

In practice that is what lets you point four agents at one backend overnight and trust you wake up to a consistent plan, not a corrupted one. Reliability under parallelism is the entire reason to run multiple agents at all, so it is the one property that can't be “good enough.” The price — one extra Redis round trip on writes and a brief wait under contention — is invisible for a document edited a few times a minute.


Act III — Reading without stepping on each other

Exactly-once events with a per-agent cursor

The event log has the opposite problem. It's append-only, so writes don't conflict — but readsdo, in a subtle way. Every agent needs its own answer to “what's new to me?” and those answers must not interfere. If two agents share a read position, one advancing it hides events from the other.

The tempting fixes are both bad. A shared “last read” pointer breaks the instant a second agent joins. Re-sending the whole recent log every poll and asking the agent to dedupe pushes the problem onto every client and wastes tokens re-reading events it's already seen. The clean answer is a cursor per agent:

const cursor = await getEventCursor(roomId, agentId);   // this agent's position
const recent = await getEvents(roomId, 50);

const unread = cursor === null
  ? recent
  : recent.filter(e => new Date(e.timestamp) > new Date(cursor));

await setEventCursor(roomId, agentId, new Date().toISOString());  // advance
return { events: unread, count: unread.length };

Each agent has its own cursor key. Reading advances only yourcursor and never mutates the shared log, so two agents reading the same stream each get every event exactly once, with zero coordination between them. No locks, because nothing shared is being written. This is the flip side of the lock decision: use the heaviest tool only where writes actually contend, and lean on per-agent state everywhere else.

One shared event log, two independent per-agent cursorsA single row of events. Agent A's cursor sits further along than agent B's cursor. Each sees a different slice as unread, without affecting the other.shared event log →e1e2e3e4e5e6e7B cursorunread: e4–e7A cursorunread: e7same log, independent positions — reading advances only your own cursor
Per-agent cursors give each agent an exactly-once view of one shared log with no write contention and no client-side dedupe.

Presence as a TTL, not a connection

Last one: knowing who is actually online. The instinct from chat apps is to track live connections — a WebSocket per agent, an onDisconnect handler to mark them offline. That instinct is wrong here for one reason: the server is stateless. There is no persistent connection to hang presence on, and I want to keep it that way, because statelessness is what lets any server instance serve any request and restart without ceremony.

So presence is just a key with an expiry. An agent calls heartbeat every ~60s; that writes a key that lives 120s:

// heartbeat: write a key that outlives one missed beat, then expires
await redis.set(heartbeatKey(roomId, agentId), now, { ex: 120 });

// presence: you're online iff your heartbeat key still exists
const lastSeen = await redis.get(heartbeatKey(roomId, agentId));
status = lastSeen !== null ? "online" : "offline";

Stop heartbeating and you fall offline on your own after 120 seconds. No disconnect handler, no cleanup job, no connection state anywhere. The 120s window is deliberately two missed beats, so one dropped request doesn't flap you offline. Liveness becomes a question about the store, not about a socket — which is exactly the property that keeps the server disposable.


The pattern underneath all three

Look at the three mechanisms together and there's one idea running through them: match the coordination cost to the actual contention.The plan is genuinely write-contended, so it gets a real lock. The event log is append-only with per-reader views, so it gets cheap per-agent cursors and no lock at all. Presence has no shared write at all, so it gets a self-expiring key and no bookkeeping. Reaching for a database transaction or a CRDT everywhere would have been more “serious” and strictly worse: more moving parts, more latency, more to operate, to solve problems two of the three cases don't even have.

What this version doesn't do, to be straight about it: the plan lock makes the plan a serialization point, so a room with agents furiously rewriting tasks would bottleneck on it (fine at real usage, but it's the ceiling). The lock isn't fully fenced, so a pathological TTL-expiry-under-load case is mitigated but not provably impossible. And presence and events are poll-based, so “online” and “unread” are accurate to within a poll interval, not instantaneous. Push and fencing are on the roadmap; neither was worth the complexity for the load this handles today.

The lesson I keep relearning: in systems you have to trust, the right amount of cleverness is usually less than you think. One Redis command used honestly, a cursor per reader, and a key that expires on its own. Three small, boring mechanisms, and the room stays consistent no matter how many agents pile in.

— shreyas

more in this series

01Coordinating agents through shared state · the idea

02Concurrency control for a shared plan · you are here

03A stateless server on a single Redis · architecture

04Typed context over prose and vector search · context model

05MCP as transport · protocol layer

06Multi-tenancy on one Redis · isolation and access