Leçon 6 — Contexte et reprise

Points de reprise et journal

7 min de lecture

Points de reprise et journal

Un agent a fait onze éditions sur six fichiers. La septième était la bonne, les quatre suivantes ont tout cassé en « nettoyant ». git stash ne sert à rien : il prend tout ou rien. git checkout . efface aussi les modifications que le développeur avait en cours avant de lancer l'agent. Et quand le responsable technique demande pourquoi l'agent a modifié montants.py, il ne reste que l'historique du terminal, déjà sorti de l'écran.

Le mécanisme

Un point de reprise est un instantané complet du répertoire de travail, pris juste avant chaque écriture approuvée. On utilise la plomberie de git, pas ses commandes habituelles :

  1. un index temporaire (GIT_INDEX_FILE) reçoit git add -A : l'index de l'utilisateur, ce qu'il a déjà indexé pour son prochain commit, n'est pas touché ;
  2. git write-tree puis git commit-tree créent un commit sans parent sur aucune branche ;
  3. git update-ref refs/harness/001 le nomme, hors de refs/heads : il n'apparaît ni dans git log ni dans git branch, mais git gc ne le supprime pas.

La restauration fait le chemin inverse dans un autre index temporaire, et supprime les fichiers apparus depuis l'instantané.

sequenceDiagram
  participant A as Agent
  participant H as Harness
  participant G as git (index temporaire)
  A->>H: edit_file remises.py
  H->>H: permission accordée
  H->>G: add -A, write-tree, commit-tree
  G-->>H: commit 2ba9091
  H->>G: update-ref refs/harness/001
  H->>H: écriture du fichier, journal
  Note over H,G: plus tard : python -m harness undo --to 1
  H->>G: diff --name-status 2ba9091 état courant
  H->>H: suppression des fichiers ajoutés depuis
  H->>G: read-tree 2ba9091, checkout-index -a -f

Le journal de trajectoire est un fichier JSONL : une ligne JSON par événement (start, model, permission, checkpoint, tool, summary, compaction, loop, end), avec l'instant relatif, les tokens de chaque tour et la sortie tronquée de chaque outil. Le format JSONL s'ajoute ligne à ligne : un crash au tour 12 laisse 11 tours lisibles.

La mise en œuvre

files/harness/checkpoint.py
def snapshot(self, label: str) -> str:
    with tempfile.TemporaryDirectory() as tmp:
        tree = self._tree(os.path.join(tmp, "index"))   # add -A + write-tree dans un index jetable
    commit = self._git("commit-tree", tree, "-m", label)
    self._n += 1
    self._git("update-ref", f"{REF_PREFIX}{self._n:03d}", commit)
    return commit

Le point de reprise est pris dans Agent.execute(), après l'accord de permission et avant l'écriture : une écriture refusée ne crée rien (c'est testé). Le journal est écrit au même endroit, ce qui garantit l'ordre permission → checkpoint → tool. python -m harness checkpoints liste les points, python -m harness undo --to N restaure.

Résultat obtenu

Sur le dépôt de démo, après une correction complète en trois éditions (2026-10-15) :

$ python -m harness checkpoints
001  2ba9091447  avant edit_file facturation/remises.py
002  dd18141fc7  avant edit_file facturation/montants.py
003  0a3637ec86  avant edit_file facturation/montants.py
$ python -m harness undo --to 2
restauré 002 (avant edit_file facturation/montants.py) ; fichiers : facturation/montants.py
$ python -m pytest -q
1 failed, 5 passed in 0.04s            # la correction des remises est conservée, pas celle de l'arrondi
$ python -m harness undo --to 1
restauré 001 (avant edit_file facturation/remises.py) ; fichiers : facturation/remises.py
4 failed, 2 passed in 0.05s            # retour exact à l'état initial ; git status est vide

Le rapport HTML (sorties/trajectoire.html), généré à partir du journal sans aucune dépendance, montre la même session lue par un humain :

Rapport de trajectoire du run réel : indicateurs, puis chaque tour, refus et sortie d'outil

Ce qui casse en production

  • Restauration qui efface le travail d'un humain. La restauration supprime les fichiers créés depuis l'instantané, y compris un fichier que le développeur a créé à côté pendant la session. Parade : restaurer seulement les chemins touchés par les outils de l'agent (Claude Code fait ce choix : ses points de reprise ne suivent que les éditions de ses propres outils), ou demander confirmation quand un fichier non créé par l'agent va disparaître.
  • Modifications par commande non couvertes. Un formateur ou un générateur lancé par run_command modifie des fichiers sans point de reprise préalable. Le point de reprise suivant les capture, mais on ne peut pas revenir juste avant la commande. Parade : instantané avant toute commande hors lecture, au prix de quelques dizaines de millisecondes.
  • Fichiers ignorés perdus. git add -A respecte .gitignore : une base SQLite locale ou un .env modifiés par un test ne sont pas restaurés. C'est voulu pour .env, gênant pour des données de test. Parade : les déclarer, ou faire travailler l'agent sur une copie (worktree) jetable.
  • Journal qui fuit. Le journal contient des extraits de fichiers et de sorties de commandes. Si un test affiche une variable sensible, elle est dans le JSONL. Parade : masquage des motifs de secrets avant écriture, rétention courte, jamais de journal dans le dépôt versionné.

La règle de décision

Prenez un instantané avant chaque écriture dès que l'agent peut écrire : le journal du run réel montre 9 à 19 ms par instantané sur ce dépôt, et c'est ce qui rend le mode auto acceptable. Si votre équipe vit dans git, l'approche d'Aider (un commit par édition, sur la branche) est plus lisible ; si elle veut un historique propre, l'approche de ce projet ou le dépôt fantôme de Gemini CLI la laisse intacte. Gardez le journal pour chaque session, même réussie : c'est votre jeu de données d'évaluation de demain.

Pratique

Un développeur a des modifications indexées (git add) pour son prochain commit. Il lance l'agent, puis fait undo vers le premier point de reprise. Que retrouve-t-il ?

On vous demande pourquoi l'agent a mis 45 s alors que le modèle répond en 2 à 3 s par tour. Où cherchez-vous ?

Exercice. Dans cmd_demo, si la vérification indépendante finale échoue (suite toujours rouge), restaurez automatiquement le premier point de reprise et journalisez l'événement rollback.

Voir la solution
ok, line = pytest_summary(repo, s.command_timeout_s)
if not ok and report.checkpoints:
    first = report.checkpoints[0]
    touched = Checkpoints(repo).restore(first)
    Trajectory(journal).log("rollback", to=first, files=touched, reason=line)
    print(f"Échec : retour à l'état initial ({len(touched)} fichiers).")

report.checkpoints[0] est l'état juste avant la première écriture, donc l'état initial de la session. Testez avec un ScriptedModel qui casse un fichier (edit_file qui supprime return taux) puis termine : le dépôt doit revenir à son état initial et git status doit être vide. Gardez une option pour désactiver ce comportement : pour déboguer l'agent, on veut parfois voir le dépôt cassé.

  • Je prends un instantané git sans toucher à la branche ni à l'index de l'utilisateur
  • Je sais restaurer un point de reprise précis et vérifier le résultat
  • Je journalise chaque décision en JSONL, dans l'ordre permission, point de reprise, outil
  • Je connais les angles morts : commandes, fichiers ignorés, travail humain concurrent
Suivant