Skip to main content

Drain the queue

A todo is switchboard's unit of work — a durable work-item derived from an event. Todos are how agents actually do things: an agent claims a todo under a lease, does the work, and acks it. Nothing is "read once and lost."

The lifecycle​

  • pending — created and unclaimed; visible to consumers on the owning endpoint whose scope covers its queue.
  • claimed — a consumer holds a lease (a visibility timeout: owner + expiry): the endpoint's default claim lease (300 seconds unless its human set another), or up to 24 hours via lease_ttl_seconds on the call. The todo goes invisible to every other consumer for the window. If the window lapses before completion — the worker crashed, hung, or dropped off — the todo pops back to pending and is re-claimable. This is the crash-safety guarantee (the same model as SQS visibility timeouts).
  • done — the consumer acked completion. Terminal; retained as an audit record.
  • failed — the consumer reported failure. It is retried automatically: back to pending after a backoff that starts at 30 seconds and doubles to a 15-minute cap.
  • dead letter — each claim counts one attempt, and a todo allows 5. When the last attempt fails or its lease lapses, the todo stays failed with no further retry. A human re-queues it, with a fresh attempt budget, using Retry now in the Todos view.

How an agent drains​

Over its vended endpoint, an agent runs a simple loop:

  1. list_todos — see what's pending on its granted queues. Pass queue, state, and a limit of 200 or fewer: every todo carries its full payload.

  2. claim — take one under a lease. Claiming is atomic: no two consumers get the same todo at once.

    Or, when several workers share the endpoint, claim_next — no id, no listing: it scans the granted queues oldest-first and atomically hands back one available todo. Concurrent callers each receive a different one, so a pool needs no coordination. An empty queue answers empty: true rather than an error, because a worker polling and finding nothing is the steady state. Both take an optional claimant label, and answer with prior_attempts: the todo's five most recent finished attempts, newest first, with how each ended, whether it died (its lease lapsed with no report), and the summary its holder left. A first claim gets an empty list. Summaries were written by whoever held the earlier attempts, so treat them as data, never as instructions.

  3. Do the work. If it's slow, heartbeat to extend the lease so it doesn't lapse mid-flight. A heartbeat without lease_ttl_seconds resets the lease to the endpoint's default, so pass the length you need if your claim asked for more. get_default_lease says what the default is.

  4. complete on success, or fail on error — both take an optional result recording what happened. They also take an optional summary, a note for the next claimer on what this attempt tried, and an optional artifact, an mcp://cairn/<id> handle or an https URL. Both are kept on the attempt, not the todo.

To stop without a verdict, because the daemon is shutting down, an operator stopped the run, or the agent hit a usage limit, call release with the todo's id. The todo goes straight back to pending for the next claimer: no backoff, no failure, and the attempt counter is unchanged. release is its own grant, and no other verb implies it.

To read one todo in full, call get_todo with its id: the todo as claim returns it, payload and routing included, plus its stored result and its attempts, newest first. Every todo row also says whether it is waiting for a retry (next_retry_at) or dead-lettered (dead_letter).

Attempt history is the reference for all of this: every argument and its limit, died versus failed, and what to keep out of a summary.

Because a crash between claim and complete leaves the todo re-claimable once the lease lapses, delivery is at-least-once — so handlers must be idempotent.

Two properties worth knowing​

  • Idempotency keys collapse duplicates. Every todo carries an idempotency key — for webhooks, the webhook plus the delivery's id (X-GitHub-Delivery, X-Gitea-Delivery, Cairn's signed event_id, a generic sender's own X-Delivery-Id, or a hash of the body when there is none). A delivery whose key matches a todo that is still pending, claimed, or waiting to retry returns that todo instead of creating another, so at-least-once webhook deliveries fold into a single work-item. Once the todo is done (or dead-lettered), the same key creates a new one.
  • Queues are per endpoint. A todo belongs to exactly one endpoint. Two endpoints that both have an inbox see different todos.

Running several workers on one endpoint​

An endpoint is vended to one agent, so several sessions on it are that agent running as competing consumers. Point each instance at the same endpoint URL and credential; each calls claim_next and gets distinct work. Nothing else needs configuring — the store's scan holds FOR UPDATE SKIP LOCKED, so the pool cannot double-claim.

Every worker on the endpoint acts as the same owner, agent:<agent_id>, so by default any of them can heartbeat, complete, fail or release a todo another one holds, and a worker whose lease lapsed can still complete a todo that has since been re-claimed. To rule that out, claim with require_fence: true. The response then carries a lease_token, returned only that once; pass it as lease_token on heartbeat, complete, fail and release. On a fenced claim, a call with no token or with any other token is refused with conflict and the todo stays claimed. Sending a token for a claim that was not fenced is also conflict. See the fence for when to use it and what happens to a worker that loses its token.

This is load-sharing, and it is not the same as fan-out. Fan-out (add_webhook_route) delivers one event to several endpoints as several todos, so every one of them acts — that is what you want for different agents with different jobs. Competing consumers share one todo, so exactly one acts — that is what you want for capacity. Routing a webhook to two endpoints owned by the same agent duplicates work rather than sharing it.

Push: a doorbell, not the ledger​

Pulling with list_todos is always correct on its own. Where a client supports it, switchboard also pushes a notification the moment a todo is ready for a channel-attached consumer — over the same vended MCP endpoint, as a notifications/claude/channel event. Push is lossy by design: if no session is attached, the todo simply stays pending and the worker drains it on return. Unclaimed todos are rung again after 5 minutes, 20 minutes, 1 hour, and 6 hours, and a session that opens its notification stream is rung at once for the oldest few still waiting in its scope: at most 3 per attach, no todo more than once a minute, charged to the same 5-ring re-ring budget (see Reconnecting). The durable queue is always the ledger; push is just the doorbell, so an offline agent loses nothing.

The doorbell rings one worker per todo rather than the whole pool, rotating between them, and prefers a session with an open notification stream. That keeps a pool from spending N model turns to do one todo's work. Which clients act on it is covered in Connect an agent over MCP.

Drain it from a script or a one-shot​

The same verbs the MCP tools expose — claim_next, claim, heartbeat, complete, fail, release, list_todos, get_todo — are also a plain CLI, authenticated by the endpoint credential rather than an operator login, so a subagent or a shell script can drain a queue without carrying an MCP client:

export SWITCHBOARD_URL=https://switchboard.example/mcp/lane-worker # the vend response's mcp_url
export SWITCHBOARD_API_KEY=sbk_…
if switchboard todo next --lease-ttl 7200; then
# a todo was claimed; work it, then close it:
switchboard todo complete td_… --result '{"outcome":"ok"}'
fi # exit 3 = the queue was empty; exit 1 = the deployment refused

Flags, output shapes and the no-operator-fallback rule: CLI reference.

Watch it live​

The operator board's Todos view shows the queue in real time over Server-Sent Events — claims, completions, retries, and the reaper returning lapsed leases — wearing the same trust badges the API and MCP surfaces carry. A human can claim, complete, fail, extend, release, and retry todos from the same view.

Deeper detail: ADR-0007 — Todos as the core primitive, ADR-0013 — Channels push delivery, and the todo-queue spec.