Orchestrateur out-box & orchestrateur in-box
Trois étages de commandement, deux portes pour faire entrer le travail dans une boîte — une commande ou une délégation — et une session vivante que vous pouvez rejoindre, par podman exec ou par SSH selon le moteur. La pièce du jour câble les deux orchestrateurs de l'usine, derrière le port boîte.
Hier, votre boîte a reçu une clé qui ne vaut que son plafond, et execute y a lancé un SDLC
détaché : un pid, un run.log, et vous êtes parti. Relisez ce geste : vous avez choisi l’ADW,
vous avez formulé la demande, vous avez tapé la commande. Le chapitre 21 vous a promis trois
étages de commandement. Pour l’instant, le deuxième, l’agent qui vit dans la boîte, est une
case vide du tableau. À la fin de ce chapitre, vous saurez faire entrer du travail dans une boîte
par deux portes bien distinctes, une commande ou une délégation, et vous saurez rejoindre la
session de l’agent qui y travaille sans perdre le fil entre deux connexions, par podman exec
sur Podman, par SSH chez exe.dev, sans que le script ait à le savoir. La pièce du jour est
adws/sandbox_orch.py et son module orch.just : le cycle de vie des chapitres 22 et 23 ne
bouge pas, il gagne un étage.
Qui commande quoi : trois étages d’orchestration
L’idée en une phrase
L’usine hors-site a trois étages de commandement : l’orchestrateur hors-boîte sur votre machine, l’orchestrateur en boîte dans le conteneur, les agents ADW dans le runner. Chacun ne commande que l’étage immédiatement intérieur. Le travail entre dans une boîte par une commande (déterministe, reproductible) ou par une délégation (un tour de l’agent en boîte, qui juge), et cette différence est une décision que vous prenez à chaque fois, côté hôte.
Points clés
- L’orchestrateur hors-boîte vit sur l’hôte, et il a une loi : chaque action est une recette
justqu’un humain pourrait taper. Aujourd’hui, c’est vous à la barre, avec la surfacejust sandbox. Au chapitre 25, un agent hôte prendra la barre, et il n’aura que cette même surface. Il monte, remplit, observe, moissonne et démonte des boîtes, et il ne lance jamais une phase de l’usine sur votre machine : ce serait réintroduire la collision que le module retire. - L’orchestrateur en boîte est une session de harnais vivante, reprise à chaque tour. Il
n’existe pas avant le premier message. Il reçoit un brief une seule fois (ce qu’est
l’usine, comment lancer un ADW, ce qu’il ne touche pas), puis chaque tour suivant retrouve sa
mémoire, d’un
podman execà l’autre. Il lance l’usine, la surveille, rend compte. Il ne code pas : les agents ADW codent. - Deux portes, et elles ne sont pas rivales.
execute(chapitre 22) est une commande : le runner part détaché, ce qui a tourné est exactement ce que vous avez tapé, zéro token d’orchestration. C’est la porte par défaut, et la seule qui rende un best-of-N comparable au chapitre 25.delegateest une délégation : l’agent en boîte choisit l’ADW, formule la demande, lance, surveille. Vous échangez la garantie contre du jugement, et c’est tout l’échange. - La frontière des credentials ne bouge pas d’un pouce. L’agent en boîte ne connaît que la clé jetable de son run, et sa porte que la passerelle. L’orchestrateur hôte, lui, ne passe jamais la clé de gestion, le socket Podman ni le compte exe.dev dans la boîte. Une boîte ne peut donc pas monter de boîte, même quand un agent y décide : le troisième étage n’a pas de quatrième.
- Équiper avant de commander. Un agent nu dans
~/appa le repo, mais rien ne lui a dit qu’il y a une usine dedans. Sans brief, il improvise : il relit vingt fichiers pour redécouvrirbun testet vous facture la visite. Avec brief, il route :uv run adws/adw_sdlc.py …, puis la trace. Le brief se met au premier tour, jamais aux suivants : la session s’en souvient.
Exemple concret
Reprenez la boîte d’hier, montée et prête. Porte 1 :
just sandbox execute <run> "Ajoute un compteur de mots à Plume" : le SDLC part détaché,
quelques dizaines de centimes, dix minutes, et vous lisez observe plus tard. Porte 2 :
just sandbox delegate <run> "Lance le SDLC sur le compteur de mots, puis dis-moi où il en est",
un tour de l’orchestrateur en boîte : il lit le brief, lance le même ADW détaché, attend quelques secondes, relit run.log, et répond en trois
lignes avec l’adw_id. Ce tour vous coûte quelques centimes sur le planner du roster par défaut
(un modèle frontier au moment d’écrire), environ un centime sur le roster éco, et le SDLC
lui-même coûte la même chose par les deux portes. Une heure plus tard, nouveau processus :
just sandbox delegate <run> "Et maintenant ?", et l’agent se souvient de ce qu’il a lancé et lit
la table runs avant de répondre. Ce que la porte 2 vous a acheté : une conversation avec la
boîte, sans un seul podman exec tapé à la main.
Les trois étages, et ce que chacun tient
| Étage | Vit où | Commande quoi | Ce qu’il connaît |
|---|---|---|---|
| Orchestrateur hors-boîte | votre machine — just sandbox … | des boîtes : monter, remplir, déléguer, observer, démonter | le moteur (socket Podman ou compte exe.dev), la clé de gestion, les fiches de run |
| Orchestrateur en boîte | le conteneur (ou la VM) — une session reprise à chaque tour | l’usine : lancer les ADW, surveiller, rendre compte | le repo, la clé jetable, le brief, la porte |
| Agents ADW | des phases bornées dans le runner (ch. 8) | le travail : scout, plan, build, review, document | leur enveloppe, rien d’autre |
Les trois portes vers une boîte montée
| Porte | Forme | Qui appuie sur la détente | Tokens d’orchestration | Prenez-la quand |
|---|---|---|---|---|
execute (ch. 22) | détachée, rend un pid | le code, sur l’hôte | zéro | la demande est déjà formée ; vous partez ; best-of-N |
delegate (ch. 24) | synchrone, session reprise | l’agent, dans la boîte | quelques centimes par tour | la demande est floue ; un run à diagnostiquer ; « et si ça échoue, relance » |
cmd (ch. 24) | synchrone, verbatim | personne — c’est vous | zéro | regarder : un log, un git log, une table |
Config — le brief, l’équipement du premier tour
Le brief est du texte, donc côté agent, mais c’est le code qui décide quand il part : au
premier tour seulement, d’après l’état de l’orchestrateur que la boîte garde sous adw_data/.
Voici ce que l’agent en boîte lit avant votre premier message.
Vous etes l'orchestrateur EN BOITE de plume-factory : une session de harnais qui vit
dans cette boite jetable, a la racine du repo (~/app). Votre role : lancer l'usine, la
surveiller, rendre compte. Vous ne codez pas vous-meme — les agents ADW codent.
Regles :
- Si ce n'est pas deja fait, lisez PLAN.md ; `just --list` montre la surface de l'usine.
- Le travail passe par les ADW : `uv run adws/adw_sdlc.py "<demande>" [--config <roster>]`
(ou adw_plan.py, adw_build.py, adw_scout.py). Pour un run long, detachez-le.
- Rendez compte d'apres la trace, jamais de memoire : `just obs runs`, `just obs lanes`,
`tail run.log`, la base adws/adw_data/factory.db.
- Ne modifiez jamais adws/adw_modules/, adws/adw_config/, adws/adw_*.py ni PLAN.md.
Ne touchez pas a .env. Ne detruisez rien : cette boite n'est pas a vous.
- Repondez court et factuel : ce que vous avez lance, l'adw_id, l'etat, le cout lu dans la trace.
Piège courant : « avec un orchestrateur en boîte, autant tout passer par
delegate» est inexact. Chaque délégation vous coûte la garantie que ce qui a tourné est ce que vous avez tapé. Trois boîtes lancées par délégation peuvent avoir reçu trois demandes reformulées différemment, et la comparaison du chapitre 25 n’a alors plus de sens.executereste la porte par défaut du travail.delegateest la porte du jugement, quand la demande a besoin d’être formée, ou qu’un run a besoin d’être compris.
L’accès agentique : entrer dans la boîte et reprendre la main
L’idée en une phrase
Sortir de la boucle ne vous interdit pas d’entrer dans la boîte. Trois accès existent : un
shell, la session de l’orchestrateur rejointe en interactif, et l’échappatoire
cmd, tous par le port « boîte » du chapitre 22, tous côté déterministe et tous non
destructifs. Ce qui change, c’est la règle d’usage :
on regarde une boîte en cours de run, on ne met la main dedans que pour diagnostiquer, et
ce qui vaut la peine d’être gardé en sort par la moisson, jamais par copier-coller.
Points clés
- Un seul mécanisme :
argv(record, commande, tty=…)du port. Aucun des trois accès n’a besoin d’une clé de plus : le shell estpodman exec -it <boîte> bash -c "cd app && exec bash -l"(chez exe.dev,ssh -t <vm> "…"), la reprise est la même entrée avec TTY qui lance le harnais sur la session de l’orchestrateur,cmdest la même entrée sans TTY. Le TTY est obligatoire pour tout ce qui a une interface :podman exec -icommessh vm "cmd"n’allouent pas de terminal, et un harnais interactif sans terminal meurt en silence. - Deux portes, une mémoire.
delegateetattachparlent à la même session : un tour envoyé depuis l’hôte, une question posée en interactif, un nouveau tour depuis l’hôte, et la conversation est continue. Chezpi, la session porte un id que le code dérive du run (un UUID v5, calculé pareil sur l’hôte et dans la boîte, jamais stocké). Chez Claude Code, c’est lui qui nomme au premier tour, et l’état gardé dans la boîte retient son id. Vous avez vu cette asymétrie au chapitre 7, et elle reste absorbée par le code. - Dans une boîte Podman, l’orchestrateur est
pi. La porte ne connaît que la passerelle et aucune clé Anthropic ne traverse : un orchestrateur Claude Code n’y aurait ni sortie ni jetons. Le code le refuse avant de lancer quoi que ce soit (--harness claude: « seulement sur exe.dev »), plutôt que de laisser une session mourir en silence. - L’état de l’orchestrateur vit dans la boîte, pas dans la fiche de run. Harnais, id de
session, nombre de tours, cumul dépensé : un petit JSON sous
adws/adw_data/orchestrator/, jamais commité, jamais chargé parfill. La fiche de l’hôte garde son schéma du chapitre 23, et un tour de plus n’y ajoute rien. - Regarder ne coûte rien, intervenir coûte la trace. Un
cmd … tail run.logou unjust obs lanesdans la boîte laisse le run tel qu’il est. Éditer un fichier pendant qu’un builder travaille, c’est mélanger votre main à la sienne : le patch deteardownet la trace ne racontent plus ce que l’usine a fait seule. Diagnostiquer une gate rouge, oui. Corriger à la main un run en cours, non. - Chez exe.dev, la variante Claude Code n’a pas besoin de clé dans la boîte. L’intégration
LLM d’exe.dev expose, depuis toute VM attachée, un hôte
llm.int.exe.xyzqui sert des modèles Anthropic sans qu’aucune clé de fournisseur ne soit stockée sur la machine, et un quota de jetons est inclus dans l’abonnement au moment d’écrire. L’orchestrateur en boîte en Claude Code ne dépense donc rien sur la clé jetable du run, et le roster reste servi parpiet OpenRouter. C’est la seule fonctionnalité du module réservée à l’option exe.dev.
Exemple concret
Une boîte a rendu une gate rouge à setup : KO bun test rouge dans la boite. Le chapitre 22
vous a appris à ne pas la détruire, aujourd’hui vous savez y entrer. just sandbox cmd <run> bun test (depuis apps/plume) : la sortie brute, en une seconde, zéro token. Le test cassé est
un import qui dépend d’un fichier généré. Vous ouvrez un shell, just sandbox shell <run>,
lancez la commande de génération, quittez, et just sandbox setup <run> repasse vert. Deux
minutes, zéro token. Autre jour, autre boîte : un SDLC délégué semble planté depuis dix minutes.
just sandbox attach <run> vous met dans la session de l’orchestrateur, qui a tout le
contexte : « le builder attend sur bun install, le réseau de la boîte a rendu une erreur, je
relance ? ». Vous répondez oui, vous quittez, et le tour suivant, depuis l’hôte, vous confirme
l’adw_id du nouveau run. Coût : quelques centimes de tokens pour la conversation, et surtout
zéro redémarrage : la boîte, la clé et la session sont restées vivantes.
Quatre façons d’entrer, et ce qu’elles touchent
| Accès | Commande | TTY | Tokens | Touche à la boîte |
|---|---|---|---|---|
| Regarder de l’extérieur | just sandbox observe <run> (ch. 22) | non | zéro | rien — lecture, Plume servie |
| L’échappatoire | just sandbox cmd <run> tail -20 run.log | non | zéro | ce que la commande fait |
| Un shell | just sandbox shell <run> | oui | zéro | ce que vous faites — avec parcimonie |
| Rejoindre l’orchestrateur | just sandbox attach <run> | oui | quelques centimes par tour | ce que l’agent lance |
| Lire la porte | just sandbox egress <run> (Podman, ch. 22) | non | zéro | rien — le journal des sorties |
Commande — rejoindre la session, version pi et version Claude Code
Les deux harnais sont au programme, et ici la différence est visible : qui nomme la session,
d’où viennent les jetons, et sur quel moteur c’est possible. Ce sont les commandes que attach
exécute pour vous, à ne pas retaper à la main sauf pour comprendre.
# version pi, boite Podman — la session est nommee PAR NOUS (uuid5 derive du run) : elle existe ou nait ici.
# Meme dossier que le port du ch. 7 ; le modele est celui du roster, par la passerelle, via la porte.
podman exec -it plume-box-<run> bash -c "cd app && pi --session-id <uuid5-du-run> --session-dir adws/adw_data/sessions --model openrouter/z-ai/glm-5.3"
# version pi, VM exe.dev — la meme commande, par SSH avec un TTY.
ssh -t <run>.exe.xyz "cd app && pi --session-id <uuid5-du-run> --session-dir adws/adw_data/sessions --model openrouter/z-ai/glm-5.3"
# version Claude Code — exe.dev seulement : la session est nommee PAR LUI au premier delegate ; on la reprend.
# Les jetons viennent de l'integration LLM d'exe.dev : aucune cle sur la VM, un placeholder.
ssh -t <run>.exe.xyz "cd app && ANTHROPIC_API_KEY=implicit ANTHROPIC_BASE_URL=https://llm.int.exe.xyz claude --resume <session-id>"
Piège courant : « je suis entré dans la boîte, j’ai corrigé le test à la main, le run est vert, donc l’usine a réussi » est inexact. Le run est vert parce que vous avez travaillé, et la trace ne le dit nulle part. Le chapitre 20 vous a donné une comptabilité par phase, et une main humaine dans la boîte en fausse le grain sans laisser de ligne. Si vous devez intervenir, faites-le entre deux runs, ou déléguez la correction à l’orchestrateur en boîte, dont le tour, lui, est daté, chiffré et tracé.
Fil rouge — la pièce posée aujourd’hui
Sur le plan de l’usine, la zone just/sandbox/ reçoit sa troisième pièce, orch.just, importée
par lifecycle.just comme keys.just hier, avec sa logique dans adws/sandbox_orch.py, un
seul script qui vit des deux côtés de la couture, parce que le repo voyage entier dans la boîte.
La loi ne bouge pas, l’agent propose et le code dispose, et aujourd’hui le code dispose de
qui commande : c’est une recette qui décide si le travail entre par une commande ou par une
délégation, c’est le code qui dérive l’id de session, qui décide que le brief part au premier
tour seulement, qui borne les outils de l’orchestrateur à lire et lancer, qui refuse un harnais
sans jetons sur ce moteur. Deux ports se superposent ici sans se confondre. Côté hôte, tout
entre dans la boîte par sandbox_box.argv, podman exec ou ssh, sans que le script le sache.
Côté boîte, turn passe par le port harnais du chapitre 7, avec le profil du planner emprunté
au roster, et rien n’appelle pi ni claude en dehors de l’adaptateur. Ce qui traverse la
couture : votre message, entré par stdin, et une réponse texte suivie d’une ligne de coût. À
l’usage : zéro token pour cmd, shell et attach tant que vous ne parlez pas, quelques
centimes par tour de délégation sur le frontier, un centime sur l’éco, contre une session
d’agent à qui il faudrait tout réexpliquer à chaque entrée dans la boîte.
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 : les deux orchestrateurs, leur module
just, et le cycle de vie qui apprend à les importer.
Prérequis : les mêmes qu’hier, Podman et l’image, plus une clé de gestion pour les gates avec
boîte. Sans eux, la première gate tourne à sec et tout le chapitre se lit. La variante Claude
Code de l’orchestrateur n’existe que sur exe.dev et suppose son intégration LLM par défaut
(llm, attachée à toutes vos VM sur un compte récent). Vérifiez-la depuis une VM avec
curl https://llm.int.exe.xyz/v1/models.
Pièce — adws/sandbox_orch.py
Les deux orchestrateurs dans un seul fichier, et le fichier le dit. Côté hôte, cmd, delegate,
attach et shell lisent la fiche de run du chapitre 23 et entrent par le port « boîte » du
chapitre 22 (argv, avec ou sans TTY). Côté boîte, turn est un tour de l’orchestrateur, par
le port harnais du chapitre 7, avec le profil du planner du roster du chapitre 10. Le brief part au premier tour, et l’état de la session vit dans la boîte sous
adw_data/. Rien ici ne détruit, rien ici ne lit la clé de gestion.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""sandbox_orch — les deux orchestrateurs du hors-site (ch. 24).
Trois etages de commandement, et chacun ne commande que l'etage interieur :
1. l'orchestrateur HORS-BOITE : ce script, sur votre machine. Il commande
des boites — et, depuis ce chapitre, parle a l'agent qui vit dedans.
2. l'orchestrateur EN BOITE : une session de harnais vivante dans la boite
(conteneur Podman ou VM exe.dev), reprise a chaque tour, qui lance
l'usine, la surveille et rend compte.
3. les agents ADW : des noeuds bornes dans les phases du runner (ch. 8).
Un seul fichier, deux cotes. Le repo voyage entier dans la boite (fill),
donc le meme script sert des deux cotes de la couture :
cote hote : cmd, delegate, attach, shell — ils lisent la fiche de run et
entrent par le port « boite » du ch. 22 (sandbox_box.py)
cote boite : turn — un tour de l'orchestrateur, par le port harnais du ch. 7
Trois portes vers une boite montee, et elles ne sont pas interchangeables :
execute (ch. 22) : une COMMANDE. Le runner detache, un pid, zero token
d'orchestration, reproductible. La porte par defaut.
delegate (ici) : une DELEGATION. Un tour de l'orchestrateur en boite,
qui juge, lance et rend compte. Du jugement, contre
la garantie « ce qui a tourne est ce que j'ai tape ».
cmd (ici) : l'echappatoire. Une commande synchrone, pour regarder.
Aucune porte ne detruit jamais une boite. Aucune ne lit la cle de gestion.
uv run adws/sandbox_orch.py cmd <run> <commande…>
uv run adws/sandbox_orch.py delegate <run> "<message>" [--harness pi|claude] [--config …]
uv run adws/sandbox_orch.py attach <run> [--harness pi|claude]
uv run adws/sandbox_orch.py shell <run>
uv run adws/sandbox_orch.py turn --run <run> [--harness …] [--config …] # DANS la boite
uv run adws/sandbox_orch.py --selftest # a sec, zero reseau
"""
from __future__ import annotations
import argparse
import json
import shlex
import subprocess
import sys
import uuid
from pathlib import Path
# L'etat de l'orchestrateur en boite vit DANS la boite, sous adw_data/
# (jamais commite, jamais charge par fill) : quel harnais, quelle session,
# combien de tours, combien depense. La fiche de run de l'hote n'en sait rien.
STATE_DIR = Path("adws/adw_data/orchestrator")
SESSION_DIR = "adws/adw_data/sessions" # le meme dossier que le port du ch. 7
# Les outils de l'orchestrateur : lire et lancer, jamais editer. Il ne code
# pas — il commande l'usine, qui code. bash suffit pour lancer un ADW.
TOOLS = ("read", "bash", "grep", "find", "ls")
# La variante Claude Code n'existe que sur exe.dev : son integration LLM
# expose un hote `llm.int.exe.xyz` que la VM peut appeler sans qu'aucune cle
# de fournisseur ne soit stockee dessus (Claude Code exige une valeur : un
# placeholder). Dans une boite Podman, la porte ne connait pas l'API
# Anthropic et aucune cle Anthropic ne traverse : l'orchestrateur y est pi.
EXE_LLM = {"ANTHROPIC_BASE_URL": "https://llm.int.exe.xyz", "ANTHROPIC_API_KEY": "implicit"}
# L'equipement du premier tour : l'orchestrateur en boite est une session
# nue dans ~/app — rien ne l'a encore informe de l'usine. Le brief le fait
# router au lieu d'improviser. Une seule fois : la session s'en souvient.
BRIEF = """\
Vous etes l'orchestrateur EN BOITE de plume-factory : une session de harnais qui vit \
dans cette boite jetable, a la racine du repo (~/app). Votre role : lancer l'usine, la \
surveiller, rendre compte. Vous ne codez pas vous-meme — les agents ADW codent.
Regles :
- Si ce n'est pas deja fait, lisez PLAN.md ; `just --list` montre la surface de l'usine.
- Le travail passe par les ADW : `uv run adws/adw_sdlc.py "<demande>" [--config <roster>]` \
(ou adw_plan.py, adw_build.py, adw_scout.py). Pour un run long, detachez-le : \
`nohup uv run adws/adw_sdlc.py "<demande>" > run.log 2>&1 < /dev/null &`.
- Rendez compte d'apres la trace, jamais de memoire : `just obs runs`, `just obs lanes`, \
`tail run.log`, la base adws/adw_data/factory.db.
- Ne modifiez jamais adws/adw_modules/, adws/adw_config/, adws/adw_*.py ni PLAN.md. \
Ne touchez pas a .env. Ne detruisez rien : cette boite n'est pas a vous.
- Repondez court et factuel : ce que vous avez lance, l'adw_id, l'etat, le cout lu dans la trace.
"""
def die(message: str, code: int = 1) -> None:
print(f"orch : {message}", file=sys.stderr)
sys.exit(code)
# ── ce qui est vrai des deux cotes ──────────────────────────────────────────
def session_id_for(run_id: str) -> str:
"""L'id de session pi de l'orchestrateur d'un run : derive, jamais stocke.
uuid5 rend un UUID valide et DETERMINISTE : le meme run donne le meme id
sur l'hote (attach) et dans la boite (turn), sans qu'un champ de plus
entre dans la fiche de run. pi cree la session si elle n'existe pas."""
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"plume-factory/orchestrator/{run_id}"))
def compose(turns: int, message: str) -> str:
"""Le brief precede le PREMIER tour seulement — ensuite la session s'en souvient."""
return message if turns else f"{BRIEF}\n---\n{message}"
def remote_command(words: list[str]) -> str:
"""L'echappatoire : vos mots, VERBATIM, apres `cd app`. Les tubes, globs et
guillemets que vous avez tapes doivent atteindre le shell distant intacts —
c'est vous qui possedez le quoting, et c'est le but."""
return "cd app && " + " ".join(words)
def state_path(run_id: str) -> Path:
return STATE_DIR / f"{run_id}.json"
def load_state(run_id: str) -> dict | None:
path = state_path(run_id)
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def save_state(state: dict) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
state_path(state["run_id"]).write_text(
json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
# ── cote hote : commander une boite montee ──────────────────────────────────
def mounted(run_id: str) -> tuple[dict, object]:
"""La fiche de run, l'adaptateur de sa boite, et la preuve qu'il y a une boite au bout."""
import sandbox_box as boxes # meme dossier : uv place adws/ en tete
import sandbox_keys as keys
record = keys.load_record(run_id)
if record.get("closed_at"):
die(f"{run_id} est ferme depuis {record['closed_at']} — plus de boite")
if not record.get("box"):
die(f"{run_id} n'a pas de boite — `just sandbox mount` d'abord")
return record, boxes.for_record(record)
def check_harness(box, harness_name: str) -> None:
"""Un orchestrateur Claude Code n'a de jetons que sur exe.dev (integration LLM) :
dans une boite Podman, la porte ne laisse passer qu'OpenRouter — donc pi."""
if harness_name == "claude" and box.name != "exedev":
die("--harness claude : seulement sur exe.dev (integration LLM sans cle) —"
" dans une boite Podman, l'orchestrateur est pi, servi par la cle jetable du run")
def cmd(run_id: str, words: list[str]) -> int:
"""Synchrone : la sortie arrive telle quelle, le code retour aussi."""
record, box = mounted(run_id)
print(f"→ {record['box']} : {' '.join(words)}", file=sys.stderr)
proc = subprocess.run(box.argv(record, remote_command(words)), stdin=subprocess.DEVNULL)
return proc.returncode
def delegate(run_id: str, message: str, harness_name: str, config: str) -> int:
"""Un tour de l'orchestrateur en boite. Le message part par stdin : rien n'a
a survivre a deux couches de quoting, et un message multi-lignes passe."""
record, box = mounted(run_id)
check_harness(box, harness_name)
cfg = f" --config {shlex.quote(config)}" if config else ""
inner = (f"cd app && uv run adws/sandbox_orch.py turn --run {shlex.quote(run_id)}"
f" --harness {harness_name}{cfg}")
print(f"→ {record['box']} : delegation ({harness_name})", file=sys.stderr)
proc = subprocess.run(box.argv(record, inner), input=message + "\n", text=True, encoding="utf-8")
return proc.returncode
def remote_state(box, record: dict, run_id: str) -> dict | None:
"""L'etat de l'orchestrateur, lu DANS la boite — c'est la qu'il vit."""
out = box.exec(record, f"cat app/{state_path(run_id)} 2>/dev/null", check=False)
try:
return json.loads(out) if out.strip() else None
except json.JSONDecodeError:
return None
def attach(run_id: str, harness_name: str) -> int:
"""Reprendre la main : la MEME session que delegate, en interactif.
Un TTY est obligatoire — une interface de harnais en a besoin, et ni
`podman exec -i` ni `ssh vm "cmd"` n'en allouent : c'est tty=True du port.
En sortant, la session reste reprenable : par delegate depuis l'hote, ou
par un nouvel attach. Deux portes, une memoire."""
record, box = mounted(run_id)
check_harness(box, harness_name)
state = remote_state(box, record, run_id)
if state and state["harness"] != harness_name:
die(f"la session de {run_id} est en {state['harness']} — un orchestrateur, un harnais")
if harness_name == "pi":
# pi : c'est nous qui nommons la session (ch. 7) — elle existe ou nait ici.
# Le modele du roster est repris de l'etat s'il y a deja eu un tour.
model = f" --model openrouter/{state['model']}" if state and state.get("model") else ""
inner = (f"cd app && pi --session-id {session_id_for(run_id)}"
f" --session-dir {SESSION_DIR}{model}")
else:
# Claude Code : c'est lui qui nomme — il faut donc un premier tour par delegate.
if not state or not state.get("session_id"):
die("aucune session Claude Code ouverte — un premier `delegate --harness claude` la cree")
env = " ".join(f"{k}={v}" for k, v in EXE_LLM.items())
inner = f"cd app && {env} claude --resume {state['session_id']}"
print(f"→ {record['box']} : session {harness_name} — Ctrl-D / /quit pour rendre la main",
file=sys.stderr)
return subprocess.call(box.argv(record, inner, tty=True))
def shell(run_id: str) -> int:
"""Un shell dans la boite. Rappel : la boite est jetable — ce qui vaut la
peine d'etre garde en sort par la moisson (ch. 25), pas par copier-coller."""
record, box = mounted(run_id)
return subprocess.call(box.argv(record, "cd app && exec bash -l", tty=True))
# ── cote boite : un tour de l'orchestrateur ─────────────────────────────────
def turn(run_id: str, harness_name: str, config: str) -> int:
"""Un tour : brief (la premiere fois) + message → le port → la reponse.
Le profil vient du roster : celui du planner, l'agent paye pour juger."""
import os
from adw_modules import harness, roster # dans la boite : le port du ch. 7, le roster du ch. 10
message = sys.stdin.read().strip()
if not message:
die("aucun message sur stdin")
state = load_state(run_id) or {"run_id": run_id, "harness": harness_name,
"session_id": None, "model": None, "turns": 0, "cost_usd": 0.0}
if state["harness"] != harness_name:
die(f"la session est en {state['harness']} — un orchestrateur, un harnais")
spec = roster.load(config or roster.DEFAULT_PATH).agents.get("planner")
if spec is None:
die(f"{config or roster.DEFAULT_PATH} : pas d'agent planner a emprunter")
if harness_name == "claude":
# L'integration LLM d'exe.dev : pas de cle sur la VM, et rien de la
# cle jetable du run n'est depense ici (exe.dev seulement — le cote
# hote a deja refuse ce harnais sur Podman). setdefault : un reglage
# explicite de l'environnement gagne toujours.
for key, value in EXE_LLM.items():
os.environ.setdefault(key, value)
# pi : session nommee par nous, derivee du run. Claude Code : nommee par
# lui au premier tour, reprise ensuite — l'asymetrie du ch. 7, absorbee ici.
session_id = state["session_id"] or (session_id_for(run_id) if harness_name == "pi" else None)
# Claude Code ne sert que des modeles Anthropic : hors de la, son defaut.
model = spec.model if harness_name == "pi" or spec.model.startswith("anthropic/") else None
result = harness.run(harness_name, harness.HarnessRequest(
prompt=compose(state["turns"], message), session_id=session_id, model=model,
thinking=spec.thinking, tools=TOOLS, timeout=900))
state.update(session_id=result.session_id, model=spec.model, turns=state["turns"] + 1,
cost_usd=round(state["cost_usd"] + result.cost_usd, 6))
save_state(state)
print(result.text.strip())
print(f"--- tour {state['turns']} · session {result.session_id} · ce tour ~{result.cost_usd:.4f} $"
f" · cumul ~{state['cost_usd']:.4f} $", file=sys.stderr)
return 0
# ── la gate a sec ───────────────────────────────────────────────────────────
def selftest() -> int:
"""Zero reseau, zero token : l'id derive, le brief au premier tour seulement,
l'echappatoire verbatim, l'etat qui survit a un tour."""
import tempfile
global STATE_DIR
rid = "plume-20260902-3f9a1c"
sid = session_id_for(rid)
ok = sid == session_id_for(rid) and uuid.UUID(sid).version == 5
ok &= session_id_for("autre-20260902-000000") != sid
ok &= compose(0, "Lance le SDLC.").startswith(BRIEF) and compose(3, "Ou en est-on ?") == "Ou en est-on ?"
ok &= remote_command(["tail", "-20", "run.log"]) == "cd app && tail -20 run.log"
ok &= remote_command(['sqlite3 x.db "select 1"']) == 'cd app && sqlite3 x.db "select 1"'
with tempfile.TemporaryDirectory() as tmp:
STATE_DIR = Path(tmp)
ok &= load_state(rid) is None
save_state({"run_id": rid, "harness": "pi", "session_id": sid, "model": "z-ai/glm-5.3",
"turns": 1, "cost_usd": 0.0123})
again = load_state(rid)
ok &= again is not None and again["turns"] == 1 and again["session_id"] == sid
import sandbox_box as boxes
ok &= boxes.PodmanBox().argv({"run_id": rid}, remote_command(["tail", "run.log"]))[-1] == "cd app && tail run.log"
ok &= boxes.PodmanBox().argv({"run_id": rid}, "x", tty=True)[2] == "-it"
print(f"sandbox_orch {'OK' if ok else 'KO'} — id de session derive (uuid5), brief au premier"
" tour seulement, echappatoire verbatim, etat de l'orchestrateur relu, entree par le port boite")
return 0 if ok else 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="les orchestrateurs du hors-site")
parser.add_argument("--selftest", action="store_true", help="la gate a sec")
sub = parser.add_subparsers(dest="verb")
p = sub.add_parser("cmd"); p.add_argument("run_id")
p.add_argument("words", nargs=argparse.REMAINDER, help="la commande, verbatim")
p = sub.add_parser("delegate"); p.add_argument("run_id"); p.add_argument("message")
p.add_argument("--harness", choices=("pi", "claude"), default="pi")
p.add_argument("--config", default="", help="le roster dont on emprunte le planner")
p = sub.add_parser("attach"); p.add_argument("run_id")
p.add_argument("--harness", choices=("pi", "claude"), default="pi")
sub.add_parser("shell").add_argument("run_id")
p = sub.add_parser("turn"); p.add_argument("--run", required=True)
p.add_argument("--harness", choices=("pi", "claude"), default="pi")
p.add_argument("--config", default="")
args = parser.parse_args()
if args.selftest:
raise SystemExit(selftest())
if args.verb == "cmd":
if not args.words:
die("cmd : aucune commande — ex. : cmd <run> tail -20 run.log")
raise SystemExit(cmd(args.run_id, args.words))
if args.verb == "delegate":
raise SystemExit(delegate(args.run_id, args.message, args.harness, args.config))
if args.verb == "attach":
raise SystemExit(attach(args.run_id, args.harness))
if args.verb == "shell":
raise SystemExit(shell(args.run_id))
if args.verb == "turn":
raise SystemExit(turn(args.run, args.harness, args.config))
parser.print_help()
Pièce — just/sandbox/orch.just
Le module des orchestrateurs, importé par lifecycle.just comme keys.just hier : mêmes
règles, aucun set, aucune variable de fichier, une ligne par recette. cmd reçoit vos mots
tels quels : "$@" les transmet un par un, et c’est le script qui les recolle verbatim pour le
shell distant.
# just/sandbox/orch.just — les orchestrateurs du hors-site (ch. 24).
# IMPORTE par lifecycle.just : aucun `set` ici (les reglages du module s'appliquent),
# aucune variable de fichier. Rien ici ne detruit une boite ni ne lit la cle de gestion.
#
# 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
# 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"
Pièce — just/sandbox/lifecycle.just
Cette version remplace celle du chapitre 23. Une seule ligne s’ajoute, l’import de
orch.just, et rien d’autre ne bouge : vos six phases, egress et vos recettes de clés
tournent telles quelles.
# just/sandbox/lifecycle.just — le hors-site : le cycle de vie d'une boite (ch. 22).
# Un module n'herite de rien : reglages redeclares, working-directory remonte de deux crans.
set working-directory := '../..'
set positional-arguments
set dotenv-load
set windows-shell := ["C:/Program Files/Git/bin/bash.exe", "-cu"]
# la frontiere des credentials : mint, spend, revoke, keys, reap (ch. 23)
import 'keys.just'
# les orchestrateurs : cmd, delegate, attach, shell (ch. 24)
import 'orch.just'
# liste les commandes du hors-site
default:
@just --list sandbox
# le preflight du hors-site (ch. 21) : zero token, quelques secondes
preflight:
uv run adws/sandbox_preflight.py
# l'image de base des boites Podman (Containerfile, ch. 21) : une fois, ~2 min, reseau ouvert
image:
podman build -t plume-node .
# la chaine create → fill → setup → observe — jamais teardown : just sandbox mount plume [--limit 5] [--memory 4g]
mount NAME *FLAGS:
uv run adws/sandbox_lifecycle.py mount "$@"
# phase 1 — la fiche, la boite (reseau interne, porte, conteneur), puis la cle jetable (hote seulement)
create NAME *FLAGS:
uv run adws/sandbox_lifecycle.py create "$@"
# phase 2 — le repo dans la boite, un commit de reference, la cle jetable par stdin
fill RUN:
uv run adws/sandbox_lifecycle.py fill "$1"
# phase 3 — provision, sentinelle, gate (a sec puis un scout) : just sandbox setup <run> [--config adws/adw_config/eco.config.yaml]
setup RUN *ARGS:
uv run adws/sandbox_lifecycle.py setup "$@"
# phase 4 — le travail, detache : just sandbox execute <run> "<demande>" [--config …] [--adw adw_plan]
execute RUN PROMPT *ARGS:
uv run adws/sandbox_lifecycle.py execute "$@"
# phase 5 — lire de l'exterieur : run.log, la table runs, Plume servie par la porte
observe RUN:
uv run adws/sandbox_lifecycle.py observe "$1"
# phase 6 — depense, preuve, cle revoquee, boite detruite : une decision, jamais enchainee
teardown RUN:
uv run adws/sandbox_lifecycle.py teardown "$1"
# les fiches connues : montees, en cours, fermees — avec moteur, cle et depense
list:
uv run adws/sandbox_lifecycle.py list
# le journal d'egress d'une boite : ce que la porte a laisse passer, ce qu'elle a refuse (Podman)
egress RUN:
podman logs --tail 40 plume-gate-"$1"
La gate du TP
Quatre commandes, une par ligne, depuis la racine de plume-factory : la première à sec, les
trois suivantes sur une boîte montée (just sandbox mount plume --limit 5, comme hier) :
uv run adws/sandbox_orch.py --selftest
just sandbox cmd plume-<la-date>-<6 hex> tail -5 run.log
just sandbox delegate plume-<la-date>-<6 hex> "Combien de tests compte apps/plume ? Reponds en une ligne, sans rien lancer d'autre."
just sandbox delegate plume-<la-date>-<6 hex> "Que vous ai-je demande au tour precedent ?"
Attendu : sandbox_orch OK — id de session derive (uuid5), brief au premier tour seulement, echappatoire verbatim, etat de l'orchestrateur relu, entree par le port boite (zéro réseau,
zéro token). La deuxième
imprime les dernières lignes du log de la boîte, ou rien si aucun execute n’a encore tourné,
en une seconde. La troisième est le premier tour : le brief part, l’agent lit le fichier de tests
et répond en une ligne, suivie de --- tour 1 · session <uuid> · ce tour ~0,0x $. La quatrième
prouve la mémoire : nouveau podman exec, nouveau processus, et l’agent cite la question
précédente, en tour 2, même session. Quelques centimes au total sur le roster par défaut, ~30 s
par tour. Variante éco : ajoutez --config adws/adw_config/eco.config.yaml aux deux
délégations, environ un centime le tour. Pour rejoindre la session en interactif :
just sandbox attach plume-<la-date>-<6 hex>, puis /quit pour rendre la main.