Skip to content

fix(guard,#13475): invariant 2 ne rougit que sur un prev: abandonne, plus sur un predecesseur en vol - #15211

Merged
myia-ai-01 merged 1 commit into
mainfrom
fix/13475-prev-in-flight
Sep 9, 2026
Merged

myia-ai-01 merged 1 commit into
mainfrom
fix/13475-prev-in-flight

Conversation

@myia-ai-01

@myia-ai-01 myia-ai-01 commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

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_targets interrogeait gh pr view --json state — donc OPEN / CLOSED / MERGED — puis n'en gardait que merged = (state == "MERGED"). validate_prev_targets, qui ne voit que ce booléen, était structurellement incapable de séparer :

état de la cible ce que ça dit de la lignée verdict dû
MERGED le prédécesseur a atterri propre
OPEN le prédécesseur est encore en vol propre aussi
CLOSED le prédécesseur a été abandonné le vrai défaut

Les deux derniers rendaient merged: false. Les deux rougissaient.

Pourquoi c'est le mauvais des deux qui rougissait le plus

Rougir sur OPEN punit exactement le comportement que R1 de proactive-coordination.md impose :

« 1 PR entre 2 wakeups = PLANCHER, jamais plafond — une PR livrée ne clôt pas la session, re-pioche IMMÉDIATEMENT »

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.py tient déjà depuis #11963 : « le champ prev: 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: … #13465 alors 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 (version origin/main puis version de cette PR) sur les vrais body + commits récupérés par gh, avec --current-pr et --resolve-targets :

PR avant après
#15156 prev-not-merged → 15129 (body + commits[0]) passe
#15190 prev-not-merged → 15175 passe
#15207 prev-not-merged → 15203 passe
#15209 prev-not-merged → 15199 passe
#15210 prev-not-merged → 15175 (body + commits[0]) passe
#15175 prev-self + prev-not-merged (les deux sur 15175) prev-self seul, toujours rouge

Les 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 ligne Grain: et avec elle toute clause prev:. 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 #15207 sur les deux surfaces (corps et message de commit — le garde lit les deux, réparer l'un seul laisse commits[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 :

garde exécuté sur le corps et les commits réels de #15211 verdict
origin/main (ancien) guard_pass: false — prev-not-merged → [15207], sur body et commits[0], rc=1
cette PR (nouveau) guard_pass: true — no prev: defect, rc=0

La dépendance à nommer : always-on-guards.yml se déclenche sur pull_request et fait un actions/checkout sans ref:, donc il extrait refs/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 pointer prev: 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

  • state remonte jusqu'à validate_prev_targets, qui ne rougit plus que sur CLOSED.
  • merged reste é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 grep sur scripts/, .github/, docs/, .claude/ ne trouve aucun consommateur qui lise ce nom de kind — les seules références sont au chemin du module.
  • Deux abstentions délibérées, écrites : cible non résolue (contrat FN-safety inchangé) ; et meta de kind pr sans state (fichier --prev-targets-file écrit à la main ou d'avant ce champ) — on ne peut pas deviner OPEN vs CLOSED, 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 :

  1. test_backticked_prev_is_a_citation_not_a_declaration
  2. test_multiline_backticked_citation_in_commit_passes_full_guard
  3. test_prose_citation_after_tag_line_still_evaluates_tag
  4. test_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_fails avait sa prémisse inversée par la réparation (« OPEN doit rougir »). Il est réécrit en test_resolved_bad_target_fails_the_abstention_flag_is_not_the_predicate sur une cible CLOSED : 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ême kind, même merged, seul state diffè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 sans state » à l'état de cas limite. Si le vrai résolveur cessait d'émettre state, 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 :

mutation appliquée au prédicat résultat
trop permissif (CLOSED ne bloque plus) 7 rouges
régression vers l'ancien (!= MERGED rebloque OPEN) 3 rouges
aucune 49 verts

scripts/tests/test_variation_prev_guard.py : 49 passés. Avec test_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.py et 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 sur No module named 'torch', également sans rapport.

See #13475

…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
@jsboige
jsboige force-pushed the fix/13475-prev-in-flight branch from eadb062 to da2a175 Compare September 8, 2026 13:30
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

prev: genre mots-clé fermant -- bloquant (#10093).

prev: reference(s) fail invariant(s) (prev-not-pr -> [15204]) -> point prev: at a PR of the same lane, distinct from the current PR, that is merged or still open -- never at an abandoned (closed-unmerged) PR nor at an issue. See #13475.

Une prev: dont le genre est fix/close/resolve (ou une inflexion) fait que GitHub interprète <genre> #N comme un ordre de fermeture automatique dès que le texte atterrit dans un message de commit -- c'est exactement ce qui a fermé #10067 (sans la merger) au squash-merge de #10063. Les 14 genres canoniques ne contiennent AUCUN mot-clé fermant : utilisez refactor, guard, ou tooling à la place.

Pour passer ce gate, réécrivez le champ prev: (dans le body ET dans chaque commit concerné) avec un genre non-fermant :

Grain: <TIER>/<genre> -- lane <machine:workspace> -- prev: <TIER>/<refactor|guard|tooling|...> #<PR>

jsboige added a commit that referenced this pull request Sep 8, 2026
…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
@myia-ai-01
myia-ai-01 merged commit 85911c3 into main Sep 9, 2026
14 of 19 checks passed
myia-ai-01 added a commit that referenced this pull request Sep 12, 2026
…ô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>
myia-ai-01 pushed a commit that referenced this pull request Sep 15, 2026
…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>
jsboige added a commit that referenced this pull request Sep 16, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants