Retour d'expérience · Claude Code

Longs travaux d'agents Claude Code : pourquoi on perd du travail à la coupure, et comment ne plus rien perdre

Retour d'expérience du 5 octobre 2026. Forfait Max 5x, extension VS Code sous Windows, Claude Code 2.1.286 au moment de l'incident, documentation officielle relue le jour même (CLI 2.1.289).

Pour ton Claude

Le même texte en Markdown, plus simple à lire pour un agent : rapport.md. Donne-lui l'adresse https://agents-sans-perte.pages.dev/rapport.md et demande-lui de challenger le constat et la méthode, en revérifiant les faits marqués doc sur sa propre version de Claude Code.

Niveaux de preuve : doc documentation officielle, relue le 05/10/2026 · observé nos journaux et relevés · hypothèse non prouvé par un test.

À lire comme : un constat suivi d'une méthode, à challenger. Chaque affirmation porte son niveau de preuve :

Claude Code bouge presque chaque jour : tout ce qui est marqué doc est à revérifier sur ta version.


1. L'incident : trois agents d'une demi-heure chacun, rien sur le disque

Un workflow (outil Workflow, scripts JS qui orchestrent des sous-agents) devait collecter puis vérifier quelques milliers de chiffres sur le web pour une étude de marché.


2. Ce que la mécanique de Claude Code fait réellement

2.1 La limite de 5 h est une limite de session, tous modèles confondus

2.2 La pause à la limite est active par défaut, sauf dans certaines sessions

Ce que ça ne dit pas : si un agent en pause garde son fil de travail, ou s'il repart de zéro à la reprise. La doc dit seulement que les agents en attente « repartent ».

2.3 Reprendre un workflow rejoue tout ce qui a démarré après un échec

resumeFromRunId relance un run dans la même conversation, ou dans une conversation rouverte par claude --resume. doc, workflows, « Resume after a pause » Le rejeu suit l'ordre de démarrage des agents :

Conséquence : le cache d'un workflow est un bonus, jamais une garantie.

2.4 Un script de workflow est aveugle

2.5 Permissions : un agent peut bloquer tout le run

2.6 Chaque agent coûte un ticket d'entrée

Chaque sous-agent recharge les CLAUDE.md du workspace et du projet avant de commencer. Chez nous, avec un gros profil personnel importé, c'est environ 165 Ko, soit ~45 000 tokens. observé, tailles de fichiers ; le chiffre de tokens est une estimation Le cache de prompt partagé entre agents d'un même run atténue ce coût, sans le supprimer. doc, « Prompt caching in a fan-out »

Conséquence : émietter un travail en très petites tâches n'est pas gratuit.

2.7 Le quota de recherches web se partage

Plusieurs agents d'une même session ont buté sur un « quota de 200 recherches ». La session principale cherchait encore pendant ce temps : c'est plutôt une limite de débit qu'un plafond dur. observé Parades qui ont marché : lire directement les adresses connues (WebFetch), ou passer par DuckDuckGo Lite et Startpage dans un Chrome invisible piloté par Playwright.


3. Le diagnostic : les agents ont échoué à cause de Remote Control, et perdu leur travail à cause du format de sortie

  1. La coupure : sans doute Remote Control. hypothèse forte La session avait été ouverte avec remoteControlAtStartup: true ; son journal contient des enregistrements bridge-session, et la doc exclut les sessions Remote Control de la pause d'un workflow (§2.2). La limite touchée était celle de 5 h (rateLimitType: five_hour), touchée vers 00:55, avec une remise à zéro vers 3 h du matin : sans Remote Control, le run aurait dû attendre deux heures, puis repartir. Non prouvé par un test.
  2. La perte : le format de sortie rendait la consigne impossible à tenir. observé Chaque vérificateur devait remplir un seul fichier JSON contenant un tableau de verdicts. Pour l'enrichir, il faut le réécrire en entier, chaque fois plus gros, donc plus cher : l'agent attend logiquement la fin. Le seul vérificateur qui a fini a écrit ses 110 Ko d'un coup, à la dernière minute.
  3. La cascade : le script avalait les échecs. observé, en relisant le script
    • Une fonction de repli relançait chaque échec sur un autre modèle, ce qui a doublé les appels ratés, sans aucune chance de succès puisque la limite est commune à tous les modèles.
    • Une boucle de correction, faute de résultat, gardait l'ancienne liste de contestations et relançait corrections et tours suivants dans le vide.
    • Ces 23 échecs n'ont presque rien coûté déduit : ils tombaient aussitôt sur la limite. Ce qui a coûté, ce sont les trois agents en vol.
  4. L'exposition : trop gros, trop cher, trop à la fois. observé Quatre agents sur le modèle le plus cher, chacun avec quatre ou cinq fichiers, soit 30 à 60 minutes de travail par agent. Fable a coûté jusqu'à environ cinq fois un agent Opus par minute (§7). Le parallélisme ne change pas le coût total, mais il accélère la course vers la limite et met plus de travail en vol au moment où elle tombe.

