Un 20 mai, j’ai corrigé cinq fois le même bug en vingt-quatre heures. Cinq commits, cinq fois la certitude d’avoir gagné, cinq fois le même défaut qui resurgissait un cran plus loin. Au cinquième, je me suis arrêté net : je ne corrigeais plus rien, je promenais le problème d’un module à l’autre. Ce que je prenais pour cinq bugs était une seule et même chose : une machine à états que je n’avais écrite nulle part. Et ce piège rattrape tout programme qui jongle avec plusieurs natures de tâches, pas seulement le mien.
Forge est la pièce qui me permet de faire développer Granit, ma plateforme de surveillance, par des agents d’IA presque sans moi. L’idée tient en une phrase : je dépose mes tickets sur GitLab (une simple liste de tâches à faire) comme on remplit une liste de courses, et Forge tourne en boucle pour les confier à des agents Claude qui écrivent le code à ma place. Mon rôle se réduit aux deux bouts de la chaîne, décrire le travail et valider le résultat ; tout ce qu’il y a entre les deux, c’est lui qui s’en charge.
Concrètement, à chaque tour de boucle, Forge interroge GitLab, retient les tickets prêts à être développés, et pour chacun ouvre un worktree (un atelier isolé, une copie du dépôt dédiée à ce seul ticket) dans lequel il lance un agent autonome. L’agent code, propose ses modifications, puis s’arrête ; un second programme, le relecteur, vérifie que les tests passent et fusionne le travail si tout est vert. Une seule contrainte encadre la fête : pas plus de six ateliers ouverts en même temps, sinon ma machine sature.
Sélection
- GitLabtickets ready-to-dev
- Forgepoll, classement, budget
Exécution
- worktreebranche isolée
- agent Claudeautonome, écrit la MR
Validation
- merge requestproduite par l'agent
- pipelinetests automatiques
Intégration
- mr-reviewerfusionne si vert
Les états d’un ticket
Avant d’en venir au piège, voyons par quels états passe un ticket : c’est de là que tout part.
Le chemin normal se lit de gauche à droite. Un ticket prêt à développer est pris par un agent, qui passe en cours ; l’agent ouvre sa proposition de modification, le ticket passe à relire ; les tests verts, il est fusionné. Quatre états, une ligne droite.
Sauf que deux retours en arrière cassent la ligne droite, et ce sont eux le sujet. Un agent qui meurt en route laisse un ticket en cours sans personne dedans : il faut le détecter et le remettre en file. Une proposition qui pourrit à relire sans jamais avancer doit être rejouée. Ce sont ces deux flèches rouges que l’ordonnanceur doit traiter. C’est sur elles que je butais.
Côté code, le classifieur ne raisonne pas en labels GitLab mais en trois modes : un ticket sans atelier est neuf, un atelier déjà ouvert est une reprise, un atelier armé par le relecteur est une réparation. Ce sont ces trois modes qui décident des droits de chacun.
Le problème
À chaque tour, Forge doit décider quels tickets lancer. Si tous se ressemblaient, ce serait trivial : je les prendrais dans l’ordre jusqu’à remplir les six ateliers. Mais dans la vraie vie, le travail ne se présente jamais sous une seule forme.
Car un agent ne finit pas toujours du premier coup. Tantôt il est interrompu en plein milieu (un délai dépassé, un plantage, ou moi qui reprends la main sur son atelier) : le ticket n’est plus neuf, il est à reprendre là où l’agent s’était arrêté. Tantôt son code part en relecture mais échoue aux tests : le ticket n’est ni neuf ni à reprendre, il est à réparer. Forge jongle donc en permanence avec trois natures de travail, les tickets neufs, les reprises et les réparations, et ces trois-là n’ont pas les mêmes droits.
C’est le budget qui les départage, et c’est lui qui tranche. Ouvrir un ticket neuf coûte un atelier, et il n’y en a que six : passé ce plafond, Forge doit cesser d’en ouvrir. Mais une reprise ou une réparation, elle, ne réclame aucun atelier nouveau : elle repart d’un atelier déjà ouvert. Les soumettre au même plafond que les tickets neufs n’a aucun sens.
Trois natures de tâches, trois politiques de budget6 gates
- Aucun worktree associé
- Création de worktree, donc soumis au budgetle seul état qui consomme un slot
- Worktree existant, ticket toujours in-progress
- Repris hors budgetréutilise le worktree, ne crée rien
- Worktree armé par le mr-reviewer
- Toujours retenu, prioritaireune pipeline rouge ne fait pas la queue
Seul un ticket new crée un worktree, et tombe donc sous le budget. Les deux autres réutilisent un worktree existant : les soumettre au budget n'a aucun sens.
La version initiale ne les distinguait pas. Le budget des tickets neufs s’appliquait à tout le monde ; une fois la limite de six atteinte, l’ordonnanceur recalait aussi bien les reprises que les réparations, alors qu’elles occupaient déjà leur atelier et ne demandaient aucune place nouvelle. Un ticket déjà en cours se retrouvait expulsé de son propre chantier.
La cause : une machine à états implicite
Ces trois natures de tâches forment une machine à états : un ticket y occupe une situation précise, qui détermine la politique à lui appliquer. Le problème n’est pas qu’elle existait, mais que je ne l’avais écrite nulle part. L’état n’était pas une donnée portée par le ticket ; chaque module le recalculait dans son coin, à partir d’indices différents.
Le même état, reconstruit à trois endroits6 gates
- Regarde si le worktree manque sur le disque
- Si oui, pour lui c'est un neufet il lui applique le budget
- Regarde si un worktree tourne sans agent vivant
- Si oui, pour lui c'est un orphelin à nettoyer
- Regarde si le ticket est marqué in-progress
- Si oui, pour lui c'est une reprise
Trois prédicats indépendants, chacun avec sa lecture du même ticket. Le jour où ils divergent, les politiques de budget entrent en collision et la reprise se fait recaler.
Le même ticket pouvait être tenu pour un ticket neuf par le filtre de budget et pour une reprise par le détecteur de reprise. Aucune de ces lectures n’était la source de vérité, parce qu’il n’y en avait aucune. C’est la définition même d’un état implicite : tant qu’il n’est pas matérialisé en un point unique, chaque module en invente sa version, et rien ne garantit qu’elles s’accordent. Trois personnes regardent chacune par sa fenêtre et se disputent pour savoir s’il faut le parapluie : aucune ne regarde la même chose, et leurs verdicts se contredisent.
En pratique, le même état se devinait à trois endroits, à partir de trois indices différents :
// dans le filtre de budget : worktree absent → c'est un "new"
const isNew = !fs.existsSync(worktreePath(iid));
// dans le reclaim, ailleurs : worktree sans agent vivant → orphelin
const isOrphan = worktreeExists(iid) && !hasLiveAgent(iid);
// dans le détecteur de reprise, ailleurs encore : ticket in-progress → reprise
const isResume = inProgress.has(iid) && worktreeExists(iid);
Trois lectures, trois vérités possibles pour le même ticket, et rien qui les oblige à s’accorder.
Trois fenêtres sur le même ticket, et pas une seule vérité.
Cinq correctifs symptomatiques
N’ayant pas vu la cause, j’ai traité les symptômes au fil de l’eau, cinq commits le même jour. Prenons le deuxième. Un cycle plus tôt, une reprise s’était fait expulser de son atelier par le plafond de budget ; j’ajoute donc une exception nette : un ticket qui occupe déjà un atelier ne compte plus dans le quota. Vert, testé, poussé, soulagé. Au cycle suivant, le même ticket disparaît de nouveau, sauf que cette fois c’est le nettoyage des chantiers abandonnés qui l’a balayé, parce que lui ignorait tout de mon exception. J’avais bouché un trou, l’eau passait par le suivant.
Les cinq correctifs ont tous cette forme. Au cinquième, le constat s’imposait.
Je ne corrigeais rien. Je déplaçais le défaut d’un module à l’autre.
C’est la signature d’un état implicite : aucun patch local ne tient, puisque les autres modules continuent d’ignorer la correction.
La résolution : rendre l’état explicite
La refonte décisive tient en un seul geste : déplacer la décision en amont, en un point unique. Une première étape de classement calcule désormais l’état de chaque ticket avant toute autre opération, et l’attache au ticket, qui le transporte intact jusqu’au bout de la chaîne.
L’état devient un type, et le classement la seule autorité qui le décide :
type TicketMode =
| { kind: 'new' }
| { kind: 'idle-resume'; worktreePath: string }
| { kind: 'armed-fix-ci'; worktreePath: string; promptPath: string };
function classify(iid: number, wt: string): PlanItem {
if (!fs.existsSync(wt)) return { iid, mode: { kind: 'new' } };
if (fs.existsSync(armedPath(wt)))
return { iid, mode: { kind: 'armed-fix-ci', worktreePath: wt, promptPath: armedPath(wt) } };
if (inProgress.has(iid)) return { iid, mode: { kind: 'idle-resume', worktreePath: wt } };
return { iid, mode: { kind: 'new' } };
}
En aval, l’ordonnanceur ne touche plus jamais au disque. Il se contente de lire l’étiquette, item.mode.kind === 'new', et le compilateur refuse désormais qu’on oublie un cas.
Classification
- classifiercalcule l'état
Plan
- PlanItem { state }source de vérité typée
Ordonnancement
- schedulerbudget selon l'état
Exécution
- executorlit l'état, n'en déduit rien
L’ordonnanceur ne fait plus que lire cette étiquette : il n’applique le budget qu’aux tickets neufs, et retient les reprises et les réparations d’office. Avant, Forge ne transmettait qu’une liste de numéros de tickets, que chaque étape suivante devait reclasser à l’arrivée. Désormais l’état voyage avec le ticket, décidé une seule fois pour toutes. Plus aucune reclassification en aval, donc plus aucune divergence possible.
Surtout, chacun des cinq cas du 20 mai est devenu un test : si l’un d’eux réapparaît un jour, la suite le rattrape avant qu’il ne reparte en production.
Les cas limites tombent juste
Une machine à états explicite règle un problème de fond : les situations pénibles cessent d’être des cas particuliers. Les trois qui me donnaient du fil à retordre se règlent désormais toutes seules, par construction.
Une reprise repart de son atelier hors budget, et l’agent continue là où il s’était arrêté plutôt que de tout recommencer ; l’atelier n’est jamais recréé, ni confondu avec un neuf. Une réparation passe prioritaire et exemptée de budget : un échec de tests ne fait jamais la queue derrière des tickets neufs : Forge répare avant d’ouvrir autre chose. Et quand les six ateliers sont pleins, Forge ne gèle que l’arrivée de tickets neufs ; les reprises et les réparations, qui ne réclament aucune place, passent toujours. La version implicite, elle, bloquait tout sans distinction.
Aucun de ces comportements n’est codé comme une exception. Tous découlent d’une seule règle : la politique de budget est indexée sur l’état.
Le prix de l’explicite
L’approche n’est pas gratuite. En centralisant la décision, le classement devient l’unique autorité : s’il se trompe, tout en aval se trompe avec lui, sans recours. Et l’état doit être exhaustif d’avance ; le jour où surgit une quatrième nature de tâche, il faut l’ajouter au type, puis repasser partout où on le lit. La contrepartie, c’est justement que le compilateur m’y oblige : tant qu’un cas n’est pas traité, le code ne compile pas. J’ai troqué des bugs silencieux à l’exécution contre des erreurs à la compilation. J’oublie un cas, le code ne part même pas : je l’apprends sur ma machine, pas en production.
Ce que j’en retiens
Un ordonnanceur qui arbitre plusieurs natures de tâches a une machine à états, qu’il l’écrive ou non. La question n’est pas de savoir s’il faut la modéliser, mais où. Laissée implicite, elle se reconstitue à chaque décision, et ces reconstructions finissent par diverger.
Transporter l’état avec le ticket, au lieu d’une liste de numéros, a réglé d’un coup ce que mes cinq correctifs n’avaient fait que déplacer. La divergence n’est plus repoussée plus loin : elle ne peut plus se produire.
Enfin, chaque transition mérite son test. Un bug d’ordonnancement ne se reproduit pas à la demande, il faut le figer dès qu’on l’a compris. Sans ce filet, je n’oserais pas le laisser tourner la nuit (et un ordonnanceur, ça travaille surtout la nuit).