Skip to main content
The board is Clawboo’s durable, transactional source of truth for task coordination. It is a set of SQLite tables, tasks, task_deps, task_comments, workspaces, and execution_processes, fronted by a single data-access layer that every other subsystem goes through. When a team delegates work, the work becomes a board task; when an agent picks up work, it atomically claims a board task; when a run completes, the outcome lands on the board. Refresh the page, restart the server, run twelve agents at once against one database file; the board survives all of it. This page explains what the board is and isn’t, the task lifecycle and its legal transitions, the atomic claim that makes single-assignee work race-free, dependency chains, the link to per-task worktrees, the write-contention recipe that keeps many concurrent writers honest, and how orphaned and stale work is recovered.

What it is, and what it isn’t

The board is canonical for task and coordination state; chat is narration of that state. A board mutation is the decision of record. A chat message is a description of a decision, never a write path back to the board. The REST handlers reflect each board mutation into the team chat room after the canonical write completes, best-effort, precisely so the narration can never become the authority. The board does not duplicate agent or session state. Who exists lives in the agent registry of record; how an agent runs lives in its runtime. The board references agents and runtimes by id and owns only task state: title, status, assignee, priority, dependencies, the worktree pointer, the cost ledger, and a verification verdict. It is the dispatcher; the worktree is the world the dispatched work happens in. The board is the substrate beneath delegation and orchestration: the orchestrator turns structured delegation signals into board tasks, and the board’s primitives (claim, dependencies, status transitions) are what make that orchestration durable and race-free.

The task lifecycle

A task moves through seven statuses. The full set is backlog, todo, in_progress, in_review, blocked, done, and cancelled. done and cancelled are terminal; they have no outgoing transitions. A newly created task defaults to todo (immediately claimable) unless the caller asks for backlog (triage-only, not yet ready to work). The transition table is the single authority on what is legal. It is declared exactly once in code, in @clawboo/board-core — the server enforces it inside the write transaction and the board UI derives its status editor from the same module, so the two cannot drift: A few rules are worth calling out:
  • Same-status is an idempotent no-op and always allowed, so re-emitting a transition does no harm.
  • in_progress → todo is the release path. A task can fall back to todo to be re-claimed; doing so clears the assignee (and the assignee runtime), so the atomic claim can re-acquire it. It also clears the stored verification verdict, because releasing for re-claim is a cross-runtime rebind boundary; a previous runtime’s failing verdict must not gate a fresh runtime’s legitimate completion. The release also detaches the task from any resident team orchestrator still mapping it to a session, since that mapping would otherwise keep refusing the re-claim.
  • in_progress and in_review are “locked” statuses. While a task is in one of them it is actively owned, and its assignee must not be reassigned.
Status changes go through one function that enforces the table against the freshly read row, inside a write transaction, so a stale pre-check from a concurrent caller can never sneak through. A REST-layer pre-check, if any, is only fast-fail ergonomics; the transactional check is the real gate. An illegal transition returns a 409 from the REST layer.
Reaching done carries an intrinsic verification gate. Any transition to done is rejected with verification_required when the task carries a non-promotable verification verdict (a failing deterministic gate, including red-gate completed_with_debt). This is the builder-≠-judge rule enforced in the board itself, un-bypassable by any caller except an explicit, audited humanOverride. A task with no stored verdict is unverified, not failing, and lands done normally. See Verification.

The atomic claim

A task is worked by exactly one assignee. The board guarantees this with a single conditional UPDATE rather than a read-then-write:
Because the guard (status='todo' AND assignee IS NULL AND dropped=0) is part of the same atomic statement that does the write, at most one concurrent caller can win. The winner gets the updated row back; every loser gets zero rows. That zero-row result is data, not an error. The claim function returns { ok: false, reason: 'conflict' } and the REST layer maps it to a 409. The rule the whole system follows is never retry a 409; a conflict means someone else legitimately owns the work, so a retry would either no-op or fight for a task that is already taken. (A 404 not_found distinguishes “the task doesn’t exist” from “someone already claimed it.”)
The “never retry a 409” rule is structural. The contention layer that wraps the claim retries only transient SQLite lock errors; it explicitly returns a 0-row result to the caller unretried, so application-level conflicts and database-level lock contention are handled by completely different mechanisms. See the contention recipe.
Stale re-claim of a dead, abandoned in_progress task is deliberately not the claim’s job. Liveness lives in one place; reconciliation releases such a task back to todo, after which an ordinary claim re-acquires it.

