Coordination — multi-agent turn-taking, awareness, and task allocation

New here? Read how-coordination-works.md first — it explains all of this in plain language with a running example, no jargon. This page is the technical design behind it.

A design note for the coordination layer: how multiple agents — co-resident on one machine or federated cross-machine over a shared Postgres ledger — stop talking over each other, share what they’re doing, and divide work, without a server.

Convention (as in knock-knock-ledger-model.md): sections marked Shipping in the skeleton are what the first cut builds; Designed, not yet shipped is the fuller vision it grows into.

The plan that produced this layer is docs/plans/2026-06-25-001-feat-ledger-native-agent-coordination-plan.md.


1. The gap

Removing the central server left coordination to the append-only ledger alone. Four gaps appeared:

  • (A) No turn-taking. Each AgentHost decides to reply independently, so two bots both answer one message (“Got it” twice). The loop-guard caps consecutive agent turns (depth); it does nothing about two agents answering one message (breadth).
  • (B) No shared awareness. No agent can see what the others are doing or what a thread is about, so they re-derive it by knocking.
  • (C) No task allocation. “Set up 5 subagent tasks” has no notion of who-first / who-next / who-owns-what.
  • (D) No topology abstraction. Different teams want different collaboration shapes; one hard-coded answer fits only some.

The dominant constraint: cross-machine state propagates only via Postgres LISTEN/NOTIFY, which fires on INSERT, never UPDATE. So every coordination decision is expressed as a new immutable interaction and derived, never as a mutated lifecycle column. Contention resolves through the external_claim table, whose atomicity is the database row lock — not NOTIFY — so exactly-one holds even under replication skew.


2. Mechanism vs. policy (the keystone)

The primitives are pattern-agnostic mechanism; the collaboration topology is pure policy selected per-room/per-thread through the existing config fold (!config). The same claim/fold/board/scheduler carries every pattern.

PatternresponderallocationFeels like
Peer / co-equal (default)racepull-claimwhoever grabs it first
Orchestrator–workerdesignatedpush-assignone lead fields + assigns
Pipeline / handoffdesignated/role-prioritypull-claimDAG unlocks in order
Contract-net / marketracebidbest bid wins

Switching topology is a config value; mechanism code is unchanged. Defaults (race + pull-claim) reproduce decentralized peers, so a room that sets nothing behaves as before.

Pure decision logic lives in lib.ts (unit-tested in tests/lib.test.ts): ResponderPolicy/preferredResponderDelayMs, AllocationPolicy/claimantFor, winningBid/scoreBid, projectTaskDag/readyTasks, projectCoordinationBoard, selectRelatedContext.


3. Turn-taking — exactly one agent replies (A) · Shipping in the skeleton

reply-claim (ledger/synchronizations/reply-claim.ts) replaces the old prompt-on-message. On an admitted channel.message it composes two claims:

  1. reply electionreplyClaimKey(channel, msgId) held by agentKey, so distinct eligible agents contend to a single winner.
  2. drive electiondriveClaimKey(channel, agentKey, msgId) held by relayId, so the same agent on multiple relays is driven exactly once.

Claim keys use the shared platform message id (IncomingMessage.ref.id), never the per-host interaction hash (each host stamps a distinct targetAgent, so the hashes differ). The loop-guard gate is kept (on the original message role). The ResponderPolicy biases who wins via a short start delay (designated / role-priority step aside for the preferred agent), degrading to a plain race if the preferred agent is silent — the claim guarantees exactly-one regardless.

Addressing is platform-neutral (R11): it reads the MessagingAdapter seam (mentionsBot / replyToMessageId / Capabilities.mentions), never <@id> text.


4. Shared awareness — the coordination board (B) · Shipping in the skeleton

A per-scope board on its own fold (ledger/concepts/coordination-board.ts), keyed coord:channel/<scopeId>, append-only, anchor none (knowledge-fold conventions). coord.note interactions record presence (what an agent is doing) and designation (who has taken a message). capture-presence derives presence from turn.prompted→working / turn.replied→done — runtime-neutral (R13), keyed on turn verbs common to every adapter, never a Claude-specific shape. reply-claim’s winner posts a designation so peers don’t re-knock.