4. La méthode : neuf règles

Le principe : le disque est la seule mémoire qui compte. Ni la mémoire d'un agent, ni le cache d'un workflow, ni la conversation ne survivent sûrement à un arrêt. Un arrêt peut venir de la limite, d'un plantage, d'une coupure de courant, de VS Code qu'on ferme, ou d'une demande de permission sans réponse.

Quand l'appliquer en entier : tout workflow, tout lancement de plus de trois agents, tout travail d'agents estimé à plus d'une demi-heure. En dessous, le bloc de reprise (§5.1) et les règles 6 à 9 suffisent.

  1. Une tâche = une sortie. Chaque agent produit un résultat qui tient seul (un fichier à vérifier, pas cinq), en 10 à 20 minutes. Pas plus fin, à cause du ticket d'entrée (§2.6). Une entrée trop grosse pour un agent (plusieurs mégaoctets) se prépare d'abord par un script, qui en extrait une table compacte.
  2. On écrit par lots, dans des fichiers neufs. Tous les 15 à 30 résultats, un nouveau fichier : lot-001.json, lot-002.json… Un lot écrit ne se touche plus. On écrit avec l'outil Write, pas par une redirection du shell : on évite ainsi les pièges de guillemets de PowerShell et les demandes de permission.
  3. Une marque de fin. L'agent qui a fini écrit FIN.json. Sans FIN.json lisible, la tâche est inachevée.
  4. L'agent sait reprendre. Sa consigne lui dit de lire ses lots existants et de continuer après (§5.1).
  5. La liste des tâches est sur le disque avant le lancement (taches.json, §5.2). L'état de chaque tâche se calcule en regardant son dossier ; on ne le tient pas à la main.
  6. Un échec arrête les lancements. Pas de nouvel essai automatique, pas de repli sur un autre modèle, pas d'étape suivante bâtie sur un résultat manquant.
  7. Trois agents à la fois au plus, sauf décision explicite, réglés à chaque run.
  8. Le modèle le plus cher seulement pour juger : synthèse, arbitrage, avocat du diable. Collecter, vérifier un chiffre contre sa source, consolider : Opus ou Sonnet.
  9. Rien qui attende un humain. Un sous-agent ne supprime, ne déplace ni ne renomme rien, et un crochet le lui refuse (§5.4).

Deux habitudes complètent ces règles :


5. Les outils : bloc de reprise, liste des tâches, squelette de workflow, crochet

5.1 Le bloc de reprise, à coller dans chaque consigne d'agent

## Écriture et reprise (obligatoire)
Ton dossier de sortie : <DOSSIER ABSOLU>
1. Avant tout, regarde ce dossier. S'il contient FIN.json, ton travail est déjà fini : réponds avec
   son contenu, sans rien refaire. S'il contient des lots (lot-001.json, lot-002.json…), lis-les :
   ce qu'ils contiennent est fait, ne le refais pas, continue après.
2. Écris tes résultats par lots, chaque fois dans un fichier NEUF (lot-001.json, puis
   lot-002.json…), avec l'outil Write, dès que tu as <N> résultats. Ne réécris jamais un lot déjà
   écrit et ne le complète pas : un lot écrit est figé. Ne garde rien en tête que tu n'aies pas
   encore écrit.
3. Quand tout est fait, écris FIN.json : { "termine": true, "lots": <nombre de lots>,
   "resultats": <nombre de résultats>, "remarques": "" }. Sans FIN.json, ton travail sera
   considéré comme inachevé et repris.
4. Tu ne supprimes, ne déplaces ni ne renommes rien. Une variable d'environnement se vide par
   $env:NOM = $null. Tes brouillons vont dans <DOSSIER>brouillon/ et y restent. Si une suppression
   te semble nécessaire, dis-le dans ta réponse.

Le seuil est un nombre de résultats, pas une durée : un agent n'a pas d'horloge.

