Multi-agent delivery in a single Rust binary, with roles running in parallel.
- Role
- Architect
- Period
- 2026

15
layered crates
6
LLM providers
8
architecture decisions
1
binary
Context
Shipyard’s loop is battle-tested but sequential: review waits, triage waits, merges are serialised.
The challenge
Parallelise roles without losing reliability: crash recovery, locks, blocking gates, controlled costs.
- 01Dev
SQLite as single source
Durable queue with expiring leases: a SIGKILL releases work on its own.
- 02AI
Six providers
Claude Code, Codex, OpenRouter, Ollama, OpenAI-compatible and mock; model per role, costs tracked.
- 03Ops
Sandboxed local gates
Lint, test, build in seconds under Landlock/bubblewrap; forge CI is a bonus, never a requirement.
- 04AI
Hybrid search
BM25 + embeddings fused with RRF, injected into agent context.
Architecture
Stack
- Rust
- SQLite
- AOAgents & orchestration
- OpenRouter / Mistral
- Ollama
Results
- ✦8 ADRs and layer boundaries checked in CI
- ✦Audit of shipyard’s 33 “scars” not to pay twice
The story
Every session on this page was actually run on a throwaway Rust repository, invoice-api, with an isolated HOME. The TUI pipeline runs on the mock provider, built for tests, so it spends no quota.
How it works
1. Check the machine

Before running agents in parallel, koklo doctor goes through everything: git and its worktrees (one per worker), the SQLite database in WAL mode with a pool sized on the worker count, the sqlite-vec extension for search, the providers on the machine, and confinement. Here bubblewrap is picked: agent commands will run in their own mount, PID and network namespaces.
2. Load a backlog

The backlog is written in Markdown, in the format shipyard already uses: a key, a title, then priority, agent, dependencies and acceptance criteria. koklo backlog import turns it into tickets. From then on there is no file to parse: every status change is an event in an append-only log, readable with koklo ticket history.
3. Local gates that actually block

Gates run in the ticket’s worktree, never in the main checkout. The stack is detected on its own: for a cargo project, cargo fmt, clippy, cargo test and cargo build. Here I broke a test on purpose. The test gate is blocking, so the command exits 1. In the loop, that verdict sends the ticket back to dev without even starting a review session.
4. The loop and its queues

koklo loop run reconciles at startup and then every five seconds. On the first pass, the four todo tickets with no live work go into the dev queue. Role workers are not wired in yet, and the command says so instead of pretending. koklo loop status shows each role’s occupancy against its quota: dev 3, review 2, triage 1, doc 1, for 6 workers in total.
5. The TUI, inherited from Koklo

The ratatui TUI comes from Koklo. It follows a session phase by phase and stops at every human gate. It is still designed for one pipeline at a time.

With several workers in parallel, the real question becomes “who is working on what, and what is stuck”. ADR-006 therefore cuts the TUI from 16 routes to 5 screens: Board, Workers, Ticket, Search and Settings. The Workers screen is the one that will make parallelism visible.
Under the hood
The life of a queue item
The work_items table holds the work, not the ticket: a ticket can wait for docs while it is in review. A partial unique index guarantees at most one live item per ticket and role. A worker takes a lease and renews it at a quarter of its TTL. If it dies, even by SIGKILL, the lease expires and reconciliation puts the work back in the queue. A rate limit releases the item without spending an attempt and pauses admission for that provider only: a review on Ollama carries on while the devs wait.
Taking a lease without a race
pub async fn lease(&self, role: &str, owner: &str, ttl: Duration) -> Result<Option<WorkItem>> {
let now = Utc::now();
let sql = format!(
"UPDATE work_items \
SET state = 'leased', lease_owner = ?, lease_expires_at = ?, leased_at = ? \
WHERE id = ( \
SELECT candidate.id FROM work_items candidate \
WHERE candidate.state = 'queued' AND candidate.role = ? \
AND NOT EXISTS ( \
SELECT 1 FROM ticket_deps dep \
JOIN tickets blocker ON blocker.id = dep.depends_on \
WHERE dep.ticket_id = candidate.ticket_id \
AND blocker.status NOT IN ('done', 'closed')) \
ORDER BY candidate.priority DESC, candidate.enqueued_at ASC \
LIMIT 1) \
RETURNING {}",
WorkItem::COLUMNS
);
// `fetch_all`, et non `fetch_optional`, sur un `RETURNING` qui mute.
let rows = sqlx::query(&sql)
.bind(owner)
.bind(format_time(now + ttl))
.bind(format_time(now))
.bind(role)
.fetch_all(&self.pool)
.await?;
rows.first().map(WorkItem::from_row).transpose()
}The whole assignment fits in one statement. A SELECT followed by an UPDATE would let two workers grab the same item. Here SQLite serialises the write, and the subquery skips tickets with an unfinished dependency. The comment on fetch_all comes from a real trap: with fetch_optional, the statement can be reset before the mutation finishes, and the lease handed back to the caller may never have been written.
The guard order before a merge
This order comes from shipyard incidents. A conflict is handled before blaming the code. A red local gate yields a request-changes without starting a reviewer, so without spending tokens. Forge CI is only added when a remote exists: koklo-cli works fully offline.
What I take away
- Start from the scars. The heritage audit lists 33 shipyard incidents not to pay for twice. Scheduler tickets cite several of them, and the tests that cover them say what they prove:
un_worker_tue_par_sigkill_voit_son_travail_repris(a SIGKILLed worker gets its work taken over),la_review_demarre_meme_quand_le_quota_dev_est_sature(review starts even when the dev quota is full). - Keep live state in the database, never in memory. That is what makes recovery free, and what lets
koklo loop statusread the state from another process. - Narrow down. Koklo covered too much ground. Here: one binary, one database, and layer boundaries checked in CI by a script.
Gallery




