Skip to content
← ProjectsAIDevIn progress

Multi-agent delivery in a single Rust binary, with roles running in parallel.

Role
Architect
Period
2026
koklo-cli

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.

  1. 01Dev

    SQLite as single source

    Durable queue with expiring leases: a SIGKILL releases work on its own.

  2. 02AI

    Six providers

    Claude Code, Codex, OpenRouter, Ollama, OpenAI-compatible and mock; model per role, costs tracked.

  3. 03Ops

    Sandboxed local gates

    Lint, test, build in seconds under Landlock/bubblewrap; forge CI is a bonus, never a requirement.

  4. 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

koklo doctor output: git, SQLite database in WAL mode with 21 migrations and sqlite-vec, detected providers, bubblewrap and landlock available
`koklo doctor` checks everything the loop needs and exits non-zero on a blocker, so it doubles as a script preflight.

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

A Markdown backlog with keys INV-001 to INV-004, its dry-run import and the resulting active sprint
Markdown is only an import format: once loaded, tickets, dependencies and criteria live in SQLite.

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

koklo gates run on a worktree: lint and build pass, test fails, the command exits with code 1
One broken test is enough: the `test` gate is blocking, the command exits 1 and the verdict lands in `gate_runs`.

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 status before and after a koklo loop run pass: the dev queue goes from 0 to 4 items
A single reconciliation put the four `todo` tickets back in the `dev` queue with nothing held in memory: everything is read back from the database.

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

ratatui TUI during a session: the spec phase is done and a "Phase validation" dialog offers Allow, Refuse or Edit
The human gate inherited from Koklo, in the terminal: the session waits for Y, N or `/edit` before the implement phase.

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.

TUI home screen: three sessions, one running and two completed, with the screen sidebar
The current TUI home, still built for one pipeline at a time: this is what ADR-006 replaces with five worker-centred screens.

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 states of a work_item, as migration 017 constrains them

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

crates/scheduler/src/queue.rs (condensed)
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

ADR-003 decision: each step avoids a pointless model session

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.

Scheduler tests and layer boundaries

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 status read 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

koklo doctor output: git, SQLite database in WAL mode with 21 migrations and sqlite-vec, detected providers, bubblewrap and landlock available
A Markdown backlog with keys INV-001 to INV-004, its dry-run import and the resulting active sprint
koklo loop status before and after a koklo loop run pass: the dev queue goes from 0 to 4 items
TUI home screen: three sessions, one running and two completed, with the screen sidebar
Civinat
Next project →CivinatMulti-tenant learning platform with per-organisation modules.

Let’s talk. Describe your need in a few lines, I’ll get back to you within 48 hours.

koklo-cli — Yahse Koffi — koklo.dev