Observabilité Chapitre 19 / 42

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 : kind donne le couloir, started_at/ended_at donnent 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 code sont des traits fins (millisecondes, gratuites) et les phases agent des 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_at se trace jusqu’au bord de l’axe, suivie de , la signature visuelle du run interrompu que vous avez appris à requêter au chapitre 18 (running sans 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 visuelLa 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’axequand la phase est entrée en piste
La largeur du blocoù sont passées les minutes du run
La marque (ok, ok t2, ✗ t4, )verdict, reprises, run gelé
La ligne de piedla 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 colonne error de phases le porte, mot pour mot. Quelle tentative a basculé ? Les événements phase_fail le 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 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 t3 dans 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)
Naturela conversation complète de l’agentdes événements typés, datés
Répond àcomment l’agent a raisonnéquoi, où, quand, combien
Coût de lecturelong, des dizaines de milliers de tokens à parcourirquelques millisecondes, requêtable
Survit au crashoui, mais un fichier par session à retrouveroui, indexée par adw_id
Quand l’ouvriren dernier, sur la phase coupabletoujours 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.


Quiz — teste tes connaissances
Observabilité 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.