Repository navigation
fix(guard,#13475): invariant 2 ne rougit que sur un prev: abandonne, plus sur un predecesseur en vol - #15211
Conversation
…plus sur un predecesseur en vol Grain: MED/guard -- lane myia-ai-01:CoursIA -- prev: MED/docs #15207 `resolve_prev_targets` recuperait un `state` a trois valeurs (OPEN / CLOSED / MERGED) et l'ecrasait dans `merged: bool`. `validate_prev_targets` etait donc structurellement incapable de separer « predecesseur encore en vol » de « predecesseur abandonne » : les deux rendaient `merged: false`, et les deux rougissaient. Le premier des deux est le comportement que R1 de proactive-coordination EXIGE (« une PR livree ne clot pas la session, re-pioche IMMEDIATEMENT ») : une lane qui ouvre sa PR suivante avant que la precedente ne merge suit la consigne, et ce garde la rejetait pour ca. Mesure du 2026-09-08 13:20Z, en rejouant l'organe sur les vrais corps et messages de commit : 5 PRs ouvertes bloquees (#15156, #15190, #15207, #15209, #15210) citant 4 predecesseurs distincts (#15129, #15175, #15199, #15203) -- les 4 OPEN, aucun abandonne. Apres reparation, les 5 passent. #15175 garde son `prev-self` (il se cite lui-meme) : le vrai defaut n'est pas touche, seul le doublon `prev-not-merged` qui le redoublait disparait. Le temoin que #13475 citait pour l'invariant 2, #13473, a MERGE le 2026-08-29 sans que personne n'y touche -- un defaut qui se resout quand son predecesseur atterrit est un etat transitoire, pas une lignee cassee. C'est aussi la doctrine que `variation_adjacency_guard.py` tient depuis #11963 : les deux organes lisent le meme champ, ils le lisent desormais pareil. - `state` remonte jusqu'a `validate_prev_targets` ; `merged` reste emis pour les lecteurs du JSON mais n'est plus le predicat. - `prev-not-merged` -> `prev-abandoned` (aucun consommateur externe ne lit ce nom : verifie par git grep sur scripts/, .github/, docs/, .claude/). - Deux abstentions deliberees : cible non resolue (contrat FN-safety inchange) et meta PR sans `state` (legacy) -- on ne peut pas deviner OPEN vs CLOSED. Tests : 49 (+9). Le point delicat est que QUATRE tests de masquage utilisaient une cible OPEN comme mecanisme de blocage pour prouver le masquage ; sous le nouveau predicat ils passeraient sans rien prouver. Leurs fixtures passent en CLOSED, ce qui leur rend leurs dents. Le quatrieme (test_fully_backticked_tag_is_still_evaluated) ne figurait pas au plan de patch : il a ete trouve en relisant la region plutot qu'en faisant confiance au plan, et c'est la seule raison pour laquelle il n'est pas devenu vert en silence. Controle de mutation des deux cotes : predicat trop permissif -> 7 rouges ; regression vers l'ancien predicat -> 3 rouges. See #13475
eadb062 to
da2a175
Compare
|
Une Pour passer ce gate, réécrivez le champ |
…lots ne part plus seul Grain: MED/tooling -- lane myia-ai-01:CoursIA -- prev: MED/guard #15211 persist/ai-01/coursia-runner.service declare 8 slots et aucun cap memoire. supervise.sh l.82 retombe alors sur son defaut 4g : 8 x 4096 = 32768 Mo contre un budget de slice de 12288 Mo. Le garde refuse, Restart=always reboucle, le pool ne monte jamais. Ce qui tient la machine debout est un drop-in 10-sizing.conf (4 slots, 1536m) qui etait UNTRACKED. persist/README.md marquait la copie 8-slots « a deployer » sans dire qu'un second fichier etait indispensable a cote : la deployer telle quelle reproduisait la panne. See #15091
…ôt n'écrase plus la borne mémoire vivante (#15214) * fix(ci,#15091): tracker le drop-in de dimensionnement ai-01 -- le 8-slots ne part plus seul Grain: MED/tooling -- lane myia-ai-01:CoursIA -- prev: MED/guard #15211 persist/ai-01/coursia-runner.service declare 8 slots et aucun cap memoire. supervise.sh l.82 retombe alors sur son defaut 4g : 8 x 4096 = 32768 Mo contre un budget de slice de 12288 Mo. Le garde refuse, Restart=always reboucle, le pool ne monte jamais. Ce qui tient la machine debout est un drop-in 10-sizing.conf (4 slots, 1536m) qui etait UNTRACKED. persist/README.md marquait la copie 8-slots « a deployer » sans dire qu'un second fichier etait indispensable a cote : la deployer telle quelle reproduisait la panne. See #15091 * fix(ci,#15091): le drop-in borne la memoire et laisse le CPU au defaut Le drop-in ai-01 pose COURSIA_RUNNER_MEMORY=1536m et ne pose RIEN sur le CPU. supervise.sh l.81 retombe donc sur CPUS="${COURSIA_RUNNER_CPUS:-3}" : les 4 slots demandent 4 x 3 = 12 vCPU contre un plafond de 8, et le service est `failed` (NRestarts=4) au moment de la mesure. Le README et l'en-tete du drop-in affirmaient que ce fichier « tient la machine debout » : c'est mesurablement faux, sur un axe que la mesure precedente ne regardait pas. Corrige aussi une causalite que j'avais avancee sans preuve dans la premiere version de cette correction : j'attribuais l'ecart matin/apres-midi a l'ordre de demarrage des familles. Le journal la refute -- les waiters etaient deja a n=4 pendant les trois demarrages reussis du matin -- et l'arithmetique la refute deux fois, puisque 4 x 3 = 12 depasse deja 8 sans aucun waiter. Le mecanisme reel est l'armement d'un garde jusque-la inerte (CPU_BUDGET=0 sort en silence) par le deploiement du wrapper a 09:47:21 CEST, au-dessus d'une sur-souscription qui existait deja. Retire par la meme occasion des details personnels sur un proche du user (age, lieu) qui n'apportaient rien au diagnostic et n'ont pas leur place sur un depot public ; le fait technique -- quatre redemarrages manuels sur site -- est conserve. See #15091 Co-Authored-By: Claude-Code <noreply@anthropic.com> * fix(ci,#15091): la copie du depot allait retirer la borne memoire vivante Trois affirmations de persist/README.md etaient fausses. Mesure firsthand sur ai-01 le 2026-09-08 via `wsl.exe -d Ubuntu -u root --` : - `coursia-ci.slice` y etait dit « a deployer (une version ad-hoc de 283 octets, sans documentation) ». Le fichier vivant fait 7235 octets, est documente, et portait MemoryHigh=12G / MemoryMax=16G / MemorySwapMax=16G que la copie du depot (4057 octets, `MemoryAccounting=yes` seul) n'avait pas. - `daemon.json` y etait dit « n'existe pas encore ». Il existe : 155 octets, byte-identique a persist/daemon.json (sha256 1ef80038e470...). - docker-ce y etait dit porter « 0 conteneur ». Il porte les quatre `myia-ai-01-linux-waiter-{1..4}`, et rien d'autre. Consequence de la premiere : l'etape 1 du bloc de deploiement disait `install -m 0644 persist/coursia-ci.slice /etc/systemd/system/coursia-ci.slice` sans condition. Executee, elle aurait remplace le fichier vivant par celui du depot -- donc retire la borne memoire de la machine, la protection exacte que le mandat demandait de poser. Ce que ce commit fait : - persist/coursia-ci.slice synchronise DEPUIS le vivant. Le depot en etait un sous-ensemble strict : `git diff --numstat` rend `56 0`. La seule ligne qui differe encore est un commentaire, neutralise cote depot parce qu'il nommait une personne privee ; le fait technique -- quatre redemarrages manuels sur site dans la meme journee -- est conserve. - Etape 1 du deploiement : `install` inconditionnel -> `diff` prealable, les deux `install` laisses en commentaire. - Les deux cellules de la table de correspondance passent a « deploye et vivant » avec leur mesure. - Le paragraphe « 0 conteneur » est corrige et dit ce qui n'a PAS ete verifie : `live-restore: true` est declare, sa tenue au restart n'a pas ete testee ici. - Nouvelle correction 6, section renommee « les six corrections ». Retractation portee dans le meme geste. Le README ecrivait « les 16 vCPU etaient demandes et servis en silence », et le drop-in de reference « 16 tournaient donc ». Les deux sont trop forts : `coursia-ci.slice` porte `CPUQuota=800%` (cpu.max 800000 100000) et etait armee tout du long -- la famille aurait demande 16 vCPU et le noyau lui en aurait servi 8, avec throttling. Le garde manquant a retire le refus LISIBLE, pas le plafond. Et `nproc` = 32 sur cette machine : 8 est le budget consenti a la CI, pas la taille du processeur. La sur-souscription reste un vrai defaut -- elle etrangle en silence au lieu de refuser franchement. Aucun fichier vivant n'est touche par ce commit. See #15091 Co-Authored-By: Claude-Code <noreply@anthropic.com> * docs(ci,#15091): bloc de deploiement non executable, drop-in inclus, ~81 Go requalifies Trois points de review de coursia-1d sur #15214, verifies puis traites. 1. L'etape 2 installait `coursia-runner.service` et son wrapper, mais PAS le drop-in `10-sizing.conf`, alors que la prose annonçait les trois ensemble. Sur un hote neuf, la famille serait repartie sur les defauts de `supervise.sh` (cpus=3, memory=4g) sans que rien ne le signale. Le `mkdir` et l'`install` du drop-in sont ajoutes. 2. Le bloc se lisait comme une procedure sure a copier-coller alors que son `systemctl restart docker.service` coupe les quatre waiters -- le seul etage du parc encore vivant -- sur un `live-restore: true` non teste firsthand. Regle de lecture posee en tete du bloc : ce qui LIT reste vivant, ce qui ECRIT est commente. Motif ecrit et mesure : sous `COURSIA_RUNNER_CPU_BUDGET=8` avec 4 waiters residents (4 x 1 = 4 vCPU), la famille `start` ainsi dimensionnee demande 4 x 3 = 12, soit 16 > 8, et `assert_cpu_budget` refuse le demarrage. C'est l'etat vivant de ai-01 au 2026-09-08 (`coursia-runner.service` en `failed`), pas une hypothese : ce bloc reproduirait le verrou sur un hote neuf. Le dimensionnement CPU n'est pas tranche ici -- il appartient a la lane qui porte le parc. 3. Le commentaire de `coursia-ci.slice` presentait les ~81 Go d'ecart hote (flotte CI armee vs desarmee) comme << l'empreinte de cette slice >>. C'est un avant/apres a deux cellules, sans controle sur les ~48 autres conteneurs ni sur la workstation : il rend un majorant OBSERVE, pas une empreinte causale mesuree. Requalifie, en disant que la borne posee ne repose pas sur ce chiffre. La copie du depot diverge desormais de la copie vivante en DEUX commentaires (la ligne neutralisee pour vie privee, et cette qualification) ; les deux sont declares dans la correction 6, et vont dans le sens depot -> vivant. Verifications : gitleaks v8.24.3 (pins concordants) rc=0 sur `persist/` et en `protect --staged`, avec controle positif valide (`ghp_` + 36 caracteres ALEATOIRES : le meme temoin a 36 fois 'A' rend rc=0, l'entropie nulle passant sous le seuil) ; detecteur de donnees privees rc=0 sur les 61 lignes ajoutees, valide sur son propre temoin ; 0 octet CR mesure sur les deux fichiers. Aucun fichier vivant n'a ete modifie. Aucun redemarrage, aucun deploiement. See #15091 * feat(ci,#15091): dry-run non mutant du controle de dimensionnement ai-01 + rollback Prepare, sans rien appliquer, le controle « un slot avant plusieurs » exige par le protocole #15095. Le script MESURE l'etat vivant, CALCULE l'arithmetique du garde de budget, CAPTURE un rollback a partir des octets vivants, et IMPRIME les commandes d'application en commentaire -- il n'en execute aucune. Toutes ses ecritures sont confinees au repertoire de bundle ($OUT, sous /var/tmp par defaut) : rien dans /etc, rien dans /usr/local/bin, aucun systemctl autre que `show` / `is-active`, aucun redemarrage docker ou WSL, aucun quota de slice touche. Verifie par un detecteur a quatre temoins positifs et trois controles negatifs (le motif `dd\b` attrapait d'abord le « dd » de `add` : ancre gauche ajoutee). Parametres par defaut, mesures sur ai-01 le 2026-09-08 : SLOTS=1, CPUS=2 -- 1 x 2 (start) + 4 x 1 (waiters) = 6 / 8 vCPU, passe le garde, la ou la configuration vivante demande 4 x 3 + 4 x 1 = 16 > 8 et se fait refuser. La MEMOIRE n'est deliberement PAS un parametre : elle est reconduite a sa valeur vivante (1536m), pour qu'une seule variable bouge -- un avant/apres a deux variables n'attribue rien. Le script porte aussi trois pieges d'instrument mesures ce jour, pour que la prochaine lane ne les repaye pas : - depuis Ubuntu, le CLI `docker` (meme via /var/run/docker.sock) atteint Docker Desktop, PAS docker-ce : seule la surface cgroupfs est autoritative sur le parc de runners ; - `memory.events` est hierarchique et `memory.events.local` ne l'est pas -- la slice a `local max = 0` alors que l'agregat compte 640 751 hits, tous dus a quatre enfants plafonnes a 512 Mio : la pression est PAR CONTENEUR, pas au plafond agrege de 16 Gio, qui n'a jamais ete approche ; - `uptime -s` est 3 min 12 s en retard sur cette machine (192 s perdus par /proc/uptime, 419 evenements « Clock change detected » dans le seul boot courant) : un boot se date par `journalctl --list-boots`. Enfin, il documente que le chemin rc=0 de slot_loop() est un `sleep 2` FIXE (supervise.sh sur main, l.588) : un cycle de conteneur court est le regime NOMINAL d'un runner ephemere, pas une pathologie. Le discriminant a relever est « cycle court ET aucun job reclame », avec jobs.runner_name cote GitHub comme seule attribution valable -- un rc conteneur nul ne prouve pas un job reussi. See #15091 See #15095 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ci,#15091): le controle n'est pas univarie -- deux facteurs de capacite bougent Review de coursia-1d, acceptee sans reserve : « Deux parametres de capacite changent, slots et CPU par slot, meme si memoire inchangee : eviter "une seule variable" causal. » Le script affirmait qu'« une seule variable bouge a la fois ». C'est faux, et c'est precisement le piege des cellules confondues que ce meme script invoque ailleurs : le controle deplace le nombre de slots (4 -> 1) ET le cap CPU par slot (3 -> 2). Reconduire la memoire retire une TROISIEME variable du plan, ca ne rend pas le plan univarie. Ce que le controle peut encore trancher est ecrit explicitement, et c'est un BINAIRE : la famille franchit-elle le garde et demarre-t-elle, un slot unique reclame-t-il un job. Ces deux reponses ne demandent aucune attribution. Tout jugement de DEBIT tire de ce controle serait confondu -- le script dit de ne pas en tirer un. Seconde review acceptee : les compteurs de `memory.stat` / `memory.events` sont CUMULATIFS depuis la creation du cgroup et rendus ici sans fenetre temporelle. Ils etablissent une STRUCTURE -- la pression s'exerce au cap par conteneur, pas au plafond agrege, qui n'a jamais ete approche -- et rien de plus. Ils n'attribuent aucun hit a un incident et n'en etablissent pas la cause. Dater la pression demanderait deux releves horodates et leur difference ; ce script n'en prend qu'un, et le dit desormais. Aucun changement de comportement : le script reste non mutant (re-verifie, 4 temoins positifs, 3 controles negatifs, zero verbe mutant en position de commande, toutes les ecritures confinees a $OUT). See #15091 See #15095 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ci,#15091): le dry-run de dimensionnement echoue au lieu d'approuver a l'aveugle (#15214) Revue de coursia-1d sur c08713c. Les six points signales sont reels et verifies dans le code tel qu'il etait ecrit ; le plus grave etait un fail-OPEN dans un script dont toute la raison d'etre est de refuser. 1. Budget illisible rendait « passe ». `BUDGET="${BUDGET:-0}"` puis un predicat `(b>0 && t>b)` : budget absent -> 0 -> aucun total ne le depasse -> verdict permissif. Un dry-run dont le mode de defaillance est d'approuver est pire que pas de dry-run. Desormais fatal. En le corrigeant, une seconde source est apparue : le wrapper ecrit `${VAR:-8}`, « 8 SAUF si deja defini », et supervise.sh l.206 rend 0 par defaut. Un drop-in posant COURSIA_RUNNER_CPU_BUDGET=0 desarmerait donc le garde sans qu'un octet du wrapper change. Le budget se lit maintenant a l'Environment EFFECTIF d'abord, au defaut du wrapper ensuite, et la source retenue est imprimee a cote de la valeur. 2. Entrees non validees. --slots/--cpus exigent un entier de 1 a 64 ; une option sans valeur, un --out vide ou en -tiret sont refuses. 3. Drop-ins absents devenaient zero waiter et memoire implicite. Un waiter non compte SOUS-estime le total, donc rend le verdict faussement permissif -- meme classe de defaut que le point 1. Un compte non denombrable est fatal ; une memoire introuvable aussi, puisque l'omettre du drop-in propose la ferait retomber sur le defaut 4g de supervise.sh, c'est-a-dire deplacer en silence la variable que le controle pretend justement tenir fixe. 4. Configuration EFFECTIVE, plus un seul fichier. Le script interrogeait 10-sizing.conf en ignorant l'unite et les autres drop-ins. Il passe par `systemctl show` (ExecStart, Environment) et rend fatale l'injoignabilite de systemd -- c'est precisement la distinction fichier/processus qui a coute 6 h 35 le 2026-09-08. 5. Bundle fail-closed. Les erreurs de copie n'etaient que des `warn` et la course finissait « Bundle complet » exit 0. Toute copie echouee ou divergente arrete le script, et une section d'auto-verification recompare chaque entree PRESENT du manifeste avant de conclure. 6. Rollback borne aux cibles reellement changees. Il reinstallait des fichiers que le controle ne modifie pas, et ne savait pas restaurer un drop-in initialement ABSENT. Il ne porte plus que la CIBLE, conserve son mode, et quand la cible etait absente il la SUPPRIME (plus le repertoire .d s'il n'existait pas) au lieu de l'installer. ABSENT et ILLISIBLE sont distingues partout : le premier peut etre nominal, le second est toujours une panne d'instrument. Tests sur fixtures, sans machine reelle ni redemarrage : test-dryrun-sizing-control.sh, 43 cas, faux systemctl pilote par l'environnement. La moitie verifie que le script REFUSE. Le temoin de non-mutation compare l'empreinte sha256 de l'arbre fixture avant/apres et porte son propre controle positif -- sans lui, l'egalite ne prouverait rien. Deux defauts trouves en ecrivant ces tests, pas avant : des backticks dans un message `die` en guillemets doubles, qui executaient `$SYSTEMCTL show` par substitution de commande ; et un faux systemctl utilisant `${FX_WAIT_N:-4}`, ou une valeur vide retombait sur 4 et ne simulait donc jamais le cas -- le meme piege `:-` que le point 1. See #15091, #15214 Co-Authored-By: Claude-Code <noreply@anthropic.com> * fix(ci,#15091): borner les configurations supportees, executer le rollback en fixture Reponse a la review de coursia-1d sur 2c7c593. Le principe est le meme sur les quatre points : quand l'instrument ne sait pas etayer un verdict, refuser vaut mieux que rendre un verdict approximatif -- qui a l'air d'avoir marche. 1. Formes de configuration hors de portee -- REFUSEES, plus supposees absentes. « systemctl show -p Environment » ne rend que les Environment= inline ; le contenu d'un EnvironmentFile= est lu par systemd a l'execution et lui est invisible. Une valeur qui y serait posee serait lue comme ABSENTE, avec repli sur le defaut du wrapper : l'erreur irait dans le sens PERMISSIF. Meme classe pour une valeur quotee, qu'env_value() couperait sur les espaces. Les deux unites sont sondees, les deux formes refusees. 2. Ordre de fusion des drop-ins et directives perdues -- REFUSES. systemd fusionne par ordre lexicographique des noms : un drop-in lu APRES la cible peut la surcharger, et le verdict porterait alors sur une configuration qui ne s'applique pas. DropInPaths est enumere, le refus est nomme. Et si la cible vivante porte des directives que la proposition ne reconduit pas, les ecrire par-dessus les perdrait en silence : statuer sur leur sort est une decision deliberee, pas un effet de bord d'un controle de dimensionnement. 3. Rollback -- proprietaire capture et chown emis ; symlink, .d symlink et ACL etendues refuses. Un rollback qui restitue le seul mode sur une cible ACL-ee rend un fichier d'apparence correcte et de droits differents. 4. Le rollback est desormais EXECUTE en fixture (section J), plus grepe. Grepper le texte du script genere prouve qu'il CONTIENT des mots, jamais qu'il RESTAURE. L'executer a revele un defaut latent que la review n'avait pas nomme : TARGET_ABS servait a la fois de clef de bundle et de destination, si bien que sous une racine de test le rollback genere visait le VRAI /etc. Un rollback qui restaure ailleurs que la ou il a mesure est pire qu'absent. TARGET_DEST (destination reelle, telle qu'inspectee) est desormais distinct de TARGET_ABS (chemin canonique, clef du bundle). En production ROOT est vide : les deux coincident, comportement inchange octet pour octet. L'assertion de section F qui grepait « rm -f "/etc/... » encodait le chemin ROOT-strippe, donc le defaut lui-meme : corriger le bug la faisait echouer. Elle accepte maintenant la racine inspectee ; la verification forte vit en J1/J2, qui executent et constatent l'effet dans la fixture. Tests : 64 reussis, 0 echoue -- faux systemctl et faux chown uniquement (« stat -c %U:%G » rend MYIA:UNKNOWN sur Git Bash, que chown refuse : mesure faite, d'ou un faux chown qui ENREGISTRE la demande. Ce que cela prouve : le rollback demande la restitution du proprietaire tel que stat l'a releve. Ce que cela ne prouve pas : que chown reussisse ici -- sur la cible Linux il est reel). Aucun service reel touche, aucun redemarrage, aucun chemin /etc reel ecrit. See #15091 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ci,#15091): comparer ExecStart par valeur, borner l'homonymie au cas qui masque Trois defauts releves en contre-verification par la lane coursia-1d sur c9f3cfc, tous dans le garde lui-meme. 1. Le filtre des directives non reconduites faisait « grep -v ^ExecStart= », jetant TOUTE ligne ExecStart. Or la proposition emet « ExecStart= » (reset) puis son propre binaire : elle remplace la liste entiere. Une cible portant un autre binaire rendait donc un ensemble vide -- « rien n'est perdu » -- au moment meme ou tout l'etait. Le trou vivait dans le garde ecrit pour l'attraper. On compare desormais la VALEUR : seules la ligne de reset et l'invocation du wrapper a un unique argument sont tolerees ; autre binaire, arguments supplementaires et prefixes systemd font refuser. 2. La cible se reconnaissait a son basename, si bien qu'un homonyme vivant ailleurs etait saute comme s'il etait elle. Elle se reconnait maintenant a son chemin, compare hors racine d'inspection. 3. Le refus d'homonymie qui en decoulait etait trop LARGE, et c'est une verification independante qui l'a montre : a nom egal, c'est la racine de plus haute PRECEDENCE qui l'emporte, pas la derniere lue. Un homonyme en /run alors que la cible est en /etc est donc masque par elle -- refuser la aurait bloque une configuration saine. Seul l'homonyme d'une racine plus forte masque la cible : c'est lui, et lui seul, qui refuse. Racine inconnue au classement = traitee comme la plus forte, l'erreur va vers le refus. L'isolation du harnais n'etait vraie que si TMPDIR etait POSIX : sous un TMPDIR Windows les faux perdent sur PATH, le VRAI chown est appele et systemctl est absent -- 61/64, sans rien qui nomme la cause. La suite verifie desormais que le faux l'emporte reellement, et abandonne en le disant sinon. Les refus symlink et ACL restent NON couverts : sur cette plateforme « ln -s » rend une copie, donc « [ -L ] » est faux et le refus ne serait jamais atteint. Un test qui passe sans exercer ce qu'il nomme vaut moins que rien ; ils sont declares non exercables, comptes a part, et la suite le repete apres son verdict. Suite : 70 reussis, 0 echoue, 2 non exercables. Aucune machine touchee, aucun redemarrage, aucun /etc reel ecrit. See #15091 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ci,#15091): le marqueur de verdict ne s'imprimait jamais -- printf lisait '->' comme une option Trouve en executant le dry-run sur la machine VIVANTE (WSL Ubuntu, ai-01) : « printf: ->: invalid option ». Une chaine de format qui commence par « - » est lue comme une OPTION. La ligne rendait « actuel : TOTAL 16.00 / 8 » sans dire que c'est un REFUS -- l'instrument taisait son verdict. Ce n'est pas un ecart de plateforme : reproduit a l'identique en Git Bash 5.2.37 et en bash 5.2.21 sous Ubuntu. Trois assertions couvraient ce marqueur, et le defaut en faisait passer deux et mentir la troisieme : - « rendu comme REFUSE » grepait « 16.00 / 8 », c'est-a-dire l'ARITHMETIQUE, et s'intitulait d'un VERDICT ; - les deux « ne doit jamais imprimer -> passe » etaient vertes PARCE QUE le marqueur ne s'imprimait jamais. Elles l'auraient ete meme si la logique avait ete inversee. Une assertion negative ne vaut que pairee a un controle positif prouvant que la chaine PEUT apparaitre. Deux controles positifs ajoutes, valides par leurs faux negatifs : sur le code d'avant ils rendent KO (70/2/2), apres correctif ok (72/0/2). Verifie sur la machine reelle apres correctif, en lecture seule (bundle dans /tmp, aucune ecriture dans /etc, aucun redemarrage) : actuel : TOTAL 16.00 / 8 -> REFUSE propose : TOTAL 6.00 / 8 -> passe See #15091, #15228 * test(ci,#15091): K5/K6 decouples -- l aveu de non-couverture ACL disparaissait au lieu de se degrader Le « skip » K6 (refus sur ACL etendues) vivait dans le « else » du test de capacite K5 (symlinks). Sur une plateforme capable de liens reels, la non-couverture ACL ne se degradait donc pas : elle DISPARAISSAIT de la sortie. Mesure du 2026-09-08, meme suite, deux plateformes, scratch isole : Git Bash 5.2.37 : reussis=72 echoues=0 non-exercables=2 WSL Ubuntu : reussis=72 echoues=1 non-exercables=0 (sortie 1) occurrences de « K6 » dans la sortie WSL : 0 La ligne finale « ils restent NON couverts, ce n est pas un succes » ne pouvait alors plus les nommer, faute de compteur. Un aveu qui s efface est pire qu un aveu bruyant. Correctif : deux capacites, deux conditions, deux declarations. - K5a cible = lien symbolique -> refus (exerce sous WSL) - K5b repertoire .d = lien symbolique -> refus (second garde, [ -L cible ] FAUX) - K6a ACL etendue REELLE posee par setfacl -> refus, sinon skip DECLARE - K6b faux « ls » emettant « + » -> refus - K6c meme faux « ls » SANS « + » -> ne refuse rien (controle negatif) K6c est indispensable : un script qui refuserait TOUJOURS passerait K6b sans rien prouver -- meme famille de defaut que le marqueur printf. La capacite ACL se mesure par son EFFET (le « + » apparait-il dans ls -ld ?), jamais par la presence de setfacl : un setfacl present mais inoperant sur le systeme de fichiers du scratch rendrait un test vert qui n exerce rien. run_sut accepte un prefixe de PATH optionnel (FX_PATH_PREFIX). Non defini -- le cas nominal -- PATH est rendu inchange : aucun test existant ne change de comportement. « ls » n est appele qu a UN endroit du script de controle, donc le faux binaire est chirurgical. Apres correctif : WSL Ubuntu : reussis=76 echoues=0 non-exercables=1 (K6a declare) Git Bash 5.2.37 : reussis=74 echoues=0 non-exercables=2 (K5 + K6a declares) Valide par ses FAUX NEGATIFS -- chaque defaut reinjecte un par un dans le script de controle, en scratch jetable, avec garde d ancre (un sed sans effet est rapporte « ANCRE MANQUEE », jamais lu comme un succes) : garde symlink CIBLE retiree -> K5a ATTRAPE (75/1/1) garde symlink REPERTOIRE .d retiree -> K5b ATTRAPE (75/1/1) predicat ACL rendu inatteignable -> K6b ATTRAPE (75/1/1) predicat ACL rendu toujours vrai -> K6c ATTRAPE (53/16/1) dryrun-sizing-control.sh est INCHANGE par ce commit (sha256 inchange : d7d39e8ca0a23af14114400ce9cd997e4507a95ba1d74012f53397551763c780). Ce qui reste NON couvert et le reste explicitement : le refus sur une ACL etendue REELLE (K6a), faute du paquet « acl » sur le WSL Ubuntu d ai-01. Installer ce paquet est une operation sudo sur le serveur de flotte, non faite unilateralement. Acceptance B de #15228 : partiellement close. See #15091 See #15228 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: jsboige <jsboige@gmail.com> Co-authored-by: Claude-Code <noreply@anthropic.com>
…nre) (#15925) Reévaluation G.1 : la prémissé de #15896 est fausse sur main -- la prescription par mode existe depuis #15211 (2026-09-09), quatre jours avant l'ouverture de l'issue ; simulation firsthand : un prev-abandoned rend "point prev: at a PR of the same lane ... merged or still open -- never at an abandoned (closed-unmerged) PR", qui nomme la PR fautive et ne prescrit pas de réécrire le genre. L'incident #15869 a été réparé par ce chemin (REPAIR c.522, body édité vers prev: DEEP/infrastructure #15839, gardes vertes). Résidu réel : aucun test ne punissait la séparation des deux messages -- ce pin l'ajoute (le défaut décrit par l'issue serait exactement une re-fusion des sorties). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…nre) (#15925) Reévaluation G.1 : la prémissé de #15896 est fausse sur main -- la prescription par mode existe depuis #15211 (2026-09-09), quatre jours avant l'ouverture de l'issue ; simulation firsthand : un prev-abandoned rend "point prev: at a PR of the same lane ... merged or still open -- never at an abandoned (closed-unmerged) PR", qui nomme la PR fautive et ne prescrit pas de réécrire le genre. L'incident #15869 a été réparé par ce chemin (REPAIR c.522, body édité vers prev: DEEP/infrastructure #15839, gardes vertes). Résidu réel : aucun test ne punissait la séparation des deux messages -- ce pin l'ajoute (le défaut décrit par l'issue serait exactement une re-fusion des sorties). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Grain: MED/guard — lane myia-ai-01:CoursIA — prev: MED/docs #15207
L'invariant 2 de #13475 rougissait sur deux situations qui n'ont rien à voir, parce que le résolveur écrasait un
stateà trois valeurs dans un booléen.Le défaut, en une ligne de code
resolve_prev_targetsinterrogeaitgh pr view --json state— doncOPEN/CLOSED/MERGED— puis n'en gardait quemerged = (state == "MERGED").validate_prev_targets, qui ne voit que ce booléen, était structurellement incapable de séparer :MERGEDOPENCLOSEDLes deux derniers rendaient
merged: false. Les deux rougissaient.Pourquoi c'est le mauvais des deux qui rougissait le plus
Rougir sur
OPENpunit exactement le comportement que R1 de proactive-coordination.md impose :Une lane qui ouvre sa PR suivante avant que la précédente ne merge suit la consigne. Ce garde la rejetait pour ça.
C'est aussi la doctrine que
variation_adjacency_guard.pytient déjà depuis #11963 : « le champprev:est gelé à l'ouverture de la PR, donc une lane qui merge des grains pendant que sa PR attend faisait bloquer par le gate des PRs que la règle ne visait pas ». Les deux organes lisent le même champ ; ils le lisent désormais pareil.Et le témoin que #13475 citait pour l'invariant 2 — #13473, tagué
prev: … #13465alors que #13465 était OPEN — a mergé le 2026-08-29 sans que personne n'y touche. Un défaut qui se résout quand son prédécesseur atterrit est un état transitoire, pas une lignée cassée.Mesure — l'organe rejoué sur les vrais corps
Mesuré le 2026-09-08 à 13:20Z, en rejouant
variation_prev_guard.py(versionorigin/mainpuis version de cette PR) sur les vraisbody+commitsrécupérés pargh, avec--current-pret--resolve-targets:prev-not-merged → 15129(body +commits[0])prev-not-merged → 15175prev-not-merged → 15203prev-not-merged → 15199prev-not-merged → 15175(body +commits[0])prev-self+prev-not-merged(les deux sur 15175)prev-selfseul, toujours rougeLes quatre prédécesseurs cités — #15129, #15175, #15199, #15203 — sont tous OPEN, vérifiés un par un. Aucun n'est abandonné.
#15175 est le contrôle négatif du lot : il se cite lui-même, c'est un vrai défaut, et la réparation ne le débloque pas. Elle retire seulement le doublon — l'invariant 2 tirait une seconde fois sur la même auto-référence.
Correction due sur un chiffre que j'ai avancé
Une mesure antérieure de ce même lot annonçait six PRs, en incluant #15200. Ce chiffre était juste au moment où il a été pris et n'est plus reproductible : le corps de #15200 a été remplacé par
...(3 caractères) à 2026-09-08T13:11:52Z, ce qui a supprimé sa ligneGrain:et avec elle toute clauseprev:. Sous les deux versions du garde, #15200 passe désormais.Je l'inscris — dans le docstring et ici — plutôt que de corriger le chiffre en silence : un corps qui bouge sous une mesure de contenu est précisément ce qui fait passer cette mesure pour fausse après coup. Effet de bord à traiter à part : #15200 est maintenant un grain-orphelin, invisible à tous les gardes de lane (GRAIN-ORPHANS-SWEEP #13086).
Cette PR est elle-même le sixième cas
En rédigeant le tag j'ai d'abord écrit
prev: MED/docs #15204— le numéro de l'issue que ma PR précédente traite, au lieu de la PR elle-même. C'est un vrai défaut (prev-not-pr), que les deux versions du garde rejettent, et je l'ai corrigé en#15207sur les deux surfaces (corps et message de commit — le garde lit les deux, réparer l'un seul laissecommits[0]rouge).Or #15207 est OPEN. Cette PR est donc, sans l'avoir cherché, une instance du défaut qu'elle répare — et le contrôle positif vit dans l'artefact plutôt que dans un fixture :
origin/main(ancien)guard_pass: false—prev-not-merged → [15207], surbodyetcommits[0],rc=1guard_pass: true—no prev: defect,rc=0La dépendance à nommer :
always-on-guards.ymlse déclenche surpull_requestet fait unactions/checkoutsansref:, donc il extraitrefs/pull/N/merge— le garde qui juge cette PR est celui de cette PR. Si le workflow lisait la version de la base, cette PR serait rouge sur son propre sujet et il faudrait pointerprev:vers une PR mergée pour l'ouvrir. Ce n'est pas une remarque théorique : c'est la condition qui rend le tableau ci-dessus vrai, et elle se lit dans le workflow, pas dans mon intention.Ce que la PR change
stateremonte jusqu'àvalidate_prev_targets, qui ne rougit plus que surCLOSED.mergedreste émis par le résolveur pour les lecteurs du JSON, mais n'est plus le prédicat.prev-not-merged→prev-abandoned. Le renommage est sûr :git grepsurscripts/,.github/,docs/,.claude/ne trouve aucun consommateur qui lise ce nom de kind — les seules références sont au chemin du module.prsansstate(fichier--prev-targets-fileécrit à la main ou d'avant ce champ) — on ne peut pas devinerOPENvsCLOSED, donc on s'abstient.Aucun workflow ne change.
always-on-guards.yml(l.704) passe déjà--current-pr --resolve-targets;variation-tag-guard.yml(l.606) n'en passe aucun des deux et ne couvre donc que l'invariant close-keyword — hors périmètre.Tests — 40 → 49, et le point délicat
Le point délicat n'est pas d'ajouter des tests, c'est que quatre tests de masquage utilisaient une cible OPEN comme mécanisme de blocage pour prouver que le masquage marchait. Sous le nouveau prédicat, ils passeraient sans avoir rien prouvé — vert sur un masque totalement cassé.
Leurs fixtures passent donc en
CLOSED, ce qui leur rend leurs dents :test_backticked_prev_is_a_citation_not_a_declarationtest_multiline_backticked_citation_in_commit_passes_full_guardtest_prose_citation_after_tag_line_still_evaluates_tagtest_fully_backticked_tag_is_still_evaluated— dont le commentaire dit lui-même « retirez cette union et ce test devient vert dans la mauvaise direction »Le quatrième ne figurait pas au plan de patch. Il a été trouvé en relisant la région plutôt qu'en faisant confiance au plan, et c'est la seule raison pour laquelle il n'est pas devenu vert en silence. Je le dis parce que le mode de défaillance visé ici est précisément celui-là : un test qui cesse de mesurer ne se plaint pas.
S'y ajoute un cinquième déplacement, de rôle différent :
test_plain_prev_at_an_open_pr_still_fails, renommé…_at_an_abandoned_pr_…. Ce n'est pas un test de masquage mais son contrôle négatif — la même clause écrite en clair, sur la même cible, doit rester rouge, faute de quoi le vert du test de citation ne prouve rien.test_14515_real_body_with_resolved_open_target_failsavait sa prémisse inversée par la réparation (« OPEN doit rougir »). Il est réécrit entest_resolved_bad_target_fails_the_abstention_flag_is_not_the_predicatesur une cibleCLOSED: il continue de mesurer le fail-open de #14550, et non la réparation.Neuf tests nets en plus, dont deux contrôles neufs :
test_resolve_prev_targets_closed_pr_is_distinguishable_from_an_open_one— mêmekind, mêmemerged, seulstatediffère. C'est la réparation, écrite comme une assertion.test_resolver_always_emits_state_for_every_pr_kind_it_returns— garde l'abstention « legacy sansstate» à l'état de cas limite. Si le vrai résolveur cessait d'émettrestate, ce chemin éteindrait l'invariant 2 sur toute la flotte et tous les autres tests resteraient verts. Celui-ci rougirait.Contrôle de mutation, des deux côtés
Un garde qui ne rougit jamais et un garde qui rougit toujours rendent le même service :
CLOSEDne bloque plus)!= MERGEDrebloqueOPEN)scripts/tests/test_variation_prev_guard.py: 49 passés. Avectest_variation_adjacency_guard.py(l'organe frère, doctrine désormais commune) : 107 passés.Suite complète — et les rouges qu'elle porte déjà
pytest scripts/tests/ --ignore-glob='**/test_sudoku*': 4529 passés, 10 échoués, 15 skipped, 5 xfailed (7 min 14).Les dix échecs sont antérieurs et hors d'atteinte de ce diff, qui ne touche que
scripts/ci/variation_prev_guard.pyet son fichier de tests — aucun des deux n'est importé par les modules rouges :scripts/tests/ml/test_garch_baseline.py(9) — dépendance ML absente de cet environnement ;scripts/tests/test_render_oversized_nbconvert.py::test_render_one_fail_non_notebook(1) —Jupyter command `jupyter-nbconvert` not found.Je les note plutôt que de ne rapporter que le vert des deux fichiers concernés : une suite à 10 rouges annoncée « tout passe » est ce qui rend le prochain rouge illisible. Les modules
test_sudoku*sont exclus de la commande pour la même raison — ils échouent à la collecte surNo module named 'torch', également sans rapport.See #13475