Appearance
Le runner d'agents
Une tâche cron sur le VPS lit les issues GitHub étiquetées agent:ready, demande à Claude de les implémenter, demande à un second Claude de relire le résultat, puis s'arrête. Il ne merge jamais. Un humain le fait.
Cette page explique comment il fonctionne et comment le piloter. Le script est la source de vérité — si cette page et ops/agent-runner/agent-runner.sh se contredisent, le script gagne et cette page est un bug. Le raisonnement derrière la liste d'outils autorisés vit à côté du script, dans ops/agent-runner/README.md.
État au 2026-07-30
L'entrée cron est installée et le fichier PAUSE est en place, ce qui signifie que chaque tick sort immédiatement sans rien faire. Le script a été vérifié au shellcheck mais n'a jamais tourné de bout en bout sur un vrai ticket. Le premier run est un test, avec un humain qui lit le log en direct.
Le flux d'étiquettes
quelqu'un écrit en prose
│
▼
agent:spec ──── snag-spec ────▶ agent:ready ─────┐
│ │
└──▶ agent:blocked │ le runner le ramasse
(questions pour │
un humain) ▼
agent:working
│
┌─────────────────────────────┴──────────────┐
▼ ▼
PR ouverte aucune PR ouverte
│ (l'agent a refusé)
snag-review │
│ ▼
▼ agent:blocked
agent:reviewed
│
▼
un humain mergeagent:working existe pour qu'un run mourant en plein ticket ne puisse pas amener le run suivant à reprendre le même ticket et à ouvrir une PR en double.
Un ticket qui finit en agent:blocked n'est pas un échec du système — c'est le système qui fonctionne. L'agent a trouvé quelque chose qu'il ne pouvait pas résoudre et l'a dit au lieu de deviner. Lisez son commentaire.
Un cycle, étape par étape
- Garde-fous. Sortir si
PAUSEexiste. Prendre unflockexclusif pour que deux runs ne se chevauchent jamais. Lire.env. Vérifier queghs'authentifie, et échouer bruyamment sinon. - Trouver du travail. Pour chaque dépôt de
AGENT_REPOS, lister les issues ouvertes étiquetéesagent:ready, les plus anciennes d'abord, plafonnées àAGENT_MAX_TICKETS_PER_RUN. - Nettoyer le checkout.
git reset --hard origin/mainpuisgit clean -fdavant chaque ticket. Le runner ne présuppose rien de l'état laissé par un run précédent. - Livrer. Re-étiqueter
agent:ready→agent:working, puis lancer Claude en mode headless avec le skillsnag-deliversur cette issue. - Retrouver la pull request. Chercher parmi les PR ouvertes celle dont le corps contient
Closes #<issue>. C'est par cette chaîne que le runner sait que l'agent a réellement ouvert quelque chose, et c'est pourquoisnag-deliverl'exige. - Relire. Lancer un second Claude avec le skill
snag-reviewsur cette PR. Le relecteur est lancé sansWriteet sansEdit— il ne peut pas corriger discrètement ce qu'il trouve, seulement le signaler. - Étiqueter le résultat.
agent:reviewedsi une PR existe, sinonagent:blockedavec un commentaire portant l'identifiant du run.
Quels skills l'agent reçoit
Le runner fait un cd dans le checkout du dépôt avant d'invoquer Claude, donc Claude découvre le .claude/ propre à ce dépôt. Les skills sont par dépôt, pas globaux, et le prompt nomme celui qu'il veut au lieu de l'espérer :
Use the snag-deliver skill to implement GitHub issue #<n> in this repository
Use the snag-review skill to review pull request #<n>| Dépôt | .claude/skills/ |
|---|---|
poc-tcg-collector | snag-stack snag-spec snag-deliver snag-review |
tcg-collector-backend | les quatre mêmes, contenu différent |
documentation | aucun pour l'instant — voir Limites connues |
Les deux jeux ne sont pas des copies. Le snag-deliver du backend porte l'étape obligatoire de câblage d'un nouveau fichier de test dans npm test (le backend n'a pas de glob ; un fichier de test non câblé fait échouer tests/testWiring.test.mjs, volontairement). Celui du mobile porte le contrat de palette et les zones interdites, android/ et ios/.
Le CLAUDE.md du dépôt et le ~/.claude/CLAUDE.md global de l'opérateur se chargent également, si bien que la discipline de branche — commits conventionnels, jamais de push direct sur main — s'applique à l'agent et pas seulement à un humain.
Où tout se trouve
| Script | /home/kael/skn-tcg/tcg-collector-backend/ops/agent-runner/agent-runner.sh |
| Configuration | .env à côté, mode 600, gitignoré |
| Checkouts | /home/kael/skn-tcg/<dépôt> |
| Verrous, coupe-circuit, logs | /home/kael/.agent-runner/ |
| Log d'un run | /home/kael/.agent-runner/logs/<run-id>.log |
| Transcript Claude complet | …/logs/<run-id>-deliver-<dépôt>-<issue>.json |
| Sortie de cron | /home/kael/.agent-runner/cron.log |
Le dossier d'état est hors de tout dépôt, volontairement : rien de ce que l'agent écrit ne doit jamais pouvoir devenir un candidat au git add.
L'espace de travail s'appelle toujours skn-tcg alors que l'organisation est désormais snag-tcg. C'est délibéré — dix bind mounts répartis sur cinq conteneurs actifs passent par ce chemin, dont le volume de données Postgres. Voir l'historique des renommages.
La planification
*/20 8-20 * * 1-5 /home/kael/skn-tcg/tcg-collector-backend/ops/agent-runner/agent-runner.sh >> /home/kael/.agent-runner/cron.log 2>&1Toutes les vingt minutes, de 8h à 20h, du lundi au vendredi. Rien la nuit, parce qu'un run qui casse à 3h du matin reste là à être faux jusqu'au matin sans personne pour le remarquer.
Les huit binaires dont le script a besoin — node, npm, git, gh, claude, timeout, flock, jq — résolvent sous le PATH minimal de cron parce que tous les huit sont dans /usr/bin. C'est la panne classique du cron qui marche à la main et pas en planifié ; elle n'aura pas lieu ici.
Démarrer et arrêter
sh
touch /home/kael/.agent-runner/PAUSE # tout run futur sort immédiatement
rm /home/kael/.agent-runner/PAUSE # repriseCela prend effet au tick suivant sans toucher au crontab, et cela survit à un redémarrage. Si quelque chose semble anormal, c'est le premier geste ; le diagnostic vient ensuite.
Pour observer un seul cycle à la main avant de faire confiance à la planification :
sh
cd /home/kael/skn-tcg/tcg-collector-backend/ops/agent-runner
AGENT_MAX_TICKETS_PER_RUN=1 ./agent-runner.shSur un seul ticket jetable, dans un seul dépôt, avec un humain qui lit le log en direct. La panne que l'on découvre ainsi coûte une heure. Celle que l'on découvre par douze pull requests ouvertes un lundi matin coûte une journée.
Authentification
Le runner n'a pas besoin d'un token propre pour fonctionner. Deux voies :
GH_TOKENrempli dans.env— un PAT fine-grained appartenant à l'organisationsnag-tcg, limité aux trois dépôts, avec Contents, Pull requests et Issues en lecture/écriture, plus Checks et Commit statuses en lecture. Ces deux dernières permissions sont ce qui permet au runner de voir la CI ; sans elles l'API répond 403 et un build rouge peut se lire comme « aucun check en attente ».GH_TOKENvide — repli sur ce avec quoighest déjà authentifié sur la machine : un token OAuth créé par un login navigateur, stocké dans~/.config/gh/hosts.ymlen mode600, aux scopes larges et sans expiration.
Le log du run dit laquelle des deux est en jeu, sur sa première ligne. Le repli est acceptable pour un tour à blanc surveillé et faux pour le cron, parce que ce token est une personne, avec les droits admin, et que le runner en hérite entièrement.
Le git push en HTTPS passe par le même identifiant dans les deux cas : le credential helper git de cette machine est !/usr/bin/gh auth git-credential, et il honore GH_TOKEN.
N'installez jamais le token du runner avec gh auth login. Un token fine-grained ne cohabite pas avec le token OAuth d'un compte, il le remplace — s'y connecter détruirait le gh interactif fonctionnel de la machine, et s'il s'avérait ensuite qu'une permission manque, vous déboguerez sans aucun accès.
Ce qui vous protège réellement
Soyons lucides : le script vous protège très peu. Les couches, de la plus forte à la plus faible.
| Couche | Ce qu'elle arrête |
|---|---|
| Un humain merge | tout — et gh pr merge n'est dans aucune des deux allowlists, donc c'est le CLI qui l'impose, pas une convention |
| La liste d'outils autorisés | force push, suppression de branche, réécriture d'historique, lecture de secrets, réglages du dépôt, shell arbitraire |
Le relecteur privé de Write | un relecteur qui corrigerait en silence ce qu'il devait signaler |
| Un token GitHub restreint | les dégâts hors des trois dépôts |
block-main-commit.sh | un commit qui atterrit sur main — mais c'est un hook git, donc ne lancez jamais l'agent avec --bare |
AGENT_MAX_TICKETS_PER_RUN | une nuit qui s'emballe. Cela borne le désordre, cela ne l'empêche pas |
Le fichier PAUSE | tout, immédiatement |
| Les règles de rayon d'action des skills | android/, ios/, migrations, workflows, montées de dépendances — faible : un prompt est une consigne, pas un bac à sable |
Chaque entrée de l'allowlist est une sous-commande énumérée plutôt qu'un joker, parce que les jokers qui paraissent étroits ne le sont pas : Bash(gh *) inclut gh pr merge, gh secret set et gh repo delete. Avec ce joker, « il ne merge jamais » reposerait sur un prompt qui demande gentiment. Énuméré, cela repose sur le refus du CLI.
Limites connues
- Le script n'a jamais tourné de bout en bout. Écrit et vérifié au shellcheck, pas exercé sur un vrai ticket.
- Le quota Claude est partagé. Le VPS est connecté sur un plan Max 5x, que deux tickets par run ne mettent pas en tension — mais c'est le même quota qu'une session interactive ailleurs sur ce compte. Un rate limit inexpliqué en milieu d'après-midi est une raison de lire le crontab avant d'accuser autre chose.
- Aucun plafond de coût.
claude --max-budget-usdn'existe que pour les utilisateurs à clé API ; sur un abonnement il n'y a rien à plafonner. Le plafond de tickets et la fenêtre cron sont les seules bornes. documentationn'a aucun skill. Sans effet aujourd'hui puisqu'il n'est pas dansAGENT_REPOS, mais l'y ajouter sans avoir d'abord écrit son.claude/skills/lancerait un agent sans garde-fous et échouerait au premier prompt.- Le runner ne vérifie pas qu'un ticket appartient au dépôt où il a été déposé. C'est à
snag-specd'aiguiller. Un ticket mobile sur le backend sera refusé, mais seulement après avoir brûlé un run. - Les skills personnels de l'opérateur sont visibles aussi.
~/.claude/skills/est sur le chemin etSkillest dans l'allowlist, donc l'agent peut invoquertdd,prototypeet les autres. Risque faible, un skill ne s'activant que nommé et le prompt en nommant un — mais à surveiller sur les premiers runs.