Aller au contenu
← ProjetsIADevEn cours

Delivery multi-agents en un seul binaire Rust, avec des rôles qui tournent en parallèle.

Rôle
Architecte
Période
2026
koklo-cli

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.

  1. 01Dev

    SQLite source unique

    File durable et baux à expiration : un SIGKILL libère le travail tout seul.

  2. 02IA

    Six providers

    Claude Code, Codex, OpenRouter, Ollama, OpenAI-compatible et mock ; modèle choisi par rôle, coûts comptabilisés.

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

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

Sortie de koklo doctor : git, base SQLite en WAL avec 21 migrations et sqlite-vec, providers détectés, bubblewrap et landlock disponibles
`koklo doctor` vérifie tout ce dont la boucle a besoin et sort en code non nul s’il manque un bloquant : il sert aussi de préflight dans un script.

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

Un backlog Markdown avec des clés INV-001 à INV-004, son import en dry-run et le sprint actif qui en résulte
Le Markdown n’est qu’un format d’import : une fois en base, les tickets, leurs dépendances et leurs critères vivent dans SQLite.

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

koklo gates run sur un worktree : lint et build passent, test échoue, la commande sort en code 1
Un test cassé suffit : le gate `test` est bloquant, la commande sort en 1 et le verdict est écrit dans `gate_runs`.

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 status avant et après un passage de koklo loop run : la file dev passe de 0 à 4 éléments
Une seule réconciliation a remis les quatre tickets `todo` dans la file `dev`, sans rien en mémoire : tout est relu en base.

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

TUI ratatui pendant une session : la phase spec est terminée et une fenêtre « Validation de la Phase » propose Autoriser, Refuser ou Modifier
La gate humaine héritée de Koklo, dans le terminal : la session attend un Y, un N ou un `/edit` avant la phase implement.

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.

Écran d’accueil de la TUI : trois sessions, une en cours et deux terminées, avec la barre latérale des écrans
L’accueil actuel de la TUI, encore taillé pour un pipeline à la fois : c’est ce que l’ADR-006 remplace par cinq écrans centrés sur les workers.

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

Les états d’un work_item, tels que la migration 017 les contraint

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

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

Décision de l’ADR-003 : chaque étape évite une session de modèle inutile

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.

Tests du scheduler et frontières de couches

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

Sortie de koklo doctor : git, base SQLite en WAL avec 21 migrations et sqlite-vec, providers détectés, bubblewrap et landlock disponibles
Un backlog Markdown avec des clés INV-001 à INV-004, son import en dry-run et le sprint actif qui en résulte
koklo loop status avant et après un passage de koklo loop run : la file dev passe de 0 à 4 éléments
Écran d’accueil de la TUI : trois sessions, une en cours et deux terminées, avec la barre latérale des écrans
Civinat
Projet suivant →CivinatPlateforme d’apprentissage multi-tenant, modules activables par organisation.

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