Swim lanes & lire un run
La salle de contrôle gagne sa vitre : une vue en couloirs qui raconte un run en un coup d'œil, et une méthode de débogage qui interroge la trace avant d'ouvrir le moindre transcript.
Hier, votre usine a gagné la mémoire : chaque run laisse son journal, trois tables SQLite et un JSONL par run, écrits au moment où les choses arrivent. Mais posez-vous la question : ce matin, un SDLC de dix-huit phases est rouge, et que faites-vous du journal ? Une requête pour les phases, une autre pour les événements, une troisième pour le motif. La mémoire est là, mais elle se lit ligne à ligne, comme un relevé bancaire sans totaux.
Ce chapitre pose la vitre de la salle de contrôle : une vue en couloirs qui projette la
trace du chapitre 18 sur un axe de temps : un regard suffit pour voir où sont passées les
minutes, quelle phase a retenté, laquelle a cassé. Et avec la vue vient la méthode, vous
apprendrez à déboguer par la trace, en n’ouvrant le transcript d’un agent qu’en dernier
recours, sur la seule phase coupable. Deux pièces s’ajoutent à la zone observabilité du plan :
un lecteur de couloirs en terminal, et le module just obs qui le met à un mot de vous.
Les couloirs de nage : un run en un coup d’œil
L’idée en une phrase
La vue en couloirs de nage regroupe les phases d’un run par côté de la couture (un
couloir agent, un couloir code) et les pose sur un axe de temps : c’est une pure
projection de la trace, dérivée de deux colonnes que le journal possède déjà (kind et
les horodatages), et elle vit entièrement côté code déterministe.
Points clés
- Dérivée, jamais stockée. La vue se recalcule à chaque lecture depuis
phases:kinddonne le couloir,started_at/ended_atdonnent la position et la largeur. Rien à maintenir, rien qui puisse mentir : si la trace est juste, la vue est juste. - L’axe de temps dit où va l’argent. Sur un SDLC, les phases
codesont des traits fins (millisecondes, gratuites) et les phasesagentdes blocs massifs (minutes, facturées au token). La tension déterminisme ↔ agence du chapitre 3 devient une image. - Les reprises se voient. Une phase verte à la troisième tentative s’affiche
ok t3: les deux échecs qui l’ont précédée sont dans le journal, et le couloir vous les montre sans qu’une requête soit posée. - Un run gelé se dessine aussi. Une phase sans
ended_atse trace jusqu’au bord de l’axe, suivie de…, la signature visuelle du run interrompu que vous avez appris à requêter au chapitre 18 (runningsans heure de fin).
Exemple concret
Reprenez le SDLC du chapitre 13 : dix-huit phases, ~40 à 60 centimes, une dizaine de
minutes. La vue en couloirs tient sur un écran : quatorze traits fins dans le couloir code,
quatre blocs dans le couloir agent, et le bloc build occupe à lui seul environ la moitié
de l’axe, marqué ok t2. Vous venez d’apprendre trois choses en moins d’une seconde et
pour zéro token : le run est vert, le build a retenté une fois, et si ce run vous semble
lent, c’est le builder qu’il faut regarder, pas les gates, qui n’ont rien coûté.
Ce que chaque élément du dessin répond
| Élément visuel | La question qu’il règle |
|---|---|
Le couloir (agent / code) | de quel côté de la couture cette durée vit |
| La position du bloc sur l’axe | quand la phase est entrée en piste |
| La largeur du bloc | où sont passées les minutes du run |
La marque (ok, ok t2, ✗ t4, …) | verdict, reprises, run gelé |
| La ligne de pied | la part du mur occupée par les agents |
Commande — la vue en couloirs du dernier run
La pièce du jour s’utilise seule (uv run) ou par le module just obs posé dans le TP.
Aucun harnais en vue : c’est du code déterministe qui lit du SQLite.
just obs lanes
run f7e2a9c1 (adw_sdlc) — success — ~0.47 $ — 611 s — 18 phases
01 constat_scout code ▏░ ▏ 0.1 s ok
02 scout agent ▏█████ ▏ 68.4 s ok
03 perimetre_scout code ▏ ░ ▏ 0.2 s ok
05 plan agent ▏ ███████ ▏ 95.0 s ok
09 build agent ▏ ████████████████████ ▏ 297.3 s ok t2
16 document agent ▏ ████████ ▏ 118.6 s ok
18 dispose code ▏ ░▏ 0.4 s ok
couloir agent : 95 % du temps du run — le reste est gratuit et instantane
(Extrait abrégé : les quatorze traits fins du couloir code sont tous rendus à l’écran,
seuls quelques-uns tiennent ici.)
Piège courant : « pour visualiser des runs, il faut un dashboard web » est inexact. La vue en couloirs est une projection triviale de deux colonnes déjà tracées, et un terminal la rend en quelques millisecondes. Un dashboard reste possible au-dessus de la même base, et le chapitre 20 ouvrira ce pont, mais il n’est jamais un prérequis pour lire un run.
Déboguer par la trace, pas par le transcript
L’idée en une phrase
Devant un run rouge, la méthode de l’usine interroge la trace dans l’ordre (quelle phase ? quel motif ? quelle tentative ?) et n’ouvre le transcript de l’agent qu’en dernier, sur la seule phase coupable : trois requêtes gratuites avant une lecture longue, et la couture ne bouge pas d’un pouce.
Points clés
- Trois questions, dans cet ordre. Quelle phase ? Les couloirs la montrent, marquée
✗. Quel motif ? La colonneerrordephasesle porte, mot pour mot. Quelle tentative a basculé ? Les événementsphase_faille datent. Chaque réponse coûte zéro token et quelques millisecondes. - Le transcript est exhaustif, pas exploitable. La session d’un builder, c’est des dizaines de milliers de tokens de prompts, de lectures de fichiers et d’appels d’outils : précieux pour comprendre comment l’agent s’est trompé, inutilisable pour trouver où chercher.
- La trace borne la lecture. Une fois la phase coupable nommée, vous n’ouvrez qu’une session, celle que le runner a mémorisée pour cette phase, au lieu de remonter le stderr de tout le run.
- Le réflexe vaut aussi pour les runs verts. Un
ok t3dans un couloir est un signal gratuit : trois tentatives payées pour une phase verte. Les motifs des deux échecs sont déjà dans le journal, et c’est souvent là que se cache le prompt à améliorer (chapitre 9).
Exemple concret
Un adw_sdlc vient de rendre rouge. just obs lanes : la phase 09 build porte ✗ t4,
quatre tentatives, toutes rejetées. just obs tail f7e2a9c1 : le dernier phase_fail dit
gate tests : 2 verifications en echec, identique sur les trois derniers événements. Le
builder bute sur les mêmes tests à chaque reprise. Diagnostic posé en ~30 secondes, zéro
token. Vous ouvrez alors la session du builder, une seule, et constatez qu’il modifie le bon
fichier mais oublie de déclarer le second dans changed_files. Sans la trace : dix minutes de
scroll, ou un re-run à plusieurs dizaines de centimes juste pour revoir l’échec.
Transcript ou trace : deux lectures, deux usages
| Le transcript (la session) | La trace (le journal) | |
|---|---|---|
| Nature | la conversation complète de l’agent | des événements typés, datés |
| Répond à | comment l’agent a raisonné | quoi, où, quand, combien |
| Coût de lecture | long, des dizaines de milliers de tokens à parcourir | quelques millisecondes, requêtable |
| Survit au crash | oui, mais un fichier par session à retrouver | oui, indexée par adw_id |
| Quand l’ouvrir | en dernier, sur la phase coupable | toujours en premier |
Commande — la séquence de débogage
Quatre lectures, une par ligne, de la plus large à la plus fine. Les trois premières sont gratuites, la quatrième est une lecture humaine que la trace a bornée :
just obs runs
just obs lanes
just obs tail f7e2a9c1
uv run adws/obs_lanes.py --run f7e2a9c1
Piège courant : « relire tout le transcript est plus fiable, puisqu’il contient tout » est inexact. L’exhaustivité est précisément le problème : un transcript de SDLC se lit en dizaines de minutes et noie le motif d’échec dans le bruit des appels d’outils. La trace ne remplace pas le transcript, elle vous dit lequel ouvrir, et à quelle phase.
Fil rouge — la pièce posée aujourd’hui
La zone « salle de contrôle » du plan reçoit sa vitre : adws/obs_lanes.py, le lecteur de
couloirs, et just/obs.just, le module qui le met à un mot, monté dans le justfile comme le
chapitre 5 vous l’a appris. La couture ne bouge pas : ces deux pièces sont du code déterministe
qui lit ce que le tracer du chapitre 18 écrit. Les agents ignorent toujours qu’ils sont
observés, et aucune enveloppe ne traverse. À l’usage : zéro token, quelques millisecondes
par lecture. À l’économie : un diagnostic qui exigeait de relire un scroll de dix minutes, ou
de repayer un run à plusieurs dizaines de centimes, tient désormais en trois commandes
gratuites sur un run déjà payé.
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, trois fichiers : le lecteur de
couloirs, son module just obs, et le justfile qui le monte.
Pièce — adws/obs_lanes.py
La vitre de la salle de contrôle. Bibliothèque standard uniquement. Il lit le journal posé au chapitre 18 et n’écrit jamais dedans, sauf son auto-test, qui fabrique une base jetable via le tracer pour vérifier le rendu, zéro token. Entièrement côté déterministe.
# /// script
# requires-python = ">=3.11"
# ///
"""obs_lanes — la salle de controle en terminal : un run en un coup d'oeil.
La vue en couloirs est une PROJECTION de la trace du chapitre 18 : rien
de nouveau n'est stocke. Le couloir vient de `kind`, la position et la
largeur viennent des horodatages — si la trace est juste, la vue est juste.
uv run adws/obs_lanes.py # les couloirs du dernier run
uv run adws/obs_lanes.py --run <adw_id> # ceux d'un run precis
uv run adws/obs_lanes.py --runs # les dix derniers runs
uv run adws/obs_lanes.py --tail <adw_id> # le grain fin : les evenements
uv run adws/obs_lanes.py --selftest # la gate du module, zero token
"""
from __future__ import annotations
import argparse
import io
import sqlite3
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
DB_PATH = Path("adws/adw_data/factory.db")
AXIS = 44 # largeur de l'axe du temps, en caracteres
BAR = {"agent": "█", "code": "░"} # un motif par couloir : l'oeil trie sans lire
def connect(db_path: Path = DB_PATH) -> sqlite3.Connection:
if not Path(db_path).is_file():
sys.exit("aucun journal — lancez un ADW, puis revenez")
conn = sqlite3.connect(db_path)
# Lire pendant que l'usine ecrit : WAL est deja pose par le tracer,
# le lecteur se contente de patienter si un ecrivain passe.
conn.execute("PRAGMA busy_timeout=5000;")
return conn
def ts(value: str) -> datetime:
return datetime.fromisoformat(value)
def pick_run(conn: sqlite3.Connection, adw_id: str | None) -> str:
if adw_id:
return adw_id
row = conn.execute(
"SELECT adw_id FROM runs ORDER BY started_at DESC LIMIT 1").fetchone()
if row is None:
sys.exit("journal vide — lancez un ADW, puis revenez")
return row[0]
def lanes(conn: sqlite3.Connection, adw_id: str) -> int:
"""Le run projete sur son axe de temps : un couloir par cote de la couture."""
run = conn.execute(
"SELECT adw_name, status, cost_usd, started_at, ended_at"
" FROM runs WHERE adw_id=?", (adw_id,)).fetchone()
if run is None:
sys.exit(f"run {adw_id} inconnu au journal")
name, status, cost, started, ended = run
phases = conn.execute(
"SELECT seq, name, kind, status, attempt, started_at, ended_at"
" FROM phases WHERE adw_id=? ORDER BY seq", (adw_id,)).fetchall()
if not phases:
sys.exit(f"run {adw_id} sans phase tracee")
t0 = ts(started)
# Fin de l'axe : la fin du run — ou, run gele, le dernier horodatage
# connu : un run interrompu se dessine jusqu'a l'instant du gel.
last = max(p[6] or p[5] for p in phases)
t1 = ts(ended) if ended else ts(last)
total = max((t1 - t0).total_seconds(), 0.001)
frozen = " (interrompu ?)" if status == "running" and not ended else ""
print(f"run {adw_id} ({name}) — {status}{frozen} — ~{cost:.2f} $"
f" — {total:.0f} s — {len(phases)} phases")
agent_time = 0.0
for seq, pname, kind, pstatus, attempt, p_start, p_end in phases:
offset = int((ts(p_start) - t0).total_seconds() / total * AXIS)
offset = min(max(offset, 0), AXIS - 1)
duration = ((ts(p_end) if p_end else t1) - ts(p_start)).total_seconds()
width = min(max(int(duration / total * AXIS), 1), AXIS - offset)
bar = " " * offset + BAR.get(kind, "?") * width
bar += " " * (AXIS - len(bar))
if kind == "agent":
agent_time += duration
if p_end is None and pstatus != "success":
mark = "…" # en piste — ou gelee la
elif pstatus == "success":
mark = "ok" if attempt <= 1 else f"ok t{attempt}"
else:
mark = f"✗ t{attempt}"
print(f"{seq:02d} {pname:<20.20} {kind:<5} ▏{bar}▏ {duration:6.1f} s {mark}")
share = agent_time / total * 100
print(f"couloir agent : {share:.0f} % du temps du run"
" — le reste est gratuit et instantane")
return 0
def runs_list(conn: sqlite3.Connection) -> int:
"""Les dix derniers runs : de quoi choisir lequel projeter."""
for adw_id, name, status, cost, started in conn.execute(
"SELECT adw_id, adw_name, status, cost_usd, started_at"
" FROM runs ORDER BY started_at DESC LIMIT 10"):
print(f"{adw_id:<12} {name:<18.18} {status:<8} ~{cost:.2f} $ {started}")
return 0
def tail(conn: sqlite3.Connection, adw_id: str) -> int:
"""Le grain fin : les 25 derniers evenements, dans l'ordre du temps."""
rows = conn.execute(
"SELECT ts, type, name, payload_json FROM events"
" WHERE adw_id=? ORDER BY ts DESC LIMIT 25", (adw_id,)).fetchall()
for ts_, type_, name, payload in reversed(rows):
snippet = payload if len(payload) <= 60 else payload[:59] + "…"
print(f"{ts_} {type_:<12} {name:<20.20} {snippet}")
return 0
def selftest() -> int:
"""La gate du module : un run synthetique, ses couloirs rendus, zero token."""
from adw_modules.tracer import Tracer
def iso(seconds: int) -> str:
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
return (base + timedelta(seconds=seconds)).isoformat(timespec="milliseconds")
with tempfile.TemporaryDirectory() as tmp:
db = Path(tmp) / "test.db"
tracer = Tracer(db_path=db, jsonl_dir=Path(tmp) / "traces")
tracer.run_start("demo", "obs_selftest", "run synthetique")
p1 = tracer.phase_start("demo", 1, "constat", "code")
tracer.phase_attempt(p1, "demo", "constat", 1, ok=True)
p2 = tracer.phase_start("demo", 2, "build", "agent", retries=2)
tracer.phase_attempt(p2, "demo", "build", 1, ok=False,
error="gate tests : 1 echec")
tracer.phase_attempt(p2, "demo", "build", 2, ok=True)
tracer.run_finish("demo", ok=True, cost_usd=0.12)
# Etirer les horodatages : un vrai axe de temps pour la projection.
tracer.conn.execute("UPDATE runs SET started_at=?, ended_at=?"
" WHERE adw_id='demo'", (iso(0), iso(90)))
tracer.conn.execute("UPDATE phases SET started_at=?, ended_at=?"
" WHERE phase_id=?", (iso(0), iso(2), p1))
tracer.conn.execute("UPDATE phases SET started_at=?, ended_at=?"
" WHERE phase_id=?", (iso(2), iso(88), p2))
buffer, real = io.StringIO(), sys.stdout
sys.stdout = buffer
try:
code = lanes(sqlite3.connect(db), "demo")
finally:
sys.stdout = real
out = buffer.getvalue()
ok = (code == 0 and "ok t2" in out and BAR["agent"] in out
and BAR["code"] in out and "couloir agent" in out)
print(f"obs_lanes {'OK' if ok else 'KO'} — 2 couloirs,"
" 1 reprise visible, projection rendue")
return 0 if ok else 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="la salle de controle en terminal")
parser.add_argument("--run", help="projeter ce run precis")
parser.add_argument("--runs", action="store_true", help="lister les derniers runs")
parser.add_argument("--tail", help="les derniers evenements de ce run")
parser.add_argument("--selftest", action="store_true", help="la gate du module")
args = parser.parse_args()
if args.selftest:
raise SystemExit(selftest())
conn = connect()
if args.runs:
raise SystemExit(runs_list(conn))
if args.tail:
raise SystemExit(tail(conn, args.tail))
raise SystemExit(lanes(conn, pick_run(conn, args.run)))
Pièce — just/obs.just
Le module de la salle de contrôle, monté par le justfile ci-dessous. Comme le chapitre 5 vous
l’a appris, un module n’hérite de rien : il redéclare set working-directory := '..' pour que
chaque chemin se résolve depuis la racine. Toutes les recettes tiennent en une ligne, la
logique vit dans adws/obs_lanes.py, portable partout où uv tourne.
# just/obs.just — la salle de controle : lire les traces de l'usine.
# Un module n'herite de rien : working-directory se redeclare ici (ch. 5).
set working-directory := '..'
set positional-arguments
# liste les commandes de la salle de controle
default:
@just --list obs
# les dix derniers runs : verdict, cout, date
runs:
uv run adws/obs_lanes.py --runs
# les couloirs de nage du dernier run
lanes:
uv run adws/obs_lanes.py
# les couloirs d'un run precis : just obs run <adw_id>
run ADW_ID:
uv run adws/obs_lanes.py --run {{ ADW_ID }}
# le grain fin : les derniers evenements d'un run : just obs tail <adw_id>
tail ADW_ID:
uv run adws/obs_lanes.py --tail {{ ADW_ID }}
Pièce — justfile
Cette version remplace celle du chapitre 6. Une seule ligne s’ajoute, le montage du module
obs, et rien d’autre ne bouge : vos recettes des chapitres 3 à 6 tournent telles quelles.
# justfile — la surface de commandes de plume-factory.
# `just` seul liste tout : c'est le panneau de commandes de l'usine.
# .env est chargé dans l'environnement des recettes (OPENROUTER_API_KEY au module 4).
set dotenv-load
# Les arguments de la ligne de commande deviennent $1, $2, "$@" dans les recettes.
set positional-arguments
# Windows : les recettes s'exécutent dans le bash de Git (installé avec git) — pas de WSL.
# Ce réglage est ignoré sur macOS/Linux ; adaptez le chemin si Git est installé ailleurs.
set windows-shell := ["C:/Program Files/Git/bin/bash.exe", "-cu"]
# la salle de contrôle de l'usine : just obs lanes, just obs runs… (ch. 19)
mod obs 'just/obs.just'
# liste les recettes — sans elle, `just` nu exécuterait la première recette du fichier
default:
@just --list
# le préflight du poste de pilotage (ch. 4)
doctor:
uv run adws/doctor.py
# l'embryon du runner (ch. 3) — choisir le harnais : just hello claude
hello HARNESS="pi":
uv run adws/hello_factory.py --harness {{ HARNESS }}
# les tests du payload Plume (ch. 2) — cd et commande sur UNE ligne : chaque ligne a son shell
test:
cd apps/plume && bun test
# servir Plume en local sur le port 4500 (ch. 2)
serve:
cd apps/plume && bun run server.ts
# monter le poste : workspace herdr « plume-factory », Plume servie dans sa pane (ch. 6)
# La logique vit dans adws/fleet.py — la recette reste une ligne, comme la loi l'exige.
fleet:
uv run adws/fleet.py
# l'état de la flotte : chaque pane et son agent_status
fleet-status:
herdr pane list
# fermer le workspace de l'usine : just fleet-down w1
fleet-down WS:
herdr workspace close {{ WS }}
La gate du TP
Trois commandes, une par ligne, depuis la racine de plume-factory :
uv run adws/obs_lanes.py --selftest
uv run adws/adw_prompt.py "Cite un fichier de apps/plume. Reponds en une phrase."
just obs lanes
Attendu : la première imprime obs_lanes OK — 2 couloirs, 1 reprise visible, projection rendue
(zéro token), la deuxième trace un vrai run sur votre roster par défaut, la troisième le
projette : son couloir agent, son couloir code, sa ligne de pied. En tout : ~quelques
centimes, ~1 minute, et plus aucun run de votre usine ne se lira ligne à ligne.