The board is delivered into each turn as a once-only <coordination> prefix (pickFreshCoordination, excluding self), prepended in Driver.buildPrompt. On separate-store SQLite there is no shared state, so an agent simply sees only local peers — correct degradation, no duplicate channel posts.


5. Task allocation (C) · Shipping in the skeleton

A per-scope task DAG (ledger/concepts/task-dag.ts), keyed task:channel/<scopeId>, folded from task.created / task.bid / task.claimed / task.completed. The projection derives op-derived status only (open/claimed/done); claim liveness (a lapsed external_claim) is the scheduler’s concern, never folded in. readyTasks derives the dependency frontier (“who’s next”) with no scheduler, so every replica agrees.

open --> ready (deps done, derived) --> [bid window] --> claimed --> done
                                   ^                         |
                                   +---- claim lapsed --------+  (failover)

Owner !delegate (owner-only short-circuit, prompt-injection-safe) seeds tasks: id: label [after deps] [@assignee], with cycles rejected at parse time.

task-scheduler (ledger/synchronizations/task-scheduler.ts) allocates ready tasks under the AllocationPolicy, composing the same two-claim pattern (taskClaimKey holder=agentKey, taskDriveKey holder=relayId), then admits task.claimed + a turn.prompted wake. Failover: claim renewal is gated on host.isTurnLive, so a crashed/ended owner stops renewing; a half-TTL reconcile tick in relay.ts re-takes the lapsed claim even when no new event arrives (the watches §7 lesson). Reassignment rides immutable INSERTs (a new task.claimed), so it converges cross-machine with no lifecycle UPDATE.

complete-task-on-turn closes the loop: when a scheduler-driven turn replies (its caused_by chain reaches task.created), the task is marked done — but only if replied.actor is the task’s current owner and it isn’t already done, so a forged/foreign turn.replied or a 🔁-retry can’t complete someone else’s task or double-complete. A crashed turn never replies, so failover (reconcile) still applies. Per-pass wakes are capped (MAX_WAKES_PER_PASS) to stay under the synchronizer’s admit budget; an over-cap frontier drains on subsequent reconcile ticks with no permanent loss. The claim TTL (60s) is ~4× the reconcile interval so a healthy owner’s renewal can’t be starved into a false failover.

Contract-net (allocation=bid): agents submit a task.bid (deterministic scoreBid utility) on sight; claiming defers to the reconcile pass (the bid window), where winningBid (highest utility, deterministic tiebreak) claims via the same claim. A no-bid task falls back to pull, so bidding never strands work.


6. Cross-thread retrieval (D / R8) · Shipping in the skeleton

selectRelatedContext scores candidate messages from other threads by three signals (recency + caused_by/reply lineage + keyword/participant overlap) over a pre-bounded candidate set (a lookback window applied before scoring — never a full-log scan). The host injects a once-only <related-context> block from a bounded window of sibling-thread messages. Embedding-based relevance is designed, not yet shipped, behind the same scoreRelatedInteraction seam.


7. Cross-machine reach

SQLite (same machine)Postgres (cross-machine)
claim contentionprocess-local (one store)cluster-wide atomic (row lock)
board / task convergencein-processINSERT + NOTIFY replication
coordination across machinesno (separate stores share nothing)yes

All coordination is INSERT-derived and claim-arbitrated, so it converges from the operations alone. The ship gate is the property battery tests/ledger/coordination-cross-machine.test.ts plus the per-pattern integration tests/ledger/coordination-policies.test.ts.


8. Designed, not yet shipped

  • Capability-aware / learned bid scoring (v1 scoreBid is deterministic pseudo-utility; ST-SR-IA class).
  • Agent-initiated task proposal (v1 seeds via owner !delegate only).
  • Embedding relevance for retrieval.
  • Finer stall detection (progress heartbeat) — v1 fails over on crash/turn-end via active-turn liveness.
  • Loop-guard fold exemption for scheduler/reply-synthesized turns (shared with the watches follow-up).
  • A second messaging platform adapter — the layer is platform-neutral, but Discord is the only live surface today.
  • Designated-responder failure fallback. Under responder=designated/role-priority, the non-preferred agent makes a single deferred claim attempt; if the preferred agent wins the reply claim and then its turn fails (no turn.replied), the message is unanswered until the reply claim’s TTL lapses. A release-on-failure + fallback-retry is the planned fix; the default race policy is unaffected.