5.2 La liste des tâches

{
  "chantier": "vérification des chiffres collectés",
  "cree": "2026-10-05",
  "taches": [
    {
      "id": "verif-fichier-a",
      "sortie": "/chemin/absolu/verification/fichier-a/",
      "entrees": ["/chemin/absolu/collecte/fichier-a.json"],
      "consigne": "consignes/verif-fichier-a.md",
      "modele": "opus",
      "effort": "high"
    }
  ]
}

L'état d'une tâche se lit dans son dossier :

Il n'y a volontairement pas de statut « en cours » : après un plantage, personne ne le remettrait à jour. La consigne de chaque tâche y figure, pour qu'une conversation neuve relance exactement la même.

5.3 Le squelette d'un workflow : seulement ce qui manque, trois à la fois, arrêt au premier échec

La session fait l'inventaire du disque, puis passe au script les tâches restantes et la date. agent, phase, log et args sont fournis par le runtime des workflows ; BLOC_REPRISE est à remplir avec le texte du §5.1.

export const meta = {
  name: 'chantier-etape',
  description: 'Une étape d’un chantier : seulement les tâches qui restent, trois à la fois',
  phases: [{ title: 'Travail' }],
}

// args = { date: '2026-10-05', simultanes: 3, lot: 20,
//          taches: [ { id, sortie, consigne, modele, effort } ] }   (consigne : le texte complet)
const BLOC_REPRISE = (dossier, n) => `## Écriture et reprise (obligatoire)
...le bloc du §5.1, avec ${dossier} et ${n}...`

const FIN = {
  type: 'object',
  properties: {
    termine: { type: 'boolean' }, lots: { type: 'integer' },
    resultats: { type: 'integer' }, remarques: { type: 'string' },
  },
  required: ['termine', 'lots', 'resultats'],
}

// Au plus `simultanes` agents à la fois ; le premier qui ne va pas au bout arrête les lancements.
async function enFile(taches, simultanes, faire) {
  const rendus = []
  let suivant = 0
  let arret = false
  async function ouvrier() {
    while (!arret && suivant < taches.length) {
      const t = taches[suivant++]
      let r
      try { r = await faire(t) } catch (e) {
        log(`${t.id} : erreur (${e && e.message ? e.message : e})`)
        r = null
      }
      rendus.push({ id: t.id, rendu: r })
      if (!r || r.termine !== true) {
        arret = true
        log(`Arrêt : ${t.id} n'est pas allée au bout. Aucune nouvelle tâche ne démarre.`)
      }
    }
  }
  await Promise.all(Array.from({ length: Math.min(simultanes, taches.length) }, ouvrier))
  return { rendus, arret, non_lancees: taches.slice(suivant).map(t => t.id) }
}

phase('Travail')
return await enFile(args.taches, args.simultanes || 3, t =>
  agent(`Nous sommes le ${args.date}.\n\n${t.consigne}\n\n${BLOC_REPRISE(t.sortie, args.lot || 20)}`, {
    label: t.id, phase: 'Travail', schema: FIN, model: t.modele, effort: t.effort,
  }))

Le retour du script est un confort. Ce qui fait foi après le run, c'est l'inventaire du disque.

5.4 Le crochet qui empêche un sous-agent d'attendre un humain

Ce crochet PreToolUse sur Bash|PowerShell sort en code 2, donc avant les règles de permission, et seulement quand l'appel vient d'un sous-agent (agent_id présent). La session principale garde ses demandes habituelles. L'agent reçoit la raison sur stderr et change de méthode. Il refuse la suppression et le déplacement de fichiers (alias PowerShell compris), et le git destructif : reset --hard, clean, push --force (ou -f, ou --force-with-lease) et add -A / --all / ., où que soient placées les options. Cette liste reprend nos propres règles ask : adapte-la aux tiennes.

