Sandboxes & scale Chapitre 25 / 42

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 : fill charge votre arbre, et un fichier non commité est un bras qui reçoit autre chose que les autres.
  • La porte est execute, jamais delegate. 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 setup qui é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. setup reç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

DimensionQui la tientPourquoi
Le roster (--config)varie : un par brasc’est la question posée
Le commitle code, épinglé avant la première boîtedeux commits = deux Plume, la comparaison est du bruit
La demandele code, la même chaîne pour tousdes prompts différents comparent des prompts
L’ADWle code, adw_sdlc par défautla chaîne complète, gates comprises (ch. 13)
La porteexecute, détachéezéro token d’orchestration, reproductible (ch. 24)
Le plafondla clé jetable de chaque brasun 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 test qui 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 test relancé dans la boîte et du statut success de la table runs (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’un git diff --shortstat contre 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 exec ou ssh selon 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.patch sous adws/adw_data/sandbox/<run>-artifacts/, le même que teardown produira. 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. --yes enchaîne le teardown du 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

ColonneSourceLue comment
verdictbun test dans la boîte + runs.statuscode retour, puis la trace du chapitre 18
tests, phases, essaisbun test, table phasescomptes lus, jamais estimés
diffgit 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_usdle cumul par phase, pour contrôle
minruns.started_atended_atduré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é.


Quiz — teste tes connaissances
Sandboxes & scale 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.