seamless

two agents, one queue

Two agents, one plan step, no collision: what a lease actually buys you

Run two coding agents against the same backlog and sooner or later they pick the same task. Both reason correctly - the oldest ready step, the obvious TODO, the relevant constraint - which is exactly why they converge, and you get the same limiter built twice in two branches, or worse, interleaved into one. The fix is an old one from distributed systems, applied to agents: claim with a lease. A claim atomically moves a ready task to in_progress with a named holder and an expiry; a second claim of the same task does not half-succeed - it bounces, naming the holder, and the loser picks the next ready step. No coordinator process, no human referee.

That race is exactly what the recorded timeline below shows. Two live Claude Code agents were given the identical prompt - pick up the next step of the plan - at the same moment, against one seeded Seamless instance. Both see two claimable steps; both, independently, choose the rate-limiting step for the same good reasons. Agent B's tasks_claim lands first. Agent A's identical call comes back task already claimed: held by "01KXP8FE…" - and A pivots to the metrics step without being told. Both steps ship. The transcripts are unedited, merged onto one clock the way the claims actually interleaved; the session ids are on the panes.

promptpick up the next step of the plan

One timeline, both agents

headless Claude Code, sessions agent B b541a655-fbe7-4280-9d53-c6c627d4cba5 and agent A 85a94e88-75d6-4345-9b1a-0243870da2c5

~/code/myapp · one shared queue
agent B
> pick up the next step of the plan
agent A
> pick up the next step of the plan
agent B
tasks_ready 2 steps ready
agent A
tasks_ready 2 steps ready
agent B

Both remaining steps are unblocked. I'll take the rate-limiting step next (step 5) — it's the natural continuation of the token/reuse work, and there's a directly relevant project constraint (rate-limit-not-in-memory) to honor. Let me claim it and look at the code.

agent A

I have the full picture. The next step is "Rate-limit POST /auth/refresh (per-IP and per-family)" — the older of the two claimable steps, and there's a clear TODO in auth.go:13-14 plus the rate-limit-not-in-memory constraint. Let me claim it.

agent B
tasks_claim 01KXMRCX... claimed_by "01KXP8FEJQ6R5Z5NMVZ7082X5A"
agent A
tasks_claim 01KXMRCX... task already claimed: held by "01KXP8FEJQ6R5Z5NMVZ7082X5A"
agent A

The rate-limit step is already being worked by another session. That leaves the other claimable step — "Emit metrics and an alert for refresh-reuse revocations." Let me claim that one.

agent A
tasks_claim 01KXN64BN3... claimed
agent B
builds the per-IP + per-family limiter, reconciling live with the other agent's edits to the same files
agent A
implements the metrics + alert on the reuse path; finds and fixes a real reuse-detection bug along the way
agent B

Done. Step 5 of the auth-refresh plan — Rate-limit POST /auth/refresh (per-IP and per-family) — is complete, and the plan is now 5/6.

agent A

Done. Picked up and completed the plan's next available step.

agent B. Claims the rate-limit step first. Its lease is what makes the other agent's claim bounce.

agent A. Reaches for the same step — the queue says it is already held. Pivots to the metrics step without being told.

two live agents against one seeded instance, same prompt — transcripts verbatim, one shared timeline

What a lease actually buys you

The collision protection is not a lock, and the difference matters when the workers are agents that can die mid-task:

  • Claiming is atomic. tasks_claim moves a ready task - open, with every dependency done - to in_progress with a holder and a lease (15 minutes by default). There is no window where two callers both think they won; the loser's call fails, naming the holder.
  • A dead agent cannot strand work. A lock needs an unlocker; a lease only needs a clock. If the holder crashes, its lease lapses and the step becomes claimable again. A live holder re-claims to refresh the lease - a heartbeat.
  • Release is tied to the work, not the process. Finishing or dropping the task releases it, tasks_release releases it explicitly, and ending a session releases everything that session held.
  • The queue is dependency-aware. Steps declare what blocks them, so "ready" already means "safe to start" - the race is only ever between tasks that genuinely could both proceed.

Two honest limits. A lease serializes intent, not file edits - both agents here still edit the same repository, and you can see B reconcile with A's changes in the fast-forward line; that remains git's job. And a claim is advisory for humans: the console shows who holds what, but nothing stops you from editing the code yourself.

The claim also outlives the moment. Claude Code's Agent Teams gives one team of agents a shared task list with claiming and dependencies - first-party and good - for the life of that team. The queue here is the layer underneath: the plan, its steps, and their claims persist across sessions and days, and an agent in a different client (Codex CLI, or any MCP client) reads the same queue. Tasks and plans has the full model; coordinate agents is the working guide.

How to reproduce this

Seed the fixture with the race flag, which leaves two steps claimable so the collision can happen:

git clone https://github.com/0spoon/seamless && cd seamless
go run ./cmd/demoseed -scenes -race -data /tmp/seamless-demo -repo /path/to/your/test/repo
SEAMLESS_DATA_DIR=/tmp/seamless-demo go run ./cmd/seamlessd serve

Then start two agent sessions in the mapped repo and give both the same prompt at the same time. The bounce is deterministic - whoever's claim lands second gets the holder's session id back. For a real setup, start at the quickstart.