Best-of-N & la moisson
Une demande, un commit épinglé, trois à cinq rosters dans trois à cinq boîtes — puis une moisson qui lit les chiffres, propose un vainqueur et vous laisse disposer. La pièce du jour atteint le jalon du module : un best-of-N lancé, moissonné, comparé.
Au chapitre 17, votre banc a comparé quatre rosters sur votre machine, l’un après l’autre,
parce que quatre runs sur un seul arbre de travail se marcheraient dessus. Hier, vous avez appris
à faire entrer du travail dans une boîte par une commande, et vous avez lu pourquoi cette porte-là,
et pas la délégation, rend des runs comparables. Il ne reste qu’à assembler : à la fin de ce
chapitre, vous saurez lancer la même demande sur trois à cinq rosters, chacun dans sa propre
boîte, puis moissonner les résultats en un tableau (verdict, coût réel lu sur la clé, durée,
taille du patch) que le code classe et que vous tranchez. La pièce du jour est
adws/sandbox_bestof.py, avec ses recettes dans orch.just et le premier skill de l’orchestrateur
hors-boîte. C’est le jalon du module 6 : l’usine hors-site est complète.
Fan-out : N rosters sur le même problème
L’idée en une phrase
Un best-of-N lance N boîtes, jamais N agents dans une boîte, sur une seule variable (le roster), toutes les autres étant tenues par le code déterministe : même commit épinglé, même demande, même ADW, même porte d’entrée. Le fan-out est une boucle sur les phases des chapitres 22 et 23, et ne réinvente aucune mécanique.
Points clés
- N boîtes, pas N agents. Le module 6 existe pour retirer une collision : cinq agents sur un arbre de travail, un port, un historique git. Lancer N agents dans une seule boîte, c’est monter une boîte pour racheter la collision. Une boîte par bras vous donne aussi ce qui rend les bras comparables : une clé par bras (un coût que personne n’a à décomposer), un plafond par bras (un bras qui déraille ne vide pas le budget des autres), un commit de référence par bras (un patch propre), une porte par bras (un journal d’egress lisible). Sur Podman, N boîtes coûtent zéro euro. C’est la RAM de votre machine qui borne N, trois à cinq sur un portable, et exe.dev lève cette borne quand vous en avez besoin.
- Une seule variable varie. Si les demandes diffèrent, vous comparez des prompts, pas des
rosters. Si les commits diffèrent, vous comparez deux versions de Plume. Le script refuse donc
de partir sur un arbre de travail sale :
fillcharge votre arbre, et un fichier non commité est un bras qui reçoit autre chose que les autres. - La porte est
execute, jamaisdelegate. Vous l’avez lu hier : une délégation échange la garantie « ce qui a tourné est ce que j’ai tapé » contre du jugement. Un best-of-N a besoin de bras identiques, donc la commande, à zéro token d’orchestration. - Un bras rouge est un résultat. Une gate de
setupqui échoue arrête ce bras, garde sa boîte debout pour diagnostic, et laisse les autres continuer. Le fan-out ne s’interrompt pas, et ne détruit rien. - Le gate de chaque bras pinge son propre roster.
setupreçoit--config: le scout de la gate tourne sous les modèles que le bras exécutera. Un modèle absent du registre est découvert avant le SDLC, pas au milieu.
Exemple concret
Vous voulez savoir lequel de vos quatre rosters ajoute un compteur de mots à Plume au meilleur
prix, en vert. just sandbox fanout compteur "Ajoute un compteur de mots à Plume" adws/adw_config/eco.config.yaml adws/adw_config/open-weights.config.yaml adws/adw_config/factory.config.yaml
: le script vérifie que votre arbre est propre, note le commit, puis déroule pour chaque roster
la chaîne create → fill → setup → execute : une vingtaine de secondes par bras sur Podman
(une minute chez exe.dev), moins d’un centime (le scout de la gate), avec --limit 5 par bras
comme filet. Une minute plus tard, trois SDLC tournent détachés dans trois boîtes fermées, sur
le même commit, avec la même demande. Le lot est écrit
sous adws/adw_data/sandbox/bestof-compteur-<date>-<hex>.json : la liste des bras, le commit,
la demande. Votre machine, elle, n’a rien exécuté de l’usine. Coût total attendu du lot, à la
fin : de quelques centimes à un peu plus d’un dollar selon les rosters, l’essentiel sur le
siège frontier du roster de production.
Ce qui varie, ce qui est tenu
| Dimension | Qui la tient | Pourquoi |
|---|---|---|
Le roster (--config) | varie : un par bras | c’est la question posée |
| Le commit | le code, épinglé avant la première boîte | deux commits = deux Plume, la comparaison est du bruit |
| La demande | le code, la même chaîne pour tous | des prompts différents comparent des prompts |
| L’ADW | le code, adw_sdlc par défaut | la chaîne complète, gates comprises (ch. 13) |
| La porte | execute, détachée | zéro token d’orchestration, reproductible (ch. 24) |
| Le plafond | la clé jetable de chaque bras | un bras qui déraille ne touche pas les autres (ch. 23) |
Commande — un fan-out, et ce que le lot retient
Une seule version suffit : le fan-out est du code qui appelle du code, les phases des chapitres 22 et 23, et le harnais reste derrière le port, choisi par chaque roster. Voici la forme du lot que le script écrit après chaque phase, pour que la moisson retrouve ses bras même si vous fermez le terminal.
{
"batch_id": "compteur-20260902-4e7a1b",
"prompt": "Ajoute un compteur de mots à Plume",
"adw": "adw_sdlc",
"pin": "9f2c1e0b7a…",
"created_at": "2026-09-02T09:12:04Z",
"closed_at": null,
"arms": [
{ "roster": "adws/adw_config/eco.config.yaml", "run_id": "compteur-eco-20260902-a91f02", "state": "running" },
{ "roster": "adws/adw_config/open-weights.config.yaml", "run_id": "compteur-open-weights-20260902-7c33d8", "state": "running" },
{ "roster": "adws/adw_config/factory.config.yaml", "run_id": "compteur-factory-20260902-0be4c1", "state": "failed at filled" }
]
}
Le troisième bras s’est arrêté à setup : sa boîte est debout, la commande d’hier
just sandbox cmd compteur-factory-… tail -20 run.log vous dit pourquoi, et just sandbox setup
se relance sur ce bras seul.
Piège courant : « N agents dans une boîte, c’est N fois moins cher que N boîtes » est inexact. Une boîte Podman ne coûte rien et se démonte en une seconde (une VM exe.dev, quelques centimes de l’heure), tandis que N agents partageant un arbre de travail produisent un patch illisible, un coût que personne ne sait attribuer et un
bun testqui ne décrit plus aucun bras. Ce que vous économisez en boîtes, soit rien sur Podman, vous le perdez en comparaison, et la comparaison est le seul produit du best-of-N.
Harvest : comparer, choisir, jeter le reste
L’idée en une phrase
La moisson lit chaque bras de l’extérieur (verdict des tests, statut du run dans la trace, tentatives, taille du patch, coût réel relevé sur la clé) et classe : vert d’abord, puis le moins cher, puis le plus rapide. Elle vit entièrement côté code déterministe, coûte zéro token, se rejoue à volonté, et propose un vainqueur que vous seul disposez de garder. La destruction des autres bras est un verbe à part, à sec par défaut.
Points clés
- Trois sources, toutes lues, aucune jugée. Le verdict vient de
bun testrelancé dans la boîte et du statutsuccessde la tableruns(chapitre 18). Le coût vient de la clé jetable du bras (chapitre 23, le chiffre qui fait foi, relevé avant toute révocation) et, à côté, du cumul de la trace. La taille du travail vient d’ungit diff --shortstatcontre le commit de référence. Aucun agent ne note un autre agent. - Le classement est lisible, pas pondéré. Vert avant tout, puis parmi les verts le moins cher, puis à coût égal le plus rapide. Trois colonnes que vous pouvez contester valent mieux qu’un score opaque que vous ne pouvez pas.
- La moisson n’attend pas la fin. Un bras encore en cours apparaît « en cours » et le relevé
vous invite à rejouer plus tard. Rien n’est détruit, rien n’est écrit dans la boîte hors
/tmp, et vous pouvez moissonner toutes les cinq minutes. Elle lit chaque bras par le port du chapitre 22,podman execousshselon ce que la fiche du bras nomme, et un lot peut même mêler les deux moteurs. - Le patch est rapatrié à la moisson, pas au démontage. Chaque bras terminé dépose son
run.patchsousadws/adw_data/sandbox/<run>-artifacts/, le même queteardownproduira. La preuve n’attend pas la décision de démonter. - Jeter le reste est un verbe à part.
discard <lot> --keep <run>liste ce qu’il démonterait et s’arrête.--yesenchaîne leteardowndu chapitre 23 sur chaque perdant : dépense relevée, preuve rapatriée, clé révoquée, boîte détruite. Le bras gardé reste debout : vous l’observez, vous appliquez son patch, vous le démontez quand vous avez fini.
Exemple concret
Dix minutes après le fan-out, just sandbox harvest compteur-20260902-4e7a1b. Le tableau tombe :
le bras éco est vert, 14 tests sur 14, 5 phases sur 5 en 6 tentatives, un patch de 3 fichiers,
~5 centimes sur sa clé, 7 minutes. Le bras open-weights est vert aussi, un patch plus
gros, ~15 centimes, 9 minutes. Le bras factory, réparé et relancé, est encore en cours.
Le script propose l’éco, « vert, le moins cher des verts », et imprime la commande de
discard sans l’exécuter. Vous ouvrez les deux patches : l’open-weights a aussi ajouté un test
de bord que l’éco n’a pas écrit. C’est ce que la moisson ne peut pas voir, et c’est pour cela
qu’elle propose sans disposer. Vous gardez l’open-weights, vous lancez discard … --keep <open-weights> --yes : deux boîtes de moins, deux clés révoquées, deux fiches fermées. Coût de la
moisson : zéro token, une dizaine de secondes, soit trois entrées dans la boîte et un relevé de
clé par bras.
Les colonnes de la moisson, et d’où elles viennent
| Colonne | Source | Lue comment |
|---|---|---|
| verdict | bun test dans la boîte + runs.status | code retour, puis la trace du chapitre 18 |
| tests, phases, essais | bun test, table phases | comptes lus, jamais estimés |
| diff | git diff --cached --shortstat <référence> | contre le commit posé par fill |
| clé $ | la clé jetable du bras (GET /key) | le chiffre qui fait foi, avant révocation |
| trace $ | runs.cost_usd | le cumul par phase, pour contrôle |
| min | runs.started_at → ended_at | durée du run, pas de la boîte |
Commande — une moisson lue, et le skill de l’orchestrateur hôte
La moisson elle-même n’a qu’une version, déterministe. Ce qui a deux versions, c’est
l’orchestrateur hors-boîte promis hier : un agent sur votre machine qui n’a que la surface
just sandbox, et qui la lit dans un skill, le même fichier pour les deux harnais. pi le charge
par --skill, Claude Code le découvre sous .claude/skills/. C’est une session interactive à
vous, pas une phase de l’usine, et le port du chapitre 7 n’est pas concerné.
# la moisson : zero token, rejouable, non destructive
uv run adws/sandbox_bestof.py harvest compteur-20260902-4e7a1b
# l'orchestrateur hote, version pi — le skill charge explicitement, la session est a vous
pi --skill .claude/skills/factory-orchestrator "Lisez et appliquez le skill factory-orchestrator, puis attendez ma demande."
# la meme posture en Claude Code — le skill est auto-decouvert sous .claude/skills/
claude "Lisez et appliquez le skill factory-orchestrator, puis attendez ma demande."
Piège courant : « la moisson choisit le meilleur bras » est inexact. Elle classe sur des chiffres qu’elle peut lire, et un patch plus cher peut valoir plus cher : un test de bord en plus, une décomposition plus propre, un nom de fonction que vous garderez. Le code propose le vert le moins cher, c’est vous qui ouvrez les patches et disposez, et le verbe qui détruit n’est jamais celui qui compare.
Fil rouge — la pièce posée aujourd’hui
Sur le plan de l’usine, la zone just/sandbox/ reçoit sa dernière logique, sandbox_bestof.py
et trois verbes dans orch.just à côté des orchestrateurs d’hier, et .claude/skills/ reçoit sa
première pièce, factory-orchestrator/SKILL.md, le brief de l’agent hôte. Le jalon du chapitre 25
est atteint : un best-of-N de trois à cinq rosters lancé, moissonné, comparé. La loi ne bouge pas,
l’agent propose et le code dispose, et aujourd’hui le code tient toutes les variables sauf une,
lit les résultats sans en juger, propose un vainqueur, et refuse de détruire sans que vous l’ayez
dit deux fois (--keep, puis --yes). La couture est exactement celle d’hier : chaque bras entre
par execute, chaque SDLC passe par le port avec le roster du bras. Ce qui traverse la frontière
à la moisson n’est pas une enveloppe d’agent mais un rapport de boîte, des lignes clé=valeur
lues par le port. À l’usage : le fan-out coûte une vingtaine de secondes et moins d’un centime
par bras avant le travail, zéro euro de machine sur Podman, la moisson zéro token, le lot
de quelques centimes à un peu plus d’un dollar,
contre un banc du chapitre 17 qui devait sérialiser les rosters sur votre machine, et un agent
seul qui n’aurait jamais lancé la même demande trois fois de la même façon.
Travaux pratiques — la pièce du jour
Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après
chapitre, votre usine logicielle agentique. Aujourd’hui : le best-of-N, ses recettes, et le skill
de l’orchestrateur hôte.
Prérequis : les mêmes qu’hier, Podman et l’image (ou un compte exe.dev), une clé de gestion. Sans eux, la première gate tourne à sec et tout le chapitre se lit. Trois boîtes Podman en parallèle demandent quelques gigaoctets de RAM libres. Budget de la gate complète : environ un dollar sur trois rosters dont le roster de production. La variante éco, en une ligne, tient dans quelques dizaines de centimes.
Pièce — adws/sandbox_bestof.py
Le best-of-N en trois verbes, tous côté hôte, tous côté déterministe. fanout importe les phases
des chapitres 22 et 23 telles quelles et n’en réécrit aucune. harvest lit les boîtes par le
port du chapitre 22, relève les clés du chapitre 23 et la trace du chapitre 18, classe et
propose. discard est la
seule action destructive, et elle enchaîne le teardown d’hier sur chaque perdant. Le lot vit
sous adw_data/, jamais commité.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""sandbox_bestof — best-of-N : N bras, une demande, une moisson (ch. 25).
Un best-of-N, c'est N BOITES — jamais N agents dans une boite. Chaque bras
a sa boite (conteneur Podman ou VM exe.dev, par le port du ch. 22), sa cle
jetable, son arbre de travail, son historique git, sa trace.
C'est ce qui rend les bras comparables : un cout par cle, un patch par
commit de reference, une base factory.db par run.
Trois verbes, et une loi par verbe :
fanout : le code fixe la variable de controle (le commit epingle, la
demande, l'ADW) et ne fait varier QUE le roster. Chaque bras
passe par les memes phases (ch. 22-23) et entre par `execute`,
la porte-commande — jamais par delegation (ch. 24).
harvest : lire, comparer, classer — zero token, non destructif, rejouable
a tout moment, meme sur un bras encore en cours. Le code PROPOSE
un vainqueur d'apres des chiffres lus, pas jugés.
discard : la seule action destructive — a sec par defaut, --keep obligatoire,
--yes pour agir. C'est vous qui disposez.
uv run adws/sandbox_bestof.py fanout <nom> "<demande>" --rosters a.yaml b.yaml [--limit 5] [--adw adw_sdlc]
uv run adws/sandbox_bestof.py harvest <lot>
uv run adws/sandbox_bestof.py discard <lot> --keep <run> [--yes]
uv run adws/sandbox_bestof.py list
uv run adws/sandbox_bestof.py --selftest # la gate a sec, zero reseau
"""
from __future__ import annotations
import argparse
import json
import re
import shlex
import subprocess
import sys
import tempfile
from pathlib import Path
# Les pieces des ch. 22-23, importees telles quelles (meme dossier : uv
# place adws/ en tete du chemin d'import). Rien n'est reecrit ici : un bras
# est une boite ordinaire, montee par les phases ordinaires.
import sandbox_box as boxes
import sandbox_keys as keys
import sandbox_lifecycle as life
from sandbox_keys import die, load_record, new_run_id, now, save_record
# Le lot vit a cote des fiches de run, sous adw_data/ : jamais commite.
BATCH_PREFIX = "bestof-"
# Ce que la boite rend a la moisson : des lignes cle=valeur, lues par le
# code hote, par le port boite. Le script est envoye par stdin (`bash -s`),
# comme au ch. 22 : $1 = pid du run, $2 = commit de reference. Il n'ecrit rien dans la boite
# hors /tmp et l'index git (git add -A, que teardown fait aussi).
HARVEST_SCRIPT = r"""
set -u
export PATH="$HOME/.bun/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
cd "$HOME/app" || { echo "reachable=0"; exit 0; }
echo "reachable=1"
if [ "$1" -gt 0 ] && kill -0 "$1" 2>/dev/null; then echo "running=1"; else echo "running=0"; fi
git add -A >/dev/null 2>&1
echo "shortstat=$(git diff --cached --shortstat "$2" 2>/dev/null)"
( cd apps/plume && bun test > /tmp/bestof-tests.log 2>&1 ); echo "tests_rc=$?"
echo "tests=$(grep -E '^ *[0-9]+ (pass|fail)' /tmp/bestof-tests.log | tr '\n' ' ')"
echo "run=$(sqlite3 -separator '|' adws/adw_data/factory.db "select status, round(cost_usd,4), started_at, ended_at from runs order by started_at desc limit 1" 2>/dev/null)"
echo "phases=$(sqlite3 -separator '|' adws/adw_data/factory.db "select count(*), sum(case when status='success' then 1 else 0 end), sum(attempt) from phases where adw_id=(select adw_id from runs order by started_at desc limit 1)" 2>/dev/null)"
"""
# ── le lot ──────────────────────────────────────────────────────────────────
def batch_path(batch_id: str) -> Path:
return keys.RUNS_DIR / f"{BATCH_PREFIX}{batch_id}.json"
def load_batch(batch_id: str) -> dict:
path = batch_path(batch_id)
if not path.is_file():
die(f"aucun lot {batch_id} — `list` montre les lots connus")
return json.loads(path.read_text(encoding="utf-8"))
def save_batch(batch: dict) -> None:
keys.RUNS_DIR.mkdir(parents=True, exist_ok=True)
batch_path(batch["batch_id"]).write_text(
json.dumps(batch, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def roster_stem(path: str) -> str:
"""`adws/adw_config/open-weights.config.yaml` → `open-weights` : le nom du bras."""
return Path(path).name.removesuffix(".yaml").removesuffix(".config")
def pinned_commit() -> str:
"""La variable de controle. fill (ch. 22) charge l'arbre de travail de l'hote :
un arbre sale, c'est un bras qui recoit autre chose que les autres."""
dirty = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True)
if dirty.returncode != 0:
die("pas un depot git — un best-of-N se lance depuis la racine de plume-factory")
if dirty.stdout.strip():
die("arbre de travail sale : commitez ou remisez d'abord — chaque bras doit"
" recevoir exactement le meme commit")
return subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True,
text=True).stdout.strip()
# ── fanout : N bras, une seule variable ─────────────────────────────────────
def fanout(name: str, prompt: str, rosters: list[str], limit: float, adw: str) -> None:
if not 2 <= len(rosters) <= 5:
die("un best-of-N va de 2 a 5 bras — au-dela, la moisson n'est plus lisible en un jour")
for roster in rosters:
if not Path(roster).is_file():
die(f"roster introuvable : {roster}")
keys.provisioning_key() # verifiee AVANT la premiere boite
pin = pinned_commit()
batch = {"batch_id": new_run_id(name), "prompt": prompt, "adw": adw, "pin": pin,
"created_at": now(), "closed_at": None, "arms": []}
save_batch(batch)
print(f"lot : {batch['batch_id']} ({len(rosters)} bras, commit {pin[:10]})")
for roster in rosters:
arm = {"roster": roster, "run_id": None, "state": "pending"}
batch["arms"].append(arm)
print(f"\n── bras {roster_stem(roster)} ──")
# Chaque phase est celle du ch. 22-23, inchangee. Un bras qui echoue a
# une gate STOPPE ce bras (sa boite reste debout, pour diagnostic) et
# ne touche pas aux autres : un rouge est un resultat.
try:
arm["run_id"] = life.create(f"{name}-{roster_stem(roster)}", ["--limit", str(limit)])
arm["state"] = "created"; save_batch(batch)
life.fill(arm["run_id"])
arm["state"] = "filled"; save_batch(batch)
life.setup(arm["run_id"], roster) # la gate pinge LE roster du bras
arm["state"] = "ready"; save_batch(batch)
life.execute(arm["run_id"], prompt, roster, adw)
arm["state"] = "running"; save_batch(batch)
except SystemExit:
arm["state"] = f"failed at {arm['state']}"; save_batch(batch)
print(f"bras {roster_stem(roster)} : arrete a l'etat « {arm['state']} » —"
" boite gardee, les autres bras continuent", file=sys.stderr)
launched = sum(1 for a in batch["arms"] if a["state"] == "running")
print(f"\nfanout : {launched}/{len(rosters)} bras lances, detaches, meme commit, meme demande")
print(f"suite : uv run adws/sandbox_bestof.py harvest {batch['batch_id']}")
# ── harvest : lire, comparer, classer — zero token ──────────────────────────
def parse_report(raw: str) -> dict:
"""Les lignes cle=valeur de la boite → un dict. Tolerant : une cle absente
(pas de trace, pas de tests) devient une valeur vide, jamais une exception."""
report: dict = {}
for line in raw.splitlines():
key, sep, value = line.partition("=")
if sep:
report[key.strip()] = value.strip()
return report
def parse_tests(report: dict) -> tuple[bool, int, int]:
"""`bun test` : (vert ?, passes, echecs) — le verdict vient du code retour,
les comptes de la sortie. Un bras sans test lisible est rouge, pas inconnu."""
green = report.get("tests_rc") == "0"
passed = re.search(r"(\d+) pass", report.get("tests", ""))
failed = re.search(r"(\d+) fail", report.get("tests", ""))
return green, int(passed.group(1)) if passed else 0, int(failed.group(1)) if failed else 0
def parse_shortstat(text: str) -> tuple[int, int, int]:
"""`3 files changed, 41 insertions(+), 2 deletions(-)` → (3, 41, 2)."""
def count(pattern: str) -> int:
found = re.search(pattern, text)
return int(found.group(1)) if found else 0
return count(r"(\d+) files? changed"), count(r"(\d+) insertion"), count(r"(\d+) deletion")
def minutes_between(started: str, ended: str) -> float | None:
from datetime import datetime
try:
a = datetime.fromisoformat(started.replace("Z", "+00:00"))
b = datetime.fromisoformat(ended.replace("Z", "+00:00"))
return round((b - a).total_seconds() / 60, 1)
except (ValueError, AttributeError):
return None
def arm_result(arm: dict, report: dict, spend: float | None) -> dict:
"""La ligne de moisson d'un bras : uniquement des chiffres LUS."""
green, passed, failed = parse_tests(report)
files, ins, dels = parse_shortstat(report.get("shortstat", ""))
run = (report.get("run", "") + "|||").split("|")
phases = (report.get("phases", "") + "||").split("|")
running = report.get("running") == "1"
status = "en cours" if running else (run[0] or "sans trace")
verdict = "vert" if (not running and status == "success" and green) else \
("en cours" if running else "rouge")
return {"run_id": arm["run_id"], "roster": roster_stem(arm["roster"]), "verdict": verdict,
"run_status": status, "tests_pass": passed, "tests_fail": failed,
"phases": int(phases[0] or 0), "phases_ok": int(phases[1] or 0),
"attempts": int(phases[2] or 0), "files": files, "insertions": ins, "deletions": dels,
"trace_usd": float(run[1] or 0), "spend_usd": spend,
"minutes": minutes_between(run[2], run[3]) if not running else None}
def rank(results: list[dict]) -> list[dict]:
"""Le classement : d'abord le verdict (vert avant tout), puis le cout reel
de la cle (a defaut celui de la trace), puis la duree. Pas de score
pondere : trois colonnes lisibles valent mieux qu'un chiffre opaque."""
order = {"vert": 0, "en cours": 1, "rouge": 2}
return sorted(results, key=lambda r: (order.get(r["verdict"], 3),
r["spend_usd"] if r["spend_usd"] is not None else r["trace_usd"],
r["minutes"] if r["minutes"] is not None else 1e9))
def print_table(results: list[dict]) -> None:
print(f"{'bras':<14} {'verdict':<9} {'tests':>9} {'phases':>8} {'essais':>6} {'diff':>14}"
f" {'cle $':>8} {'trace $':>8} {'min':>6}")
for r in results:
tests = f"{r['tests_pass']}/{r['tests_pass'] + r['tests_fail']}"
diff = f"{r['files']}f +{r['insertions']} -{r['deletions']}"
spend = f"{r['spend_usd']:.4f}" if r["spend_usd"] is not None else "-"
mins = f"{r['minutes']:.1f}" if r["minutes"] is not None else "-"
print(f"{r['roster']:<14} {r['verdict']:<9} {tests:>9} {r['phases_ok']:>3}/{r['phases']:<4}"
f" {r['attempts']:>6} {diff:>14} {spend:>8} {r['trace_usd']:>8.4f} {mins:>6}")
def pull_patch(box, record: dict, run_id: str) -> int:
"""Le patch du bras, rapatrie MAINTENANT — le meme que teardown produira :
la preuve n'attend pas la decision de demonter."""
artifacts = keys.RUNS_DIR / f"{run_id}-artifacts"
artifacts.mkdir(parents=True, exist_ok=True)
patch = box.exec(record, "bash -s", script=(
"cd app && git add -A >/dev/null 2>&1; "
f"git diff --cached --binary {record['commit_sha']}"), binary=True, check=False)
(artifacts / "run.patch").write_bytes(patch or b"")
return len(patch or b"")
def harvest(batch_id: str) -> None:
batch = load_batch(batch_id)
results = []
for arm in batch["arms"]:
if not arm.get("run_id"):
continue
record = load_record(arm["run_id"])
if record.get("closed_at") or not record.get("box"):
continue
box = boxes.for_record(record)
# pid et commit de reference passent en arguments du script, comme la gate du ch. 22.
raw = box.exec(record, f"bash -s {int(record.get('pid') or 0)}"
f" {shlex.quote(record.get('commit_sha') or 'HEAD')}",
script=HARVEST_SCRIPT, check=False)
report = parse_report(raw or "")
if report.get("reachable") != "1":
print(f"{roster_stem(arm['roster'])} : boite injoignable — ignoree", file=sys.stderr)
continue
spend = keys.spend(arm["run_id"], record.get("key_hash"))
result = arm_result(arm, report, spend)
result["patch_bytes"] = pull_patch(box, record, arm["run_id"]) if result["verdict"] != "en cours" else 0
results.append(result)
if not results:
die("aucun bras lisible dans ce lot")
ranked = rank(results)
print(f"lot {batch_id} — « {batch['prompt']} » — {batch['adw']} sur {batch['pin'][:10]}\n")
print_table(ranked)
out = keys.RUNS_DIR / f"{BATCH_PREFIX}{batch_id}-harvest.json"
out.write_text(json.dumps({"harvested_at": now(), "batch": batch, "results": ranked},
indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
pending = [r for r in ranked if r["verdict"] == "en cours"]
print(f"\nreleve : {out}")
if pending:
print(f"{len(pending)} bras encore en cours — rejouez harvest plus tard, il ne detruit rien")
winner = ranked[0]
if winner["verdict"] == "vert":
print(f"propose: {winner['roster']} ({winner['run_id']}) — vert, le moins cher des verts")
print(f"dispose: uv run adws/sandbox_bestof.py discard {batch_id} --keep {winner['run_id']} --yes")
else:
print("propose: aucun bras vert — diagnostiquez (just sandbox cmd <run> tail -20 run.log)"
" avant de demonter quoi que ce soit")
# ── discard : la seule action destructive, jamais implicite ─────────────────
def discard(batch_id: str, keep: str, apply: bool) -> None:
batch = load_batch(batch_id)
run_ids = [a["run_id"] for a in batch["arms"] if a.get("run_id")]
if keep not in run_ids:
die(f"--keep {keep} n'est pas un bras du lot {batch_id} : {', '.join(run_ids)}")
losers = [r for r in run_ids if r != keep and not load_record(r).get("closed_at")]
if not losers:
print("rien a demonter : les autres bras sont deja fermes")
for run_id in losers:
print(f"{'demonte' if apply else 'a sec '}: {run_id}")
if apply:
life.teardown(run_id) # depense → preuve → cle revoquee → boite detruite
if not apply:
print(f"a sec — rien detruit. Pour agir : ajoutez --yes ({len(losers)} bras)")
return
batch["closed_at"] = now()
save_batch(batch)
print(f"garde : {keep} — toujours debout ; `just sandbox teardown {keep}` quand vous aurez fini")
def list_batches() -> None:
keys.RUNS_DIR.mkdir(parents=True, exist_ok=True)
for path in sorted(keys.RUNS_DIR.glob(f"{BATCH_PREFIX}*.json")):
if path.name.endswith("-harvest.json"):
continue
b = json.loads(path.read_text(encoding="utf-8"))
state = "ferme" if b.get("closed_at") else "ouvert"
arms = ", ".join(f"{roster_stem(a['roster'])}:{a['state']}" for a in b["arms"])
print(f"{b['batch_id']:<40} {state:<7} {arms}")
# ── la gate a sec ───────────────────────────────────────────────────────────
def selftest() -> int:
"""Zero reseau, zero token : le rapport de boite se lit, le verdict tombe,
le classement met le vert le moins cher en tete, le lot survit sur disque."""
ok = roster_stem("adws/adw_config/open-weights.config.yaml") == "open-weights"
green = parse_report("reachable=1\nrunning=0\nshortstat= 3 files changed, 41 insertions(+), 2 deletions(-)\n"
"tests_rc=0\ntests= 12 pass 0 fail\nrun=success|0.4321|2026-09-02T10:00:00Z|2026-09-02T10:08:30Z\n"
"phases=5|5|6\n")
red = parse_report("reachable=1\nrunning=0\nshortstat=\ntests_rc=1\ntests= 10 pass 2 fail\n"
"run=fail|0.0900|2026-09-02T10:00:00Z|2026-09-02T10:03:00Z\nphases=5|3|7\n")
live = parse_report("reachable=1\nrunning=1\nshortstat=\ntests_rc=0\ntests= 12 pass\nrun=\nphases=\n")
a = arm_result({"run_id": "a", "roster": "x/frontier.config.yaml"}, green, spend=0.61)
b = arm_result({"run_id": "b", "roster": "x/eco.config.yaml"}, green, spend=0.05)
c = arm_result({"run_id": "c", "roster": "x/open-weights.config.yaml"}, red, spend=0.09)
d = arm_result({"run_id": "d", "roster": "x/factory.config.yaml"}, live, spend=None)
ok &= (a["verdict"], c["verdict"], d["verdict"]) == ("vert", "rouge", "en cours")
ok &= (a["files"], a["insertions"], a["deletions"], a["minutes"]) == (3, 41, 2, 8.5)
ok &= (a["tests_pass"], c["tests_fail"], c["phases_ok"], c["attempts"]) == (12, 2, 3, 7)
ok &= [r["run_id"] for r in rank([a, c, d, b])] == ["b", "a", "d", "c"]
with tempfile.TemporaryDirectory() as tmp:
keys.RUNS_DIR = Path(tmp)
batch = {"batch_id": "essai-20260902-0a1b2c", "prompt": "p", "adw": "adw_sdlc", "pin": "0" * 40,
"created_at": now(), "closed_at": None,
"arms": [{"roster": "x/eco.config.yaml", "run_id": "b", "state": "running"}]}
save_batch(batch)
ok &= load_batch("essai-20260902-0a1b2c")["arms"][0]["run_id"] == "b"
print(f"sandbox_bestof {'OK' if ok else 'KO'} — rapport lu, verdicts vert/rouge/en cours,"
" classement vert-le-moins-cher d'abord, lot relu")
return 0 if ok else 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="best-of-N : N bras, une demande, une moisson")
parser.add_argument("--selftest", action="store_true", help="la gate a sec")
sub = parser.add_subparsers(dest="verb")
p = sub.add_parser("fanout"); p.add_argument("name"); p.add_argument("prompt")
p.add_argument("--rosters", nargs="+", required=True, help="2 a 5 fichiers de roster")
p.add_argument("--limit", type=float, default=keys.DEFAULT_LIMIT, help="plafond PAR BRAS, en dollars")
p.add_argument("--adw", default="adw_sdlc")
sub.add_parser("harvest").add_argument("batch_id")
p = sub.add_parser("discard"); p.add_argument("batch_id")
p.add_argument("--keep", required=True, help="le run a garder debout")
p.add_argument("--yes", action="store_true", help="agir — a sec sans lui")
sub.add_parser("list")
args = parser.parse_args()
if args.selftest:
raise SystemExit(selftest())
if args.verb == "fanout":
fanout(args.name, args.prompt, args.rosters, args.limit, args.adw.removesuffix(".py"))
elif args.verb == "harvest":
harvest(args.batch_id)
elif args.verb == "discard":
discard(args.batch_id, args.keep, args.yes)
elif args.verb == "list":
list_batches()
else:
parser.print_help()
Pièce — just/sandbox/orch.just
Cette version remplace celle du chapitre 24. Les quatre recettes d’hier ne bougent pas, et
quatre s’ajoutent, fanout, harvest, discard et lots, toujours une ligne chacune, aucun
set, aucune variable de fichier. fanout reçoit le nom, la demande, puis les rosters en
arguments libres : "$@" les transmet et le script pose lui-même --rosters.
# just/sandbox/orch.just — les orchestrateurs du hors-site (ch. 24) et le best-of-N (ch. 25).
# IMPORTE par lifecycle.just : aucun `set` ici (les reglages du module s'appliquent),
# aucune variable de fichier. Rien ici ne detruit une boite sans --yes explicite.
#
# Trois portes vers une boite montee, et elles ne sont pas interchangeables :
# just sandbox execute (ch. 22) une COMMANDE : le runner detache, un pid, zero token
# just sandbox delegate (ch. 24) une DELEGATION : un tour de l'orchestrateur en boite
# just sandbox cmd (ch. 24) l'echappatoire : une commande synchrone, pour regarder
# Et le best-of-N (ch. 25) : N boites par la porte-commande, une moisson, un choix a vous.
# l'echappatoire : une commande DANS la boite, verbatim, synchrone : just sandbox cmd <run> tail -20 run.log
cmd RUN +CMD:
uv run adws/sandbox_orch.py cmd "$@"
# un tour de l'orchestrateur en boite (session reprise a chaque tour) : just sandbox delegate <run> "<message>" [--config …] [--harness claude]
delegate RUN MESSAGE *FLAGS:
uv run adws/sandbox_orch.py delegate "$@"
# reprendre la main : rejoindre la session de l'orchestrateur, en interactif : just sandbox attach <run> [--harness claude]
attach RUN *FLAGS:
uv run adws/sandbox_orch.py attach "$@"
# un shell dans la boite — pour diagnostiquer, jamais pour travailler a la place de l'usine
shell RUN:
uv run adws/sandbox_orch.py shell "$1"
# best-of-N : N boites, meme commit, meme demande, un roster par bras : just sandbox fanout compteur "<demande>" adws/adw_config/eco.config.yaml adws/adw_config/factory.config.yaml
fanout NAME PROMPT +ROSTERS:
uv run adws/sandbox_bestof.py fanout "$1" "$2" --rosters "${@:3}"
# la moisson : lire, comparer, classer — zero token, non destructif, rejouable : just sandbox harvest <lot>
harvest BATCH:
uv run adws/sandbox_bestof.py harvest "$1"
# jeter le reste : demonter tous les bras SAUF un — a sec sans --yes : just sandbox discard <lot> --keep <run> --yes
discard BATCH *FLAGS:
uv run adws/sandbox_bestof.py discard "$@"
# les lots connus et l'etat de leurs bras
lots:
uv run adws/sandbox_bestof.py list
Pièce — .claude/skills/factory-orchestrator/SKILL.md
Le brief de l’orchestrateur hors-boîte, promis hier : un agent sur votre machine qui n’a que la
surface just sandbox. Un seul fichier pour les deux harnais : Claude Code le découvre sous
.claude/skills/, pi le charge par --skill .claude/skills/factory-orchestrator (ou par la
clé skills de .pi/settings.json). Le skill tient le jugement, les recettes tiennent le
savoir. Il ne contient ni clé, ni commande destructive sans confirmation.
---
name: factory-orchestrator
description: Orchestrateur HORS-BOITE de plume-factory — monter des boites (Podman par defaut, exe.dev en option), y faire entrer du travail, observer, lancer un best-of-N, moissonner, et recommander un demontage sans jamais le decider. A utiliser pour « monte une boite », « lance N rosters », « moissonne le lot », « ou en est le run ».
---
# Orchestrateur hors-boite
Vous pilotez le hors-site de plume-factory depuis la machine de l'ingenieur. Une seule loi :
**chaque action est une recette `just sandbox …` qu'un humain pourrait taper.** Les recettes
tiennent le savoir (ordre des phases, gate, revocation, detachement) ; vous tenez le jugement :
quelle recette, dans quel ordre, et comment lire le resultat.
## Avant tout
- `just sandbox preflight` : le moteur (Podman rootless et son image, ou exe.dev), la cle de gestion, les outils. Un echec = rapport et arret.
- `just sandbox list` et `just sandbox lots` : ce qui tourne deja. `just sandbox reap` (a sec) :
les cles orphelines.
## La surface
| Besoin | Recette |
|---|---|
| Une boite prete | `just sandbox mount <nom> [--limit 5]` — s'arrete a observe, jamais teardown |
| Du travail, reproductible | `just sandbox execute <run> "<demande>" [--config <roster>]` |
| Du travail, avec jugement | `just sandbox delegate <run> "<message>"` |
| Regarder | `just sandbox observe <run>` ; `just sandbox cmd <run> tail -20 run.log` ; `just sandbox egress <run>` (la porte) |
| N rosters, une demande | `just sandbox fanout <nom> "<demande>" <roster1> <roster2> …` |
| Comparer | `just sandbox harvest <lot>` — zero token, rejouable |
| Jeter le reste | `just sandbox discard <lot> --keep <run>` — a sec ; `--yes` seulement sur ordre |
## Regles
1. **Vous ne decidez jamais un demontage.** Rapportez ce qu'un run a produit, ce qu'il a coute,
recommandez — l'ingenieur decide. `teardown` et `discard --yes` ne partent que sur son ordre
explicite, dans cette conversation.
2. **Jamais une phase de l'usine sur cette machine.** `uv run adws/adw_sdlc.py` ici est la
collision que le hors-site retire. Le travail entre par `execute`, `delegate` ou `fanout`.
3. **Une gate rouge = diagnostiquer, jamais detruire.** La boite est gardee expres :
`just sandbox cmd <run> …`, comprendre, corriger, relancer `just sandbox setup <run>`.
4. **Jamais une cle.** Ni lire, ni afficher, ni copier `adws/adw_data/sandbox/*.key` ni `.env`.
5. **Un best-of-N entre par `fanout`, jamais par des `delegate` en boucle** : les bras doivent
recevoir exactement la meme demande.
6. **Rapportez toujours l'id** — run ou lot : c'est la seule poignee de la phase suivante.
7. **La moisson propose ; l'ingenieur dispose.** Presentez le tableau, ouvrez les patches sous
`adws/adw_data/sandbox/<run>-artifacts/run.patch` si on vous le demande, et attendez.
La gate du TP
Trois commandes, une par ligne, depuis la racine de plume-factory : la première à sec, les
deux autres avec Podman (ou un compte exe.dev) et un arbre de travail propre :
uv run adws/sandbox_bestof.py --selftest
just sandbox fanout compteur "Ajoute un compteur de mots à Plume" adws/adw_config/eco.config.yaml adws/adw_config/open-weights.config.yaml adws/adw_config/factory.config.yaml
just sandbox harvest compteur-<la-date>-<6 hex>
Attendu : sandbox_bestof OK — rapport lu, verdicts vert/rouge/en cours, classement vert-le-moins-cher d'abord, lot relu (zéro réseau, zéro token). La deuxième déroule trois bras,
quatre phases chacun, ~20 secondes et moins d’un centime par bras sur Podman, puis fanout : 3/3 bras lances, detaches, meme commit, meme demande et l’id du lot. Attendez une dizaine de minutes,
puis la troisième : le tableau, une ligne par bras, le relevé JSON, et propose: … — vert, le moins cher des verts suivi de la commande discard, non exécutée. Environ un dollar au
total, l’essentiel sur le siège frontier du roster de production. Variante éco : ne passez
que eco.config.yaml et open-weights.config.yaml, pour quelques dizaines de centimes. Quand
vous avez choisi : just sandbox discard <lot> --keep <run> --yes, puis, plus tard,
just sandbox teardown <run> sur le bras gardé.