Delivery multi-agents en un seul binaire Rust, avec des rôles qui tournent en parallèle.
- Rôle
- Architecte
- Période
- 2026

15
crates en couches
6
providers LLM
8
décisions d’architecture
1
binaire
Contexte
La boucle de shipyard est rodée mais séquentielle : la review attend, le triage attend, les merges sont sérialisés.
Le défi
Paralléliser les rôles sans perdre la fiabilité : reprise après crash, verrous, gates bloquants, coûts maîtrisés.
- 01Dev
SQLite source unique
File durable et baux à expiration : un SIGKILL libère le travail tout seul.
- 02IA
Six providers
Claude Code, Codex, OpenRouter, Ollama, OpenAI-compatible et mock ; modèle choisi par rôle, coûts comptabilisés.
- 03Ops
Gates locaux sous sandbox
Lint, test, build exécutés en secondes sous Landlock/bubblewrap ; la CI de forge est un bonus, jamais un prérequis.
- 04IA
Recherche hybride
BM25 + embeddings fusionnés par RRF, injectés dans le contexte des agents.
Architecture
Stack
- Rust
- SQLite
- AOAgents & orchestration
- OpenRouter / Mistral
- Ollama
Résultats
- ✦8 ADR et frontières de couches vérifiées en CI
- ✦Audit des 33 « cicatrices » de shipyard à ne pas repayer
Le récit
Toutes les sessions de cette page ont été lancées pour de vrai sur un dépôt Rust jetable, invoice-api, avec un HOME isolé. Le pipeline TUI tourne sur le provider mock, prévu pour les tests, afin de ne consommer aucun quota.
Comment ça marche
1. Vérifier la machine

Avant de lancer des agents en parallèle, koklo doctor passe tout en revue : git et ses worktrees (un par worker), la base SQLite en mode WAL avec un pool dimensionné sur le nombre de workers, l’extension sqlite-vec pour la recherche, les providers présents, et le confinement. Ici bubblewrap est retenu : les commandes d’agent tourneront dans leurs propres namespaces de montage, de PID et de réseau.
2. Charger un backlog

Le backlog s’écrit en Markdown, au format déjà utilisé par shipyard : une clé, un titre, puis la priorité, l’agent, les dépendances et les critères d’acceptation. koklo backlog import le transforme en tickets. À partir de là, plus de fichier à parser : chaque changement de statut est un événement dans un journal en ajout seul, lisible avec koklo ticket history.
3. Des gates locaux qui bloquent vraiment

Les gates tournent dans le worktree du ticket, jamais dans l’arbre principal. La stack est détectée seule : pour un projet cargo, cargo fmt, clippy, cargo test et cargo build. Ici j’ai cassé un test exprès. Le gate test est bloquant, donc la commande sort en 1. Dans la boucle, ce verdict renverra le ticket au dev sans même lancer de session de review.
4. La boucle et ses files

koklo loop run réconcilie au démarrage puis toutes les cinq secondes. Au premier passage, les quatre tickets todo sans travail vivant sont mis dans la file dev. Les workers de rôle ne sont pas encore branchés, et la commande le dit au lieu de faire semblant. koklo loop status montre l’occupation de chaque rôle par rapport à son quota : dev 3, review 2, triage 1, doc 1, pour 6 workers au total.
5. La TUI, héritée de Koklo

La TUI ratatui vient de Koklo. Elle suit une session phase par phase et s’arrête à chaque gate humaine. Elle est encore pensée pour un seul pipeline à la fois.

Avec plusieurs workers en parallèle, la vraie question devient « qui travaille sur quoi, et qu’est-ce qui est coincé ». L’ADR-006 ramène donc la TUI de 16 routes à 5 écrans : Board, Workers, Ticket, Search et Settings. L’écran Workers est celui qui rendra le parallélisme visible.
Sous le capot
La vie d’un élément de file
La table work_items porte le travail, pas le ticket : un ticket peut attendre la doc pendant qu’il est en review. Un index unique partiel garantit au plus un élément vivant par couple ticket et rôle. Un worker prend un bail et le renouvelle au quart de son TTL. S’il meurt, même par SIGKILL, le bail expire et la réconciliation remet le travail en file. Une limite de quota rend l’élément sans consommer d’essai et suspend l’admission pour ce provider seulement : une review sur Ollama continue pendant que les devs attendent.
Prendre un bail sans course
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()
}Toute l’attribution tient dans une seule instruction. Un SELECT suivi d’un UPDATE laisserait deux workers prendre le même élément. Ici SQLite sérialise l’écriture, et la sous-requête écarte les tickets dont une dépendance n’est pas terminée. Le commentaire sur fetch_all vient d’un vrai piège : avec fetch_optional, l’instruction peut être réinitialisée avant la fin de la mutation, et le bail rendu à l’appelant n’est pas forcément écrit.
L’ordre des gardes avant un merge
Cet ordre vient des incidents de shipyard. Un conflit se règle avant d’accuser le code. Un gate local rouge produit un request-changes sans lancer de reviewer, donc sans dépenser de tokens. Le CI de forge ne s’ajoute que s’il existe un dépôt distant : koklo-cli fonctionne entièrement hors ligne.
Ce que j’en retiens
- Partir des cicatrices. L’audit d’héritage liste 33 incidents de shipyard à ne pas repayer. Les tickets du scheduler en citent plusieurs, et les tests qui les couvrent disent ce qu’ils prouvent :
un_worker_tue_par_sigkill_voit_son_travail_repris,la_review_demarre_meme_quand_le_quota_dev_est_sature. - Mettre l’état vivant en base, jamais en mémoire. C’est ce qui rend la reprise gratuite, et ce qui permet à
koklo loop statusde lire l’état depuis un autre processus. - Resserrer. Koklo couvrait trop de terrain. Ici, un binaire, une base, et des frontières de couches vérifiées en CI par un script.
Galerie





Parlons-en. Décrivez votre besoin en quelques lignes, je reviens vers vous sous 48 h.