// ~/.claude/hooks/refuser-sous-agents.mjs
let entree = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', morceau => { entree += morceau })
process.stdin.on('end', () => {
  let e
  try { e = JSON.parse(entree.replace(/^\uFEFF/, '')) } catch { process.exit(0) }
  if (!e || !e.agent_id) process.exit(0)

  const commande = String((e.tool_input && e.tool_input.command) || '')
  // Les alias PowerShell sont ramenés à leur cmdlet avant les règles : on les refuse aussi.
  const verbes = e.tool_name === 'PowerShell'
    ? 'Remove-Item|Move-Item|rm|rmdir|mv|del|erase|ri|rd|mi|move'
    : 'rm|rmdir|mv'
  const interdit = new RegExp(
    `(^|[\\s;|&({])(${verbes})(?=\\s|$)` +
    '|(^|[\\s;|&({])git\\b[^;|&\\n]*?\\s(reset\\s+--hard|clean|push\\b[^;|&\\n]*?\\s(--force\\S*|-f)|add\\s+(-A|--all|\\.))(?=\\s|$)',
    'i')
  if (!interdit.test(commande)) process.exit(0)

  process.stderr.write(
    "Refusé aux sous-agents : cette commande attendrait un accord humain. Ne supprime, ne déplace " +
    "ni ne renomme rien : écris un fichier neuf. Une variable d'environnement se vide par " +
    '$env:NOM = $null. Si une suppression est nécessaire, signale-la : la session principale la fera.')
  process.exit(2)
})
"hooks": {
  "PreToolUse": [
    { "matcher": "Bash|PowerShell",
      "hooks": [ { "type": "command", "command": "node",
                   "args": ["<chemin absolu>/refuser-sous-agents.mjs"], "timeout": 10 } ] }
  ]
}

Nous l'avons testé à blanc sur 29 commandes choisies, alias PowerShell et options git déplacées compris : toutes traitées comme prévu. La couverture n'est pas exhaustive pour autant.

Ce qu'il ne fait pas :

5.5 Avec ou sans Remote Control : pause automatique à la limite, ou échec des agents

Notre choix : (b) par défaut, (a) pour un run de nuit assumé.


6. Après un arrêt : inventaire du disque, puis un nouveau run, jamais une reprise

  1. Ne rien relancer. Vérifier d'abord qu'aucun agent de l'ancien run ne tourne encore : il écrirait dans les mêmes dossiers qu'un agent neuf.
  2. Faire l'inventaire du disque : faite, entamée, à faire. Un fichier illisible compte comme absent.
  3. Lancer toujours un nouveau run, avec la liste de ce qui reste. Reprendre l'ancien (resumeFromRunId) n'apporte rien de plus avec cette méthode, et rejouerait à tort tout ce qui a démarré après un échec (§2.3). Exception : un run mis en pause, et non arrêté.
  4. Arrêter proprement depuis VS Code, c'est arrêter le run entier ; jamais le bouton Stop d'un seul agent, qui compte comme un échec.

7. Repères de coût observés, Max 5x, effort high

À confiance moyenne : ce sont des relevés /usage arrondis, sur une seule soirée.

SituationConsommation de la fenêtre de 5 h
10 agents Opus 5.5 en parallèle1,36 à 1,5 point par minute, soit ~0,15 par agent
4 agents Fable 5.1 en parallèlece qui restait de la fenêtre vidé en ~35 min. Faute de relevé au départ, ~0,7 point par minute et par agent est une borne haute
Rapport fenêtre / semaine1 point de fenêtre ≈ 0,11 point de semaine sur le relevé le plus large (0,2 sur un relevé plus court, arrondi), soit 5 à 9 fenêtres pleines par semaine

Pour mesurer avant un gros lancement :

  1. relever /usage ;
  2. lancer les trois premières tâches ensemble ;
  3. relever de nouveau, et diviser l'écart par trois.

Une seule tâche coûte trop peu pour l'arrondi de /usage.


8. Ce qui reste à vérifier


9. Questions pour ton Claude

  1. Le crochet en code 2 laisse-t-il passer une voie de blocage : un outil MCP, un Edit sous règle ask, un WebFetch à demande de permission ? Faut-il élargir le matcher ?
  2. La file enFile (des Promise.all sur des « ouvriers ») pose-t-elle problème au runtime des workflows : concurrence, ordre de démarrage, affichage de la progression ?
  3. Lots par l'outil Write ou JSONL par ajout du shell : sur macOS ou Linux, l'ajout par >> serait-il plus simple et aussi sûr ?
  4. Peut-on détecter de l'intérieur l'approche de la limite (un crochet StopFailure sur rate_limit, par exemple) pour arrêter proprement entre deux tâches, plutôt que subir la coupure ?
  5. Une définition d'agent avec omitClaudemd (v2.1.271) pour les agents de collecte : combien de tokens économisés, et quelles règles faut-il alors remettre dans la consigne ?
  6. La granularité de 10 à 20 minutes par tâche est-elle la bonne, compte tenu du ticket d'entrée de chaque agent et du cache de prompt partagé ?