Dependencies

Tasks form a blocks / blocked-by graph in the task_deps table. Linking a dependency means “this task won’t become ready to work until that task is done.” A composite primary key on (task_id, depends_on_task_id) prevents duplicate edges, and inserts are conflict-tolerant, so re-linking is harmless. A link that would close a cycle — directly (A → B, then B → A) or transitively — is refused at write time, because a cycle can never resolve: readiness requires every dependency to be done, so each task in the cycle would sit un-ready forever with nothing surfacing the stall. The reachability check runs in the same transaction as the insert, so concurrent links cannot race one in. A task is ready when it is todo, not dropped, and every one of its dependencies is done. Plans become dependency chains: each step depends on the prior step, and an orchestration ready-pump fires the next step the moment its blocker completes. Ready tasks are ordered by priority (descending), then by recency. Dependencies also drive failure recovery. When a blocker fails, moves to blocked or otherwise can’t reach done, its downstream chain can never become ready and would otherwise sit as permanent ghost todo cards. The board can cancel the still-pending (todo / backlog) transitive dependents of a failed task via a recursive walk of the dependency graph, returning the cancelled rows so the orchestrator can report the stalled plan to the team leader. Tasks already in_progress, done, or cancelled are left untouched. The board similarly walks the parent chain (via the self-referential parent_task_id) with a recursive query to compute a task’s ancestors, which is how delegation depth is enforced: the orchestrator and the board’s capped create path refuse a create once the parent’s ancestor chain has reached the depth ceiling (default 2), while the executor runner refuses a dispatch only for a task whose own ancestor chain is already past it. The two comparisons differ on purpose: the invariant is that anything creatable is dispatchable, so a task sitting exactly at the ceiling still runs. That create path adds two ceilings of its own: a per-parent live-child count (default 24), so an agent looping on create_subtask cannot quietly fill the board with rows, and a rolling-window rate on ROOT creation (default 30 per 5 minutes), which bounds the other runaway shape: a loop that files parentless tasks forever. Both recursive reads validate their raw SQL output with a schema at runtime rather than trusting the type system over a raw query.

Worktree linkage

When a task involves file mutation, it gets an isolated git worktree so concurrent runs never collide. The board doesn’t own that worktree; it owns a pointer to it. Two columns on the task, worktree_ref and branch_ref, record where the isolated work lives; a workspaces row tracks the worktree’s own lifecycle (active, then archived on cleanup or stale on garbage collection). The board is annotating the durable task with a filesystem location; the worktree subsystem owns the actual checkout and the system-of-record files inside it. Read-only or research tasks don’t pay the worktree cost; provisioning a worktree for a non-file-mutating task is refused (the REST layer returns 422). The worktree is for the concurrency boundary that file-mutating work needs.

Execution processes

Each spawned run for a task is recorded as an execution_processes row, a per-run ledger that exists for any executor. An exec row is opened only after a successful claim, starts in running, and is closed with an outcome (succeeded, failed, timed_out, or cancelled) plus an optional token / cost ledger and git before/after commit checkpoints. This ledger is what crash recovery reads on restart, which is why an executor that starts work must open one and close it.

The contention recipe

Clawboo is team-first: many agents may write one SQLite file. Without care, concurrent writers hit SQLite’s single-writer lock and degrade into a “convoy.” The board’s answer is a layered recipe: five connection-level pragmas plus two application-level pieces. Every connection sets, at open: On top of those, the board’s data-access layer adds two application-level mechanisms:
  • Jittered retry. Writes are wrapped so that only transient lock errors (SQLITE_BUSY, SQLITE_BUSY_SNAPSHOT, SQLITE_LOCKED) are retried, with a jittered backoff (20–150 ms) bounded by a 1.5-second wall-clock budget rather than an attempt count, because the retry sleep blocks the server’s event loop: an attempt count does not bound how long a contended write can freeze the server, a deadline does. A 0-row result, like a lost claim race, is returned to the caller unretried, which is what lets callers honor the never-retry-a-409 rule. Any other error propagates immediately.
  • BEGIN IMMEDIATE transactions. Multi-step writes (status transitions, reconciliation passes) acquire the write lock up front rather than escalating mid-transaction, which avoids lock-escalation deadlocks. The whole transaction re-runs from scratch on a transient lock error.
Because better-sqlite3 is fully synchronous, the retry uses a synchronous, non-busy-spinning sleep (Atomics.wait); making every repository method async just to back off would be a needless cost.

Orphan and stale reconciliation

Two recovery passes keep the board from accumulating stuck work: one for crashes, one for abandonment. Orphan reconciliation runs once at startup, and it is liveness-aware. An execution row still marked running is reaped only when its task has stopped beating, meaning updated_at older than the same TTL the stale sweep uses. A still-beating row belongs to a live process, such as a second Clawboo on the same state dir or the previous server’s drains during a fast restart, so it is spared. Each reaped execution is marked failed, gets a recovery_tombstone so a second pass is a no-op (no infinite auto-resume), and has its task released back to todo so a fresh claim can pick it up. A run that truly died but is not yet stale at boot keeps missing beats and is released by the interval sweep below once the TTL has passed. It runs in a single BEGIN IMMEDIATE transaction and never blocks boot. Stale-task reconciliation runs on an interval. It releases an in_progress task whose owner has stopped proving it is alive. A task that is in_progress, whose updated_at is older than the TTL, and whose execution is still running is timed out and released to todo. A boot pass plus a periodic interval cover both startup and steady state.
tasks.updated_at is a liveness signal. Every drain that claims a task heartbeats it every 30 seconds for as long as it owns the task, on a timer rather than on event traffic, so a working-but-silent run keeps beating and is never falsely swept. That is what lets the TTL be short: 3 minutes by default, six missed beats, tunable via CLAWBOO_BOARD_STALE_TTL_MS. The per-team server orchestrator’s own 8-minute idle watchdog (swept every 30 seconds) still handles a delegate that goes quiet without dying, and it runs server-side, so closing the browser does not stop it.

Design rationale and trade-offs

The board exists because narration-only orchestration is fragile: a chat transcript can’t be transactionally claimed, can’t survive a refresh as authority, and can’t tell a crashed run from a slow one. Making the board canonical buys durability (state survives restart), race-freedom (the atomic claim), and recoverability (the two reconciliation passes), at the cost of a second persistence layer beside the runtime’s own session state. SQLite is a deliberate choice: it ships in-process, needs no server, and, with the WAL recipe, handles the team-scale concurrency Clawboo needs without a database to administer. The single data-access layer is the seam where a future SQLite→Postgres or multi-tenant move would land; the dormant tenant_id column on every board table is the placeholder for that.

Boundaries and non-goals

  • Not a general-purpose project tracker. The board exists to coordinate agent execution, not to replace a human-facing issue tracker. Its statuses, transitions, and recovery passes are tuned for runtime-driven work.
  • Not the agent or session registry. The board references agents and sessions by id and owns no agent identity, agent files, or live session state. Those belong to the registry of record and the runtime.
  • Single implicit tenant today. Every board table carries a tenant_id column, but it is a dormant seam; no per-tenant filtering is active in v0.3.1. Multi-tenant scoping is a future seam, not a shipped feature.
These docs describe Clawboo v0.3.1, the current release.

See also

Last modified on August 21, 2026