From a161523d883362573ee00e050c09a67bef453bf8 Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 17 Sep 2026 02:27:58 +0200 Subject: [PATCH 1/4] feat(coordination): derive and consume READY queue Add fail-closed batch classification and exact-head revalidation, wire the coordinator cycle to merge before dispatch, and cover live B.0, checks, review, dwell, staleness, and mutation cases. See #16480 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/rules/coordinator-discipline.md | 2 +- .claude/skills/coordinate/SKILL.md | 66 +-- docs/reference/scripts-reference.md | 2 +- scripts/check_adjoint_prevalidation.py | 422 +++++++++++++++++- .../tests/test_check_adjoint_prevalidation.py | 293 +++++++++++- 5 files changed, 714 insertions(+), 71 deletions(-) diff --git a/.claude/rules/coordinator-discipline.md b/.claude/rules/coordinator-discipline.md index d7092e3db9..cd8cc0c5de 100644 --- a/.claude/rules/coordinator-discipline.md +++ b/.claude/rules/coordinator-discipline.md @@ -68,7 +68,7 @@ La production des lanes et la digestion (CI, reviews, merges) sont **deux pipeli - Un check rouge, un `DWELL`, une review en attente, un conflit ou un HOLD bloque **la candidate concernee**, jamais la lane. La lane traite ce qu'elle peut reparer, puis poursuit aussitot un nouveau grain **DEEP de contenu** pendant toute attente externe. - `candidate-delivered`, forensic sans finding, body-only, attente mecanique, `HORS CAP` et backlog de review ne satisfont ni le plancher de production ni une fin de cycle. - Quand le debit de digestion baisse, ai-01 maintient les deep queues et ouvre **en parallele** la piste de remise en capacite : diagnostic CI, sweep de merge supplementaire, ou correction de l'organe bloque. Il ne reduit pas les dispatchs pour rendre la queue confortable. -- ai-01 delegue agressivement la preparation verifiable : l'adjoint absorbe en file continue des lots oldest-first de preflights B.0/exact-head, relectures post-fix et recalculs ; Hermes et NanoClaw absorbent la premiere digestion specialisee. Chaque dossier nomme le head exact, les trois surfaces B.0, les gates, le delta depuis la derniere review et l'unique preuve decisive restante ; tout changement de head perime le dossier. **Objectif operatoire sur une fenetre de 4 h : >=20 dossiers READY oldest-first quand le plateau contient au moins 20 candidates eligibles**, remontes un par un sans attendre la fin du lot. Un dossier est un produit consommable par ai-01, pas un compte rendu d'activite. Ces avis preparent la decision sans remplacer la lecture B.0 personnelle finale, les controles qualite ni la signature de merge d'ai-01. +- ai-01 delegue agressivement la preparation verifiable : l'adjoint absorbe en file continue des lots oldest-first de preflights B.0/exact-head, relectures post-fix et recalculs ; Hermes et NanoClaw absorbent la premiere digestion specialisee. Chaque dossier nomme le head exact, les trois surfaces B.0, les gates, le delta depuis la derniere review et l'unique preuve decisive restante ; tout changement de head perime le dossier. **Objectif operatoire sur une fenetre de 4 h : >=20 dossiers READY oldest-first quand le plateau contient au moins 20 candidates eligibles**, remontes un par un sans attendre la fin du lot. Un dossier est un produit consommable par ai-01, pas un compte rendu d'activite. La file se derive par `check_adjoint_prevalidation.py --queue`, puis chaque tete se relit sans cache par `--consume` immediatement avant le geste exact-head : queue READY et merges massifs precedent les dispatchs ; toute mutation expulse la candidate au lieu d'ouvrir une investigation. Ces avis preparent la decision sans remplacer la lecture B.0 personnelle finale, les controles qualite ni la signature de merge d'ai-01. - **Aucun de mes messages n'est un prealable (HARD, mandat user 2026-09-12).** Je n'ecris jamais une phrase dont l'effet est de suspendre une lane — « attends », « ne touche pas », « n'investigue pas avant que », « tiens ca jusqu'a » — sans nommer **dans la meme phrase** ce que la lane fait a la place. Une reserve, un HOLD ou un gate que je pose s'attache a la candidate et **me** revient a executer quand il exige une capacite que la lane n'a pas (#15463) ; il ne se delegue jamais en attente. - **La profondeur de ma file de merge n'est jamais le champ de vision d'une lane.** Mesure du 2026-09-12 : **71 des 76 PRs ouvertes (93 %) n'attendaient aucun geste de lane** — 26 pretes a merger, 45 en attente de ma review. Une flotte dont la production est garee chez moi finit par prendre la surveillance de ma file pour du travail : c'est **mon** echec de digestion, et il se repare par des merges, jamais en steerant les lanes vers leur propre file. - **Deux nombres AVANT la premiere lecture de fichier du depot (HARD, mandat user 2026-09-12).** Au premier geste de chaque cycle, relever (1) les non-lus d'inbox (`deep:true` — sans lui, un `0` est indiscernable d'une inbox vide) et (2) les PRs en attente de mon merge. Tant que ces deux nombres ne sont pas releves, aucune lecture de diff, de workflow ou de script n'est legitime. L'auto-interrogation « suis-je en train de micromanager ? » ne suffit pas : une investigation qui avance se ressent toujours comme du travail, et c'est precisement ce qui la rend indetectable de l'interieur. diff --git a/.claude/skills/coordinate/SKILL.md b/.claude/skills/coordinate/SKILL.md index a4599e1800..21e86ffd08 100644 --- a/.claude/skills/coordinate/SKILL.md +++ b/.claude/skills/coordinate/SKILL.md @@ -18,7 +18,7 @@ Cycle de coordination du cluster CoursIA. **Reserve au coordinateur ai-01** : un ## Budget de cycle (HARD — mandat user 2026-09-14) 1. **Un cycle tient en 1 h a 1 h 30 de travail entre deux crons de 4 h**, puis la session se rendort. Verbatim user : « Ca donne entre 1h et 1h30 max de travail entre 2 crons, c'est deja beaucoup je pense, et il ne faudrait pas depasser ca. Sinon c'est un defaut de delegation. » -2. **Decoupe interne des phases — EN ATTENTE DE MESURE.** Le user a recuse une decoupe chiffree posee au jugement : « sur les durees suggerees c'est au doigt mouille, hein, le mieux serait d'etudier ce qui a bien marche debut juillet quand on produisait beaucoup sans pour autant trop lesiner sur la qualite ». La mesure du regime de debut juillet (fenetre 2026-07-01 → 07-14) est deleguee a la lane `myia-ai-01:claudish` (DM `msg-20260914T195758-l93rsb`). **A REMPLACER par la decoupe mesuree — ne pas poser de chiffre au jugement.** Tant que cette mesure n'est pas rendue, aucune duree de phase n'est normative : les trois phases gardent leur ORDRE (grounding → dispatch → travail reel) sans budget chiffre. +2. **Decoupe interne des phases — EN ATTENTE DE MESURE.** Le user a recuse une decoupe chiffree posee au jugement : « sur les durees suggerees c'est au doigt mouille, hein, le mieux serait d'etudier ce qui a bien marche debut juillet quand on produisait beaucoup sans pour autant trop lesiner sur la qualite ». La mesure du regime de debut juillet (fenetre 2026-07-01 → 07-14) est deleguee a la lane `myia-ai-01:claudish` (DM `msg-20260914T195758-l93rsb`). **A REMPLACER par la decoupe mesuree — ne pas poser de chiffre au jugement.** Tant que cette mesure n'est pas rendue, aucune duree de phase n'est normative : les quatre phases gardent leur ORDRE (queue READY → merges → dispatchs → travail non delegable) sans budget chiffre. 3. **Mesurer le temps activement**, pas au ressenti : `date -u` en entree et en sortie de chaque phase ; le total du cycle est annonce dans le rapport de fin. 4. **Un depassement se traite en DELEGUANT**, jamais en rognant le grounding ou le dispatch. 5. **Tout ce qui est delegable EST delegue**, sans arbitrage au cas par cas. Attendre le cron suivant pour recuperer un resultat est gratuit — verbatim : « tu peux tout a fait attendre un cron pour economiser tes tokens, on n'est pas a 4h pres sauf crise a gerer ». @@ -27,58 +27,34 @@ Cycle de coordination du cluster CoursIA. **Reserve au coordinateur ai-01** : un ## Process -Les phases ci-dessous s'executent sous le budget defini par la section `## Budget de cycle` ci-dessus : 1 h a 1 h 30 de travail au total, tout depassement etant un defaut de delegation. +Les phases A-D s'executent dans cet ordre sous le budget total de 1 h a 1 h 30. La file READY est preparee inter-cycle : la reconstruire par un sweep manuel au debut annule le travail de l'adjoint. -### Phase 1 - Contexte memoire +### Phase A - Deriver la queue READY une fois -1. `~/.claude/projects/d--CoursIA/memory/MEMORY.md` — index + quick reference -2. `~/.claude/projects/d--CoursIA/memory/coordinator-durable-state.md` — axes directeurs, bloqueurs user, watches, calendrier -3. Au besoin : [docs/reference/cluster-agents.md](../../../docs/reference/cluster-agents.md) (machines, lanes, GPU), [docs/reference/teaching-context.md](../../../docs/reference/teaching-context.md) (calendrier ecoles) +1. Lire l'index memoire et `coordinator-durable-state.md`, puis les deux dashboards workspace et l'inbox `deep:true`. Relever les non-lus et les PRs en attente avant toute lecture de fichier. +2. Verifier le cron unique. Ne pas bouger le checkout partage s'il est sale : travailler depuis `main` frais dans un worktree isole si necessaire. +3. Extraire les numeros explicitement prepares par l'adjoint et lancer **une fois** : `python scripts/check_adjoint_prevalidation.py --queue `. La sortie JSON est une queue **derivee**, oldest-first, sans registre persistant a invalider. +4. `READY` entre en Phase B. `DWELL_PENDING` attend son `dwell_until`. `STALE` et `BLOCKED` sortent immediatement de la passe avec leur `reject_cause`, `tail_to_read`, `WAIT_FOR` et `RESUME_WHEN`. Exit 2 est UNKNOWN fail-closed. -### Phase 2 - Etat live +### Phase B - Consommer et merger en rafale -0. **Rester courant** : `git checkout main && git pull --ff-only` + `git submodule update --init` — le coordinateur travaille sur un main LOCAL a jour, jamais en grepant un working-tree stale ni en pilotant via `origin/*`. -1. **Dashboards (canal PRINCIPAL) — lire LES DEUX, independamment** : `roosync_dashboard(action:"read", type:"workspace", section:"all")` pour `workspace-CoursIA` **et** pour `workspace-CoursIA-2`. Deux lanes co-egales ; **aucune n'est "le dashboard du coordinateur"**. Une `lane` = machine x workspace : chaque machine avec une lane CoursIA-2 a AUSSI une lane CoursIA. Lire chacun separement pour ne rater aucun ASK/blocker. -2. **Inbox DM — drainer et EXTRAIRE, jamais survoler** : `roosync_messages(action:"inbox", status:"unread", deep:true)` — **sans `deep:true` le compte de non-lus est un faux zero**. Deux gestes, dans cet ordre. **(a) Purger les classes qui doublonnent une surface deja lue** — `bulk_mark_read(subject_contains:"Worker Report")` et `bulk_mark_read(subject_contains:"[MENTION] Dashboard")` : sans ca l'arriere se reconstruit a ~8 DM/h et noie le signal utile, qui pese moins de 10 % du volume. **(b) Extraire la liste nommee des PRs deja pre-machees** — marqueurs `[ADJOINT PREFLIGHT]`, `[ADJOINT VERIFIED]`, `[ADJOINT DECISION PACK]`, `preflight exact-head`. Cette liste est une **entree obligatoire de la Phase 3.3** : le pre-machage est produit qu'on le lise ou non ; non consomme, il est paye deux fois. -3. **GitHub** : `gh pr list --state open` (a merger) + le pool **tire, jamais scanne** — `python scripts/pick_idle_grain.py --lane myia-ai-01:CoursIA` (un `gh issue list` nu plafonne a 30, tries par recence : il ne montre que ce que je viens de creer, et c'est ce biais que le steering doit eviter de reproduire). -4. **Cron** : `CronList` — si le job coordinateur a disparu (session-only), re-armer `CronCreate("27 */4 * * *", "/coordinate", recurring)` — **4 h, minute off-`:00`** (mandat user 2026-09-13, crise de consommation Anthropic : le coordinateur doit etre le dernier agent a tourner sur le provider ; le jitter evite de frapper l'API a la meme seconde que le reste de la flotte). Cadence unique, PAS de 2e cron ni ScheduleWakeup en plus — un cycle plus long que sa cadence annule deja ses propres declenchements, en empiler un second ne fait qu'ajouter de la conso. +1. Parcourir les `READY` oldest-first. Juste avant la lecture personnelle, lancer `python scripts/check_adjoint_prevalidation.py --consume ` : cette commande relit toutes les surfaces live et ne reutilise aucun cache de `--queue`. +2. **Exit 0 seulement** ouvre la lecture B.0 personnelle minimale : body, commentaires, corps/etats des reviews, threads et diff. Lire `tail_to_read`, le delta et la preuve decisive ; ne pas rejouer l'audit complet de l'adjoint, d'Hermes ou de NanoClaw. +3. Repasser `python scripts/check_unaddressed_nits.py `, latest-wins CI, H.4, catalogue byte-identique a main, scope, stack et variation. Un preflight READY n'autorise jamais a lui seul le merge. +4. Merger avec le `head` retourne par **ce** `--consume` : `gh pr merge --repo jsboige/CoursIA --squash --match-head-commit `. Jamais `--delete-branch`. Recalculer la variation apres chaque merge pertinent. +5. Toute mutation ou insuffisance expulse la candidate vers la Phase C ; elle ne transforme pas la rafale en investigation. -### Phase 3 - Dispatchs, relances, memoire (LES 30 PREMIERES MINUTES) +### Phase C - Dispatcher massivement les sorties et le travail lourd -**Cette phase precede la passe de merge et se ferme avant elle.** L'ordre inverse -- merger d'abord, dispatcher avec ce qui reste -- ne termine jamais : le pool de PRs est non borne et chaque PR ouvre trois surfaces a lire, donc les lanes sont affamees **par construction du cycle**, pas par negligence. Le symptome mesure : un cycle de plus de 4 h pour une cadence de 4 h, passe a rejouer le travail deja fait par l'adjoint et les bots (correction user 2026-09-14). +1. Regrouper `STALE`/`BLOCKED` et les rouges par lane ; citer la cause exacte et l'evenement de reprise attendu. Les dossiers invalides repartent vers l'adjoint, les reparations et re-reviews vers leur lane proprietaire. +2. Grounder chaque grain firsthand, poser le claim GitHub et utiliser le double canal DM + pointeur dashboard. Une lane sans grain recoit une deep-queue ou un fallback perenne ; elle n'attend jamais une candidate en HOLD, DWELL, CI ou review. +3. Les investigations lourdes, tests, builds et reconciliations sont dispatches maintenant afin que le prochain cycle retrouve une queue actionnable. Après la rafale seulement, exécuter `python scripts/pick_idle_grain.py --orphans-report` : toute PR hors radar de l'adjoint reçoit alors un propriétaire ou un événement de reprise sans retarder le premier merge. Mettre a jour la memoire durable si son etat a change. -1. **Sweep unique, trie par anciennete** -- il sert LES DEUX phases, on ne le capture qu'une fois : `gh pr list --state open --limit 200 --json number,title,author,createdAt,mergeStateStatus,reviewDecision,headRefOid --jq 'sort_by(.createdAt) | .[] | [.createdAt[0:10],.number,.mergeStateStatus,.reviewDecision,.title] | @tsv'`. Sans `--limit`, gh plafonne a 30, et sans tri declare il rend par recence. **Pas de champ `reviews`** (payload lourd -- 504). 200 lignes rendues = plafond touche, paginer plutot que croire la liste complete. -2. **Grouper LA QUEUE par lane, et dispatcher le deblocage.** Les ~20 PRs les plus vieilles, regroupees par leur tag `Grain: ... lane`, partent en mandat de deblocage a leur lane. Une PR est vieille **parce qu'**elle est bloquee : ce qui se dispatche est le deblocage, pas le merge qui s'attend. Le lot d'une lane se derive du sweep seul -- aucun re-audit prealable n'est requis pour l'envoyer. -3. **Relancer les nits bloquants, nommement.** Chaque reserve non levee (`[Hermes] COMMENT_WITH_CONCERNS`, `CHANGES_REQUESTED`, nit user, thread inline non resolu) est renvoyee a la lane de l'auteur de la PR **avec le point cite**. Une reserve qu'on ne relance pas devient un grain qu'aucune lane ne sait qu'elle doit executer -- et celles posees par ai-01 ne peuvent etre levees par personne d'autre. -4. **Le rouge sans lane est a MOI.** Le garde "reparer son rouge d'abord" ([proactive-coordination](../../rules/proactive-coordination.md) R5) renvoie chaque lane sur ses propres PRs bloquees -- mais une PR **sans tag `Grain:` lisible** n'est imputable a aucune lane et reste invisible a tous les gardes. Lire le commentaire marker-guarde `GRAIN-ORPHANS-SWEEP` sur #13086 (rafraichi via `python scripts/pick_idle_grain.py --orphans-report`) et traiter chaque orpheline nommee avec son auteur : reparer, dispatcher nommement, ou fermer en le disant. Le coordinateur est soumis au meme garde pour **sa propre** lane. -5. **Trancher les design-gates en attente** dans le cycle -- ne pas deferer une option deja investiguee. Regles : [coordinator-discipline.md](../../rules/coordinator-discipline.md) (R3 lanes independantes, R4 jamais sanctionner l'idle, R5 steer qui ATTEINT/VRAI/DECIDE). -6. **Grounder chaque grain firsthand** (`gh issue view N` / `gh pr view N`) AVANT de dispatcher -- jamais depuis un status condense. -7. **Double canal obligatoire** : DM `roosync_messages(action:"send", to:":", ...)` (le worker lit l'inbox en premier, le DM survit a la condensation) **+** pointeur `[DISPATCH->inbox]` sur le dashboard de la lane (sonnette persistante). -8. **Une lane sans grain = echec coordinateur** : deep-queue, fallback perenne par famille, ou pool global -- jamais un statut terminal-idle. Chaque worker draine **tous** ses nits et reserves reparables sur **toutes** ses PRs, puis enchaine plusieurs grains DEEP/MED ; une seule PR livree ne clot pas sa session. -9. **MAJ memoire maintenant, pas en fin de cycle** : `coordinator-durable-state.md` si l'etat durable a bouge. Repoussee a la fin, elle saute quand le cycle deborde -- et le cycle suivant re-derive ce qu'il savait deja. +### Phase D - Travail non delegable borne et fin de cycle -**Budget** : ces neuf points sont **clos avant** d'ouvrir la Phase 4. S'ils ne le sont pas a la fin des 30 minutes, ce sont eux qu'on termine -- pas le merge qu'on commence. - -### Phase 4 - Merge PAR LA QUEUE, sur dossiers premaches - -**Ordre unique : du plus ancien au plus recent.** Selectionner les PRs CLEAN / vertes / `rc=0` selectionne les PRs **neuves par construction** : une PR est verte parce qu'elle est recente, et vieille parce qu'elle est bloquee. Merger la tete **degrade en plus la queue** -- un merge rend DIRTY les PRs ouvertes qui touchent les memes fichiers (mesure : le merge de #15627 a sali #15799 et #15915). Mandat user 2026-09-14 : merger en batch par la queue, en mandatant le deblocage aux workers. - -**Ce que le coordinateur NE refait PAS.** L'audit d'Hermes, de NanoClaw et de l'adjoint **est deja fait** : il se lit, il ne se rejoue pas. Ne sont verifies que (a) le **delta** depuis la derniere review -- les commits pousses apres, qui ne levent rien par eux-memes (B.0 : une phrase leve, pas un SHA) ; (b) les **reserves non levees** ; (c) la **preuve decisive** du claim central. Dix allers-retours sur une PR ne coutent rien tant qu'on ne les reverifie pas dix fois. - -1. **Gate d'entree AVANT toute lecture personnelle (HARD)** : pour chaque candidate oldest-first, lancer `python scripts/check_adjoint_prevalidation.py `. **Exit 0 seulement** autorise ai-01 a ouvrir le body, les commentaires, les reviews, les threads ou le diff. Exit 1 (absent, incomplet, BLOCKED, ancien SHA ou surfaces modifiees) = router la candidate a l'adjoint `myia-po-2025:CoursIA-2`, l'exclure de la file personnelle jusqu'a un nouveau dossier exact-head, puis prendre la candidate suivante. Exit 2 = organe injoignable, donc meme refus fail-closed. Le bloc `[ADJOINT PREFLIGHT]` est genere par `python scripts/check_adjoint_prevalidation.py --template`, puis complete par l'adjoint ; le compte des commentaires exclut le commentaire-dossier lui-meme. **Interdit de contourner le gate par un sous-agent, une lecture API directe ou un ancien dossier d'inbox.** -2. **Exploiter les verdicts deja poses** : `[Hermes] COMMENT_WITH_CONCERNS` (prefixe de `reviews[].body`), `EXEC_PROVED` / `STRUCTURAL_ONLY` / `SUSPECT_REGRESSION` (body). Tout finding NanoClaw suit [audit-reassessment.md](../../rules/audit-reassessment.md) avant fix (~60 % de FP). -3. **L'adjoint fabrique, ai-01 ne re-fabrique pas** : les PRs sans dossier valide partent en lots explicites issus du sweep vers l'adjoint, pas vers des sous-agents ai-01. Dossier attendu : contrat machine-lisible exact-head, trois surfaces B.0, checks latest-wins, scope, domaine et verdict ; un changement de head ou de surface le perime. L'adjoint vise **>=20 READY oldest-first par fenetre de 4 h quand >=20 candidates sont eligibles**, et remonte chaque READY immediatement : le lot n'est pas une barriere. Un dossier insuffisant repart avec UNE question precise. Le login GitHub `jsboige` etant partage, le champ `lane` est une declaration fail-closed, pas une preuve cryptographique d'identite ; aucune autre lane declaree n'est acceptee. -4. **Lecture B.0 personnelle minimale avant chaque merge -- non delegable, seulement APRES gate vert** : body + comments + reviews + diff ("Read Body Before Any Action") ; etat A L'INSTANT-T via `gh pr view N --json state,mergedAt,mergeStateStatus,reviews` (jamais depuis le dashboard ni le cycle N-1) ; organe `python scripts/check_unaddressed_nits.py ` (exit 1 = ne pas merger ; son vert ne dispense pas de la lecture). Verifier seulement le dossier, le delta et la preuve decisive ; ne pas rejouer l'audit complet. Une levee porte un auteur et une heure. -5. **Gates de merge** : un preflight READY n'autorise jamais le merge. Appliquer encore B.0, latest-wins CI, H.4 (notebooks : checkout + Papermill local OU log dans le body), catalogue byte-identique a main (`gh pr view N --json files`), scope reel = titre, ordre de stack, variation et relecture de la queue de commentaires. -6. **Merge** : sous `myia-ai-01` (droit `MergePullRequest` verifie firsthand 2026-08-08), avec `gh pr merge --repo jsboige/CoursIA --squash --match-head-commit ` (`--merge` preserve-SHA pour la base d'un stack), **JAMAIS `--delete-branch`**. - -### Phase 5 - Fin de cycle (obligatoire) - -1. **Commit + PR AVANT le rapport** — ne jamais annoncer un travail non commite. -2. `[DONE]` lane-specific sur **les deux** dashboards (jamais un miroir copie-colle). -3. **Bloqueurs user** : restituer en un bloc les questions ouvertes du registre durable ; aucun re-poke intermédiaire ni liste parallèle ([user-blocker-signaling](../../rules/user-blocker-signaling.md)). -4. MAJ `coordinator-durable-state.md` si l'etat durable a change (PR#/SHA ephemeres → dashboard, pas la memoire). -5. **Une seule investigation par cycle, et en fin de session.** Toute question ouverte qui n'est **pas** un bloqueur de merge se note et attend le cycle suivant : mesurer un organe, verifier une provenance, instruire un doute de securite sont des gestes utiles et couteux, qui n'ont leur place qu'apres les dispatchs, les relances et la passe de merge. Une investigation qui deborde sur le cycle suivant est une investigation de trop -- elle a mange le temps des lanes. Si l'objet est reellement urgent, il devient un **grain dispatche**, pas une enquete du coordinateur. +1. Garder seulement les decisions finales, arbitrages strategiques et lectures personnelles que personne ne peut signer a la place d'ai-01. Une seule investigation personnelle au maximum ; si elle est delegable ou deborde, elle devient un grain. +2. Consigner les metriques : READY recues, merges tentes/reussis, rejets par cause, heure du premier merge, temps avant passage aux dispatchs et dossiers republies apres staleness. Si une READY existe et qu'aucun premier merge n'est tente dans les 15 minutes, signaler l'alarme de derive. +3. Commit + PR avant rapport ; puis `[DONE]` lane-specific sur les deux dashboards. Restituer en bloc le registre des questions user ouvertes, sans interruption intermediaire ni liste parallele. ## Regles importantes diff --git a/docs/reference/scripts-reference.md b/docs/reference/scripts-reference.md index 90f18ee4d7..92fd2f8ab7 100644 --- a/docs/reference/scripts-reference.md +++ b/docs/reference/scripts-reference.md @@ -189,7 +189,7 @@ Pipeline d'audit qualité et de **matrice de coût** (EPIC #8056) + audit séman | `scripts/lean/check_mathlib_cache.py` | **Vérifie qu'un cache Mathlib est réellement atteignable** pour chaque lake, en traversant les junctions (`realpath` + `os.walk`) — `find` renvoie **0** sur un cache sain de 8124 oleans et `os.path.islink()` renvoie **False** sur une junction, combinaison qui fabrique un faux verdict « cache purgé » (5 cycles de lane Lean perdus, 2026-07-29). Statuts `ok`/`partial`/`cold`/`absent`, dédoublonne les lakes partageant un cache physique. Advisory (exit 0) ; `--strict` exit 1. La preuve décisive reste un `lake build` réel | | `scripts/lean/setup_shared_mathlib.ps1` | Mutualisation checkouts Mathlib via junctions NTFS (#2611) : `-Mode Scan` (inventaire groupes), `Apply` (cache `.mathlib-cache/` + junctions, `-Build` vérifie, `-RemoveBackups` libère l'espace), `Rollback` (restaure les checkouts physiques). Précondition : lake-manifest.json identique sur TOUTES les deps transitives + même lean-toolchain. Ne jamais `lake update` un projet junctionné | | `scripts/check_grain_free.py` | **Grounding pré-dispatch** (une commande avant d'écrire un steer) : agrège l'état de l'issue, les **contraintes de forme** de son acceptance (cases `- [ ]` portant une négation — c'est ce que #10990 interdisait), les **PRs ouvertes** et **mergées** la référençant, et délègue les claims à `check_lane_claim.py` (autorité). `--lane` obligatoire. Exit `0` CLEAR / `1` FLAGGED / `2` UNKNOWN — une surface non interrogeable rend **UNKNOWN**, jamais une liste vide : « rien trouvé » et « pas regardé » ne partagent pas de valeur de retour. Complète `check_lane_claim.py --paths`, qui exige de connaître les chemins — ce qu'on n'a pas encore au moment de dispatcher | -| `scripts/check_adjoint_prevalidation.py` | **Gate d'entrée en review ai-01** (#16442) : exige un dossier `[ADJOINT PREFLIGHT]` complet de la lane adjointe, lié au SHA exact et à une empreinte des surfaces body/comments/reviews/threads/checks. Toute absence, mutation observable des surfaces actuelles ou valeur non canonique échoue fermé ; un événement GitHub ensuite supprimé/reverté n'est pas reconstructible par cet organe stateless. `--template` génère le bloc complet (empreinte incluse) et `--fingerprint` expose l'empreinte seule. Exit `0` READY / `1` BLOCKED / `2` UNKNOWN. READY autorise la lecture finale du coordinateur, jamais le merge | +| `scripts/check_adjoint_prevalidation.py` | **Gate d'entrée en review ai-01** (#16442, #16480) : exige un dossier `[ADJOINT PREFLIGHT]` complet de la lane adjointe, lié au SHA exact et à une empreinte des surfaces body/comments/reviews/threads/checks. `--queue ` dérive sans état persistant une file oldest-first (`READY` / `BLOCKED` / `DWELL_PENDING` / `STALE`) avec cause, tail, review exact-head, checks et métriques ; `--consume ` relit toutes les surfaces live sans cache juste avant le merge exact-head. Toute absence, mutation observable ou valeur non canonique échoue fermé ; un événement GitHub ensuite supprimé/reverté n'est pas reconstructible par cet organe stateless. `--template` génère le bloc complet et `--fingerprint` expose l'empreinte seule. Exit `0` READY/queue complète, `1` non-READY, `2` UNKNOWN. READY autorise la lecture finale du coordinateur, jamais le merge | | `scripts/check_pr_perimeter.py` | **Source de vérité périmètre pour reviews** (#11268) : énumère les fichiers effectifs d'une PR (`gh pr view --json files`), nomme tout `.github/workflows/**` dans une section dédiée, détecte les **mouvements de baseline/seuil** du diff avec leur sens (`sorry-baseline` 16→14 = TIGHTEN ; un desserrement sans `--baseline-justified` => CHANGES_REQUESTED), et confronte l'assertion de périmètre du reviewer (`--assert "..."`) à la liste réelle — la review #11227 (« 2 fichiers twins uniquement » sur 3 fichiers dont un workflow) ne peut plus être produite à l'insu. `--scan-thread` scanne le body PR + les reviews top-level et confronte chaque assertion trouvée à la liste effective ; câblé par `.github/workflows/perimeter-review-guard.yml` (déclenché sur `pull_request` + `pull_request_review`) — une fausse assertion devient un check rouge bloquant. Exit `0` OK / `1` FAIL (écart assertion, workflow non nommé, desserrement nu) / `2` erreur gh. À exécuter AVANT toute assertion de périmètre dans une review | | `scripts/ci/measure_runner_demand.py` | **Baseline exhaustive de demande GitHub Actions** (#12704) : collecte une fenêtre UTC avec bisection anti-cap 1 000 + pagination de tous les jobs, mesure attente (`started_at-created_at`), travail runner (`completed_at-started_at`), provenance same-repo/fork/unknown et dénominateurs ; replay offline par `--input`. Exit `0` mesure valide / `2` instrument ou snapshot incomplet. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) | | `scripts/ci/manage_self_hosted_runner.py` + `self_hosted_runner_profiles.json` | **Cycle de vie Windows des runners éphémères isolés** (#12704) : profils distribués po-2023..po-2026 avec archive/SHA-256 épinglés ; commandes `install`, `register`, `verify`, `teardown` en dry-run par défaut, mutations uniquement avec `--apply`; compte local dédié, ACL négatives `.secrets`/SSH/gh, tokens via `ACTIONS_RUNNER_INPUT_*`, extraction anti-Zip-Slip/ADS et teardown borné par manifeste. `register --apply` est le bouton d’activation séparé, jamais lancé pendant la préparation. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) | diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 96462445b1..740b6be2d8 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -44,8 +44,10 @@ import re import subprocess import sys -from dataclasses import dataclass -from typing import Any +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Sequence REPO = "jsboige/CoursIA" ADJOINT_LANE = "myia-po-2025:CoursIA-2" @@ -53,6 +55,15 @@ START = "[ADJOINT PREFLIGHT]" END = "[/ADJOINT PREFLIGHT]" SHA_RE = re.compile(r"[0-9a-f]{40}") +READY = "READY" +BLOCKED = "BLOCKED" +DWELL_PENDING = "DWELL_PENDING" +STALE = "STALE" +DEFAULT_DWELL_MINUTES = 120.0 +DEFAULT_STALE_AFTER_MINUTES = 24 * 60.0 +DWELL_WAIVER_LABEL = "merge-dwell-waived" +CHECK_OK = frozenset({"SUCCESS", "NEUTRAL", "SKIPPED"}) +CHECK_PENDING = frozenset({"EXPECTED", "PENDING", "QUEUED", "IN_PROGRESS", "WAITING"}) REQUIRED_FIELDS = { "schema", @@ -94,6 +105,34 @@ class Dossier: author: str +@dataclass(frozen=True) +class QueueEntry: + pr: int + verdict: str + created_at: str + head: str + dossier_comment_id: str | None + dossier_created_at: str | None + surfaces_sha256: str + b0_rc: int | None + checks: str + review_qualifying: bool + grain_tag: str | None + last_comment_is_dossier: bool + tail_to_read: list[dict[str, Any]] + dossier_age_minutes: float | None + dwell_until: str | None + reject_cause: list[str] + + @property + def status(self) -> str: + """Compatibility alias for callers using the initial queue prototype.""" + return self.verdict + + def to_json(self) -> dict[str, Any]: + return asdict(self) + + def gh_json(args: list[str]) -> Any: proc = subprocess.run( ["gh", *args], capture_output=True, text=True, encoding="utf-8" @@ -290,6 +329,322 @@ def evaluate(snapshot: dict[str, Any]) -> tuple[bool, list[str]]: return not errors, errors +def run_b0(pr: int) -> tuple[int, str]: + """Run the canonical B.0 organ; its output remains evidence, not approval.""" + checker = Path(__file__).with_name("check_unaddressed_nits.py") + proc = subprocess.run( + [sys.executable, str(checker), str(pr)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + output = "\n".join(part.strip() for part in (proc.stdout, proc.stderr) if part.strip()) + return proc.returncode, output + + +def _utc(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _dossiers(snapshot: dict[str, Any]) -> list[tuple[Dossier, list[str]]]: + dossiers: list[tuple[Dossier, list[str]]] = [] + for index, comment in enumerate(snapshot.get("comments") or []): + author = (comment.get("author") or {}).get("login", "") + dossier, errors = parse_dossier(comment.get("body") or "", index, author) + if dossier is not None: + dossiers.append((dossier, errors)) + return dossiers + + +def _check_name(check: dict[str, Any]) -> str: + return str(check.get("name") or check.get("context") or "") + + +def _check_verdict(check: dict[str, Any]) -> str: + return str(check.get("conclusion") or check.get("state") or check.get("status") or "").upper() + + +def _latest_checks(snapshot: dict[str, Any]) -> dict[str, dict[str, Any]]: + latest: dict[str, dict[str, Any]] = {} + for index, check in enumerate(snapshot.get("statusCheckRollup") or []): + name = _check_name(check) + stamp = str( + check.get("startedAt") + or check.get("createdAt") + or check.get("completedAt") + or "" + ) + candidate = (stamp, str(check.get("id") or index)) + current = latest.get(name) + if current is None or candidate > current["_order"]: + latest[name] = {**check, "_order": candidate} + return latest + + +def _checks_summary(snapshot: dict[str, Any]) -> tuple[str, list[str]]: + latest = _latest_checks(snapshot) + if not latest: + return "missing", ["no live checks found"] + pending: list[str] = [] + failed: list[str] = [] + for name, check in latest.items(): + verdict = _check_verdict(check) + if verdict in CHECK_PENDING or not verdict: + pending.append(name) + elif verdict not in CHECK_OK: + failed.append(f"{name}={verdict}") + reasons = [] + if pending: + reasons.append("checks in-flight: " + ", ".join(sorted(pending))) + if failed: + reasons.append("checks not green: " + ", ".join(sorted(failed))) + if pending: + return "in-flight", reasons + if failed: + return "not-green", reasons + return "latest-wins-green", [] + + +def _qualifying_review(snapshot: dict[str, Any]) -> bool: + head = snapshot.get("headRefOid") + latest_by_author: dict[str, tuple[tuple[str, str], dict[str, Any]]] = {} + for index, review in enumerate(snapshot.get("reviews") or []): + author = (review.get("author") or {}).get("login", "") or f"unknown-{index}" + order = (str(review.get("submittedAt") or ""), str(review.get("id") or index)) + current = latest_by_author.get(author) + if current is None or order > current[0]: + latest_by_author[author] = (order, review) + latest = [item[1] for item in latest_by_author.values()] + if any(review.get("state") == "CHANGES_REQUESTED" for review in latest): + return False + return any( + review.get("state") == "APPROVED" + and (review.get("commit") or {}).get("oid") == head + for review in latest + ) + + +def _grain_tag(snapshot: dict[str, Any]) -> str | None: + texts = [snapshot.get("body") or ""] + texts.extend(comment.get("body") or "" for comment in snapshot.get("comments") or []) + pattern = re.compile(r"(?im)^\s*Grain:\s*(.+?)\s*$") + for text in reversed(texts): + match = pattern.search(text) + if match: + return match.group(1) + return None + + +def _head_committed_at(snapshot: dict[str, Any]) -> datetime | None: + head = snapshot.get("headRefOid") + for commit in snapshot.get("commits") or []: + if commit.get("oid") == head and commit.get("committedDate"): + return _utc(commit["committedDate"]) + return None + + +def _comment_tail(snapshot: dict[str, Any], dossier_index: int) -> list[dict[str, Any]]: + return [ + { + "id": comment.get("id"), + "author": (comment.get("author") or {}).get("login", ""), + "createdAt": comment.get("createdAt"), + "body": comment.get("body") or "", + } + for comment in (snapshot.get("comments") or [])[dossier_index + 1 :] + ] + + +def classify_snapshot( + snapshot: dict[str, Any], + *, + now: datetime | None = None, + dwell_minutes: float = DEFAULT_DWELL_MINUTES, + stale_after_minutes: float = DEFAULT_STALE_AFTER_MINUTES, + b0_result: tuple[int, str] | None = None, +) -> QueueEntry: + """Derive one queue entry from current surfaces without stored queue state.""" + now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + dossiers = _dossiers(snapshot) + created_at = str(snapshot.get("createdAt") or "") + head = str(snapshot.get("headRefOid") or "") + live_fingerprint = surfaces_fingerprint(snapshot) + checks, check_errors = _checks_summary(snapshot) + review_qualifying = _qualifying_review(snapshot) + + if not dossiers: + return QueueEntry( + pr=snapshot["number"], verdict=BLOCKED, created_at=created_at, + head=head, dossier_comment_id=None, dossier_created_at=None, + surfaces_sha256=live_fingerprint, + b0_rc=b0_result[0] if b0_result is not None else None, + checks=checks, review_qualifying=review_qualifying, + grain_tag=_grain_tag(snapshot), last_comment_is_dossier=False, + tail_to_read=[], dossier_age_minutes=None, dwell_until=None, + reject_cause=["no [ADJOINT PREFLIGHT] dossier comment found"], + ) + + dossier, parse_errors = dossiers[-1] + comments = snapshot.get("comments") or [] + dossier_comment = comments[dossier.comment_index] + dossier_created_at = dossier_comment.get("createdAt") + dossier_age = None + if dossier_created_at: + dossier_age = max(0.0, (now - _utc(dossier_created_at)).total_seconds() / 60.0) + tail = _comment_tail(snapshot, dossier.comment_index) + validation_errors = [*parse_errors, *validate_dossier(dossier, snapshot)] + reasons = [*validation_errors, *check_errors] + b0_rc = b0_result[0] if b0_result is not None else None + if b0_rc is not None and b0_rc != 0: + detail = b0_result[1].splitlines()[0] if b0_result[1] else "no detail" + reasons.append(f"B.0 organ blocked (rc={b0_rc}): {detail}") + if not review_qualifying: + reasons.append("no qualifying APPROVED review on exact head") + + stale_reasons: list[str] = [] + stale_markers = ( + "head is stale", + "discussion surfaces changed", + "discussion changed after dossier", + ) + dossier_head_is_canonical = bool(SHA_RE.fullmatch(dossier.fields.get("head", ""))) + for reason in validation_errors: + if reason.startswith("head is stale") and not dossier_head_is_canonical: + continue + if any(marker in reason for marker in stale_markers): + stale_reasons.append(reason) + if dossier.comment_index != len(comments) - 1: + reason = "discussion changed after dossier: a fresh adjoint preflight is required" + if reason not in reasons: + reasons.append(reason) + stale_reasons.append(reason) + if ( + dossier_age is not None + and stale_after_minutes > 0 + and dossier_age > stale_after_minutes + ): + reason = ( + "dossier stale by age: " + f"age={dossier_age:.1f}m, limit={stale_after_minutes:.1f}m" + ) + reasons.append(reason) + stale_reasons.append(reason) + + dwell_until = None + head_time = _head_committed_at(snapshot) + labels = { + str((label or {}).get("name") or "") + for label in snapshot.get("labels") or [] + } + dwell_waived = DWELL_WAIVER_LABEL in labels + dwell_pending = False + if head_time is None: + reasons.append("head commit timestamp unavailable; dwell cannot be verified") + else: + floor = head_time + timedelta(minutes=dwell_minutes) + dwell_until = _iso(floor) + dwell_pending = not dwell_waived and now < floor + + if stale_reasons: + status = STALE + elif reasons: + status = BLOCKED + elif dwell_pending: + status = DWELL_PENDING + reasons = [f"dwell pending until {dwell_until}"] + else: + status = READY + + return QueueEntry( + pr=snapshot["number"], verdict=status, created_at=created_at, head=head, + dossier_comment_id=str(dossier_comment.get("id") or "") or None, + dossier_created_at=str(dossier_created_at) if dossier_created_at else None, + surfaces_sha256=dossier.fields.get("surfaces-sha256", ""), + b0_rc=b0_rc, + checks=checks, review_qualifying=review_qualifying, + grain_tag=_grain_tag(snapshot), + last_comment_is_dossier=dossier.comment_index == len(comments) - 1, + tail_to_read=tail, dossier_age_minutes=dossier_age, + dwell_until=dwell_until, reject_cause=reasons, + ) + + +def build_queue( + prs: Sequence[int], + *, + now: datetime | None = None, + dwell_minutes: float = DEFAULT_DWELL_MINUTES, + stale_after_minutes: float = DEFAULT_STALE_AFTER_MINUTES, + loader: Callable[[int], dict[str, Any]] | None = None, + b0_runner: Callable[[int], tuple[int, str]] | None = None, +) -> tuple[dict[str, Any], bool]: + """Load each PR independently and derive an oldest-first queue.""" + loader = loader or load_snapshot + b0_runner = b0_runner or run_b0 + generated = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + entries: list[QueueEntry] = [] + unknown: list[dict[str, Any]] = [] + for pr in dict.fromkeys(prs): + try: + b0_result = b0_runner(pr) + snapshot = loader(pr) + entries.append(classify_snapshot( + snapshot, now=generated, dwell_minutes=dwell_minutes, + stale_after_minutes=stale_after_minutes, + b0_result=b0_result, + )) + except ( + RuntimeError, KeyError, TypeError, ValueError, OSError, UnicodeError, + json.JSONDecodeError, + ) as exc: + unknown.append({"pr": pr, "error": f"UNKNOWN: {exc}"}) + entries.sort(key=lambda entry: (entry.created_at, entry.pr)) + counts = {status: 0 for status in (READY, BLOCKED, DWELL_PENDING, STALE)} + for entry in entries: + counts[entry.status] += 1 + result = { + "schema": 1, + "generated_at": _iso(generated), + "queue": [entry.to_json() for entry in entries], + "unknown": unknown, + "metrics": { + "received": len(set(prs)), + "classified": len(entries), + "unknown": len(unknown), + **counts, + }, + } + return result, not unknown + + +def consume_pr( + pr: int, + *, + now: datetime | None = None, + dwell_minutes: float = DEFAULT_DWELL_MINUTES, + stale_after_minutes: float = DEFAULT_STALE_AFTER_MINUTES, + loader: Callable[[int], dict[str, Any]] | None = None, + b0_runner: Callable[[int], tuple[int, str]] | None = None, +) -> QueueEntry: + """Re-read every live surface immediately before an exact-head merge.""" + loader = loader or load_snapshot + b0_runner = b0_runner or run_b0 + b0_result = b0_runner(pr) + snapshot = loader(pr) + return classify_snapshot( + snapshot, now=now, dwell_minutes=dwell_minutes, + stale_after_minutes=stale_after_minutes, b0_result=b0_result, + ) + + def review_threads(pr: int) -> list[dict[str, Any]]: query = """ query($owner:String!,$repo:String!,$number:Int!,$cursor:String){ @@ -373,8 +728,8 @@ def _reviews(pr: int) -> list[dict[str, Any]]: def _pr_metadata(pr: int) -> dict[str, Any]: fields = ( - "number,title,body,state,isDraft,baseRefName,headRefOid,updatedAt," - "changedFiles,additions,deletions,statusCheckRollup" + "number,title,body,state,isDraft,baseRefName,headRefOid,createdAt,updatedAt," + "changedFiles,additions,deletions,statusCheckRollup,commits,labels" ) data = gh_json([ "pr", "view", str(pr), "--repo", REPO, "--json", fields, @@ -441,8 +796,25 @@ def render_template(snapshot: dict[str, Any]) -> str: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("pr", type=int, help="pull request number") + parser.add_argument("pr", type=int, nargs="?", help="pull request number") + parser.add_argument( + "--queue", type=int, nargs="+", metavar="PR", + help="derive the oldest-first queue for the listed pull requests", + ) + parser.add_argument( + "--consume", type=int, metavar="PR", + help="re-read one candidate immediately before exact-head merge", + ) parser.add_argument("--json", action="store_true", help="emit machine-readable output") + parser.add_argument( + "--dwell-minutes", type=float, default=DEFAULT_DWELL_MINUTES, + help=f"minimum head age (default: {DEFAULT_DWELL_MINUTES:g})", + ) + parser.add_argument( + "--stale-after-minutes", type=float, + default=DEFAULT_STALE_AFTER_MINUTES, + help=f"maximum dossier age; 0 disables (default: {DEFAULT_STALE_AFTER_MINUTES:g})", + ) parser.add_argument( "--fingerprint", action="store_true", @@ -454,8 +826,36 @@ def main() -> int: help="render a complete dossier template from the live snapshot", ) args = parser.parse_args() + modes = sum((args.pr is not None, args.queue is not None, args.consume is not None)) + if modes != 1: + parser.error("choose exactly one of PR, --queue, or --consume") + if (args.queue is not None or args.consume is not None) and ( + args.template or args.fingerprint + ): + parser.error("--template and --fingerprint require the positional PR mode") + if args.dwell_minutes < 0 or args.stale_after_minutes < 0: + parser.error("age thresholds must be non-negative") + + if args.queue is not None: + result, complete = build_queue( + args.queue, dwell_minutes=args.dwell_minutes, + stale_after_minutes=args.stale_after_minutes, + ) + print(json.dumps(result, ensure_ascii=False)) + return 0 if complete else 2 + + target = args.consume if args.consume is not None else args.pr + assert target is not None try: - snapshot = load_snapshot(args.pr) + if args.consume is not None: + entry = consume_pr( + target, dwell_minutes=args.dwell_minutes, + stale_after_minutes=args.stale_after_minutes, + ) + print(json.dumps({"schema": 1, **entry.to_json()}, ensure_ascii=False)) + return 0 if entry.status == READY else 1 + + snapshot = load_snapshot(target) if args.template: print(render_template(snapshot)) return 0 @@ -472,12 +872,12 @@ def main() -> int: UnicodeError, json.JSONDecodeError, ) as exc: - result = {"pr": args.pr, "ready": False, "errors": [f"UNKNOWN: {exc}"]} - print(json.dumps(result, ensure_ascii=False) if args.json else f"UNKNOWN -- {exc}") + result = {"pr": target, "ready": False, "errors": [f"UNKNOWN: {exc}"]} + print(json.dumps(result, ensure_ascii=False) if args.json or args.consume is not None else f"UNKNOWN -- {exc}") return 2 result = { - "pr": args.pr, + "pr": target, "head": snapshot["headRefOid"], "ready": ready, "errors": errors, @@ -485,9 +885,9 @@ def main() -> int: if args.json: print(json.dumps(result, ensure_ascii=False)) elif ready: - print(f"READY -- PR #{args.pr} prevalidated by adjoint at {snapshot['headRefOid']}") + print(f"READY -- PR #{target} prevalidated by adjoint at {snapshot['headRefOid']}") else: - print(f"BLOCKED -- PR #{args.pr} is not adjoint-prevalidated") + print(f"BLOCKED -- PR #{target} is not adjoint-prevalidated") for error in errors: print(f" - {error}") return 0 if ready else 1 diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 6a8418e204..58fab2468a 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -2,6 +2,7 @@ import importlib.util import sys +from datetime import datetime, timezone from pathlib import Path HERE = Path(__file__).resolve().parent @@ -14,21 +15,36 @@ HEAD = "0123456789abcdef0123456789abcdef01234567" -def _comment(body: str, login: str = "jsboige") -> dict: - return {"author": {"login": login}, "body": body} +def _comment( + body: str, + login: str = "jsboige", + created_at: str = "2026-09-17T09:00:00Z", +) -> dict: + return { + "id": f"comment-{abs(hash(body))}", + "author": {"login": login}, + "createdAt": created_at, + "body": body, + } def _base_snapshot() -> dict: return { "number": 123, "body": "PR body", + "createdAt": "2026-09-16T08:00:00Z", "headRefOid": HEAD, + "commits": [{"oid": HEAD, "committedDate": "2026-09-17T07:00:00Z"}], "state": "OPEN", "title": "PR title", "isDraft": False, "baseRefName": "main", + "labels": [], "comments": [_comment("ordinary earlier comment")], - "reviews": [{"state": "COMMENTED"}, {"state": "APPROVED"}], + "reviews": [ + {"state": "COMMENTED", "commit": {"oid": HEAD}}, + {"state": "APPROVED", "commit": {"oid": HEAD}}, + ], "threads": [{"isResolved": True}], "statusCheckRollup": [{"name": "PR gate", "conclusion": "SUCCESS"}], "changedFiles": 3, @@ -37,22 +53,26 @@ def _base_snapshot() -> dict: } -def _body(**changes: str) -> str: +def _body(source: dict | None = None, **changes: str) -> str: + source = source or _base_snapshot() fields = { "schema": "1", "lane": "myia-po-2025:CoursIA-2", "pr": "123", - "head": HEAD, + "head": source["headRefOid"], "complete": "true", "body": "read", - "comments-reviewed": "1", - "reviews-reviewed": "2", - "threads-reviewed": "1", - "threads-unresolved": "0", - "surfaces-sha256": mod.surfaces_fingerprint(_base_snapshot()), - "diff-files": "3", - "diff-additions": "42", - "diff-deletions": "7", + "comments-reviewed": str(len(source.get("comments") or [])), + "reviews-reviewed": str(len(source.get("reviews") or [])), + "threads-reviewed": str(len(source.get("threads") or [])), + "threads-unresolved": str(sum( + not thread.get("isResolved", False) + for thread in source.get("threads") or [] + )), + "surfaces-sha256": mod.surfaces_fingerprint(source), + "diff-files": str(source["changedFiles"]), + "diff-additions": str(source["additions"]), + "diff-deletions": str(source["deletions"]), "checks": "latest-wins-green", "b0": "clear", "scope": "pass", @@ -77,6 +97,19 @@ def _errors(snapshot: dict) -> list[str]: return errors +def _queue_snapshot(**dossier_fields: str) -> dict: + snapshot = _base_snapshot() + snapshot["comments"].append(_comment( + _body(snapshot, **dossier_fields), + created_at="2026-09-17T09:30:00Z", + )) + return snapshot + + +def _now() -> datetime: + return datetime(2026, 9, 17, 12, 0, tzinfo=timezone.utc) + + def test_exact_head_complete_ready_dossier_passes(): ready, errors = mod.evaluate(_snapshot(_body())) assert ready @@ -269,3 +302,237 @@ def test_ready_dossier_is_evidence_not_merge_authorization(): # The result intentionally has no merge/approve decision or mutation API. assert not hasattr(mod, "merge") assert not hasattr(mod, "approve") + + +def test_queue_typo_sha_is_blocked_never_ready(): + entry = mod.classify_snapshot( + _queue_snapshot(head="f" * 39), now=_now() + ) + assert entry.status == mod.BLOCKED + assert any("40-character SHA" in reason for reason in entry.reject_cause) + + +def test_queue_in_flight_checks_contradict_green_claim(): + snapshot = _base_snapshot() + snapshot["statusCheckRollup"] = [ + {"name": "PR gate", "status": "IN_PROGRESS", "conclusion": None} + ] + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T09:30:00Z" + )) + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.status == mod.BLOCKED + assert entry.checks == "in-flight" + assert "checks in-flight: PR gate" in entry.reject_cause + + +def test_queue_latest_started_check_wins_over_older_completed_success(): + snapshot = _base_snapshot() + snapshot["statusCheckRollup"] = [ + { + "name": "PR gate", "status": "COMPLETED", "conclusion": "SUCCESS", + "startedAt": "2026-09-17T08:00:00Z", + "completedAt": "2026-09-17T11:00:00Z", + }, + { + "name": "PR gate", "status": "IN_PROGRESS", "conclusion": None, + "startedAt": "2026-09-17T10:00:00Z", + }, + ] + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T09:30:00Z" + )) + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.verdict == mod.BLOCKED + assert entry.checks == "in-flight" + + +def test_latest_changes_requested_prevents_qualifying_review(): + snapshot = _base_snapshot() + snapshot["reviews"][1].update({ + "id": "approval", "submittedAt": "2026-09-17T08:00:00Z", + "author": {"login": "reviewer"}, + }) + snapshot["reviews"].append({ + "id": "request", "submittedAt": "2026-09-17T09:00:00Z", + "author": {"login": "reviewer"}, + "state": "CHANGES_REQUESTED", "commit": {"oid": HEAD}, + }) + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T09:30:00Z" + )) + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.verdict == mod.BLOCKED + assert not entry.review_qualifying + + +def test_later_approval_supersedes_same_reviewers_change_request(): + snapshot = _base_snapshot() + snapshot["reviews"] = [ + { + "id": "request", "submittedAt": "2026-09-17T08:00:00Z", + "author": {"login": "reviewer"}, "state": "CHANGES_REQUESTED", + "commit": {"oid": HEAD}, + }, + { + "id": "approval", "submittedAt": "2026-09-17T09:00:00Z", + "author": {"login": "reviewer"}, "state": "APPROVED", + "commit": {"oid": HEAD}, + }, + ] + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T09:30:00Z" + )) + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.verdict == mod.READY + assert entry.review_qualifying + + +def test_queue_dossier_not_last_comment_is_stale_and_exposes_tail(): + snapshot = _queue_snapshot() + snapshot["comments"].append(_comment( + "new concern", created_at="2026-09-17T10:00:00Z" + )) + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.status == mod.STALE + assert not entry.last_comment_is_dossier + assert [row["body"] for row in entry.tail_to_read] == ["new concern"] + + +def test_queue_dwell_pending_until_floor_elapses(): + snapshot = _queue_snapshot() + snapshot["commits"] = [{"oid": HEAD, "committedDate": "2026-09-17T11:30:00Z"}] + pending = mod.classify_snapshot(snapshot, now=_now()) + ready = mod.classify_snapshot( + snapshot, + now=datetime(2026, 9, 17, 13, 31, tzinfo=timezone.utc), + ) + assert pending.status == mod.DWELL_PENDING + assert pending.dwell_until == "2026-09-17T13:30:00Z" + assert ready.status == mod.READY + + +def test_dwell_waiver_label_allows_ready_and_is_fingerprinted(): + snapshot = _base_snapshot() + snapshot["commits"] = [{"oid": HEAD, "committedDate": "2026-09-17T11:30:00Z"}] + snapshot["labels"] = [{"name": mod.DWELL_WAIVER_LABEL}] + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T11:40:00Z" + )) + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.verdict == mod.READY + snapshot["labels"] = [] + assert mod.classify_snapshot(snapshot, now=_now()).verdict == mod.DWELL_PENDING + + +def test_head_timestamp_must_belong_to_exact_head(): + snapshot = _queue_snapshot() + snapshot["commits"] = [ + {"oid": "f" * 40, "committedDate": "2026-09-01T00:00:00Z"} + ] + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.verdict == mod.BLOCKED + assert any("head commit timestamp unavailable" in reason for reason in entry.reject_cause) + + +def test_dossier_age_becomes_stale_and_zero_disables_age_guard(): + snapshot = _base_snapshot() + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-15T00:00:00Z" + )) + stale = mod.classify_snapshot(snapshot, now=_now()) + disabled = mod.classify_snapshot( + snapshot, now=_now(), stale_after_minutes=0 + ) + assert stale.verdict == mod.STALE + assert any("stale by age" in reason for reason in stale.reject_cause) + assert disabled.verdict == mod.READY + + +def test_latest_failed_check_blocks_queue(): + snapshot = _base_snapshot() + snapshot["statusCheckRollup"] = [ + {"name": "PR gate", "status": "COMPLETED", "conclusion": "FAILURE"} + ] + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T09:30:00Z" + )) + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.verdict == mod.BLOCKED + assert entry.checks == "not-green" + + +def test_queue_unresolved_b0_is_blocked(): + entry = mod.classify_snapshot( + _queue_snapshot(b0="blocked", verdict="BLOCKED"), + now=_now(), b0_result=(1, "BLOCKED"), + ) + assert entry.status == mod.BLOCKED + assert entry.b0_rc == 1 + assert any("b0 must" in reason for reason in entry.reject_cause) + + +def test_live_b0_nonzero_blocks_even_when_dossier_claims_clear(): + entry = mod.classify_snapshot( + _queue_snapshot(), now=_now(), b0_result=(1, "BLOCKED -- live nit") + ) + assert entry.verdict == mod.BLOCKED + assert entry.b0_rc == 1 + assert any("B.0 organ blocked" in reason for reason in entry.reject_cause) + + +def test_stacked_pr_domain_not_applicable_stays_ready(): + snapshot = _base_snapshot() + snapshot["baseRefName"] = "feature/base-stack" + snapshot["comments"].append(_comment( + _body(snapshot, domain="not-applicable"), + created_at="2026-09-17T09:30:00Z", + )) + entry = mod.classify_snapshot(snapshot, now=_now()) + assert entry.status == mod.READY + + +def test_build_queue_sorts_oldest_first_and_counts_statuses(): + newer = _base_snapshot() + newer["number"] = 124 + newer["createdAt"] = "2026-09-16T09:00:00Z" + newer["comments"].append(_comment( + _body(newer, pr="124"), created_at="2026-09-17T09:30:00Z" + )) + older = _queue_snapshot() + older["createdAt"] = "2026-09-15T09:00:00Z" + snapshots = {123: older, 124: newer} + result, complete = mod.build_queue( + [124, 123, 124], now=_now(), loader=snapshots.__getitem__, + b0_runner=lambda _pr: (0, "OK"), + ) + assert complete + assert [entry["pr"] for entry in result["queue"]] == [123, 124] + assert all(entry["verdict"] == mod.READY for entry in result["queue"]) + assert result["metrics"]["received"] == 2 + assert result["metrics"][mod.READY] == 2 + + +def test_consume_rereads_live_and_fails_closed_on_head_move(): + snapshot = _queue_snapshot() + calls = 0 + + def loader(_pr: int) -> dict: + nonlocal calls + calls += 1 + if calls == 1: + return snapshot + moved = dict(snapshot) + moved["headRefOid"] = "f" * 40 + return moved + + queue, complete = mod.build_queue( + [123], now=_now(), loader=loader, b0_runner=lambda _pr: (0, "OK") + ) + consumed = mod.consume_pr( + 123, now=_now(), loader=loader, b0_runner=lambda _pr: (0, "OK") + ) + assert complete and queue["queue"][0]["verdict"] == mod.READY + assert calls == 2 + assert consumed.status == mod.STALE + assert any("head is stale" in reason for reason in consumed.reject_cause) From 4326443a112a7888b531b8f14e8a234965fb02b4 Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 17 Sep 2026 04:41:18 +0200 Subject: [PATCH 2/4] fix(coordination): isolate B0 subprocess failures Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check_adjoint_prevalidation.py | 2 +- .../tests/test_check_adjoint_prevalidation.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 740b6be2d8..6b4a820043 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -603,7 +603,7 @@ def build_queue( )) except ( RuntimeError, KeyError, TypeError, ValueError, OSError, UnicodeError, - json.JSONDecodeError, + json.JSONDecodeError, subprocess.SubprocessError, ) as exc: unknown.append({"pr": pr, "error": f"UNKNOWN: {exc}"}) entries.sort(key=lambda entry: (entry.created_at, entry.pr)) diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 58fab2468a..2285489435 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -1,6 +1,7 @@ """Causal tests for the adjoint prevalidation entry gate (#16442).""" import importlib.util +import subprocess import sys from datetime import datetime, timezone from pathlib import Path @@ -513,6 +514,39 @@ def test_build_queue_sorts_oldest_first_and_counts_statuses(): assert result["metrics"][mod.READY] == 2 +def test_b0_timeout_classifies_unknown_without_aborting_batch(): + surviving = _base_snapshot() + surviving["number"] = 124 + surviving["comments"].append(_comment( + _body(surviving, pr="124"), created_at="2026-09-17T09:30:00Z" + )) + snapshots = {124: surviving} + + def b0_runner(pr: int) -> tuple[int, str]: + if pr == 123: + raise subprocess.TimeoutExpired("check_unaddressed_nits.py", 120) + return 0, "OK" + + result, complete = mod.build_queue( + [123, 124], now=_now(), loader=snapshots.__getitem__, + b0_runner=b0_runner, + ) + + assert not complete + assert [entry["pr"] for entry in result["queue"]] == [124] + assert result["queue"][0]["verdict"] == mod.READY + assert result["unknown"] == [{ + "pr": 123, + "error": ( + "UNKNOWN: Command 'check_unaddressed_nits.py' timed out " + "after 120 seconds" + ), + }] + assert result["metrics"]["received"] == 2 + assert result["metrics"]["classified"] == 1 + assert result["metrics"]["unknown"] == 1 + + def test_consume_rereads_live_and_fails_closed_on_head_move(): snapshot = _queue_snapshot() calls = 0 From 61bc853912e5bf7343fb1adcce832e9f0c32b2be Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 17 Sep 2026 12:40:32 +0200 Subject: [PATCH 3/4] feat(coordination): expose review-ready queue Separate coordinator-owned exact-head review from external blockers while keeping the adjoint's preparation pipeline continuous. Preserve the live review disposition so current change requests are not collapsed into an unreviewed state. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/coordinate/SKILL.md | 19 ++++--- docs/reference/scripts-reference.md | 2 +- scripts/check_adjoint_prevalidation.py | 47 ++++++++++++---- .../tests/test_check_adjoint_prevalidation.py | 53 ++++++++++++++++++- 4 files changed, 102 insertions(+), 19 deletions(-) diff --git a/.claude/skills/coordinate/SKILL.md b/.claude/skills/coordinate/SKILL.md index 21e86ffd08..05bf695753 100644 --- a/.claude/skills/coordinate/SKILL.md +++ b/.claude/skills/coordinate/SKILL.md @@ -29,16 +29,23 @@ Cycle de coordination du cluster CoursIA. **Reserve au coordinateur ai-01** : un Les phases A-D s'executent dans cet ordre sous le budget total de 1 h a 1 h 30. La file READY est preparee inter-cycle : la reconstruire par un sweep manuel au debut annule le travail de l'adjoint. -### Phase A - Deriver la queue READY une fois +### Phase A - Deriver les deux files executables une fois 1. Lire l'index memoire et `coordinator-durable-state.md`, puis les deux dashboards workspace et l'inbox `deep:true`. Relever les non-lus et les PRs en attente avant toute lecture de fichier. 2. Verifier le cron unique. Ne pas bouger le checkout partage s'il est sale : travailler depuis `main` frais dans un worktree isole si necessaire. -3. Extraire les numeros explicitement prepares par l'adjoint et lancer **une fois** : `python scripts/check_adjoint_prevalidation.py --queue `. La sortie JSON est une queue **derivee**, oldest-first, sans registre persistant a invalider. -4. `READY` entre en Phase B. `DWELL_PENDING` attend son `dwell_until`. `STALE` et `BLOCKED` sortent immediatement de la passe avec leur `reject_cause`, `tail_to_read`, `WAIT_FOR` et `RESUME_WHEN`. Exit 2 est UNKNOWN fail-closed. +3. Extraire les numeros explicitement prepares par l'adjoint et lancer **une fois** : `python scripts/check_adjoint_prevalidation.py --queue `. La sortie JSON est derivee oldest-first, sans registre persistant. +4. `REVIEW_READY` entre en Phase B0 : toutes les portes prémâchées sont vertes, seule la review exact-head manque. `READY`/`MERGE_READY` entre en Phase B1. `DWELL_PENDING`, `STALE` et les vrais `BLOCKED` portent leur événement de reprise. Exit 2 est UNKNOWN fail-closed. +5. **L'adjoint est un producteur continu, jamais un waiter.** Après avoir émis `REVIEW_READY`, il poursuit les candidates suivantes, refreshs, extensions et préparations conditionnelles. Il consomme les reviews ai-01 comme événements entrants et republie les dossiers finals, sans suspendre sa cadence ni attendre la fin de Phase B0. -### Phase B - Consommer et merger en rafale +### Phase B0 - Reviewer en rafale la file REVIEW_READY -1. Parcourir les `READY` oldest-first. Juste avant la lecture personnelle, lancer `python scripts/check_adjoint_prevalidation.py --consume ` : cette commande relit toutes les surfaces live et ne reutilise aucun cache de `--queue`. +1. Parcourir `REVIEW_READY` oldest-first. Lire personnellement body, tous les commentaires, corps/états des reviews, threads et diff ; exploiter le dossier prémâché sans rejouer les audits déjà étayés. +2. Poser la disposition formelle exact-head immédiatement (APPROVED ou CHANGES_REQUESTED motivée). Une absence d'approval n'est jamais redispatchée comme attente worker : c'est du travail local ai-01. +3. Chaque APPROVED est un événement pour l'adjoint, qui finalise le dossier en parallèle. Continuer la rafale sans attendre chaque republication individuellement ; les dossiers finals alimentent Phase B1 dès qu'ils arrivent. + +### Phase B1 - Consommer et merger en rafale + +1. Parcourir les `READY` oldest-first, y compris celles générées pendant Phase B0. Juste avant la lecture personnelle, lancer `python scripts/check_adjoint_prevalidation.py --consume ` : cette commande relit toutes les surfaces live et ne reutilise aucun cache de `--queue`. 2. **Exit 0 seulement** ouvre la lecture B.0 personnelle minimale : body, commentaires, corps/etats des reviews, threads et diff. Lire `tail_to_read`, le delta et la preuve decisive ; ne pas rejouer l'audit complet de l'adjoint, d'Hermes ou de NanoClaw. 3. Repasser `python scripts/check_unaddressed_nits.py `, latest-wins CI, H.4, catalogue byte-identique a main, scope, stack et variation. Un preflight READY n'autorise jamais a lui seul le merge. 4. Merger avec le `head` retourne par **ce** `--consume` : `gh pr merge --repo jsboige/CoursIA --squash --match-head-commit `. Jamais `--delete-branch`. Recalculer la variation apres chaque merge pertinent. @@ -46,7 +53,7 @@ Les phases A-D s'executent dans cet ordre sous le budget total de 1 h a 1 h 30. ### Phase C - Dispatcher massivement les sorties et le travail lourd -1. Regrouper `STALE`/`BLOCKED` et les rouges par lane ; citer la cause exacte et l'evenement de reprise attendu. Les dossiers invalides repartent vers l'adjoint, les reparations et re-reviews vers leur lane proprietaire. +1. Regrouper `STALE`/vrais `BLOCKED` et les rouges par lane ; citer la cause exacte et l'événement de reprise attendu. **Ne jamais dispatcher une simple absence d’APPROVED** : elle appartient à `REVIEW_READY` et à la rafale locale ai-01. Les dossiers invalides repartent vers l'adjoint, les réparations vers leur lane propriétaire. 2. Grounder chaque grain firsthand, poser le claim GitHub et utiliser le double canal DM + pointeur dashboard. Une lane sans grain recoit une deep-queue ou un fallback perenne ; elle n'attend jamais une candidate en HOLD, DWELL, CI ou review. 3. Les investigations lourdes, tests, builds et reconciliations sont dispatches maintenant afin que le prochain cycle retrouve une queue actionnable. Après la rafale seulement, exécuter `python scripts/pick_idle_grain.py --orphans-report` : toute PR hors radar de l'adjoint reçoit alors un propriétaire ou un événement de reprise sans retarder le premier merge. Mettre a jour la memoire durable si son etat a change. diff --git a/docs/reference/scripts-reference.md b/docs/reference/scripts-reference.md index 92fd2f8ab7..94995ff914 100644 --- a/docs/reference/scripts-reference.md +++ b/docs/reference/scripts-reference.md @@ -189,7 +189,7 @@ Pipeline d'audit qualité et de **matrice de coût** (EPIC #8056) + audit séman | `scripts/lean/check_mathlib_cache.py` | **Vérifie qu'un cache Mathlib est réellement atteignable** pour chaque lake, en traversant les junctions (`realpath` + `os.walk`) — `find` renvoie **0** sur un cache sain de 8124 oleans et `os.path.islink()` renvoie **False** sur une junction, combinaison qui fabrique un faux verdict « cache purgé » (5 cycles de lane Lean perdus, 2026-07-29). Statuts `ok`/`partial`/`cold`/`absent`, dédoublonne les lakes partageant un cache physique. Advisory (exit 0) ; `--strict` exit 1. La preuve décisive reste un `lake build` réel | | `scripts/lean/setup_shared_mathlib.ps1` | Mutualisation checkouts Mathlib via junctions NTFS (#2611) : `-Mode Scan` (inventaire groupes), `Apply` (cache `.mathlib-cache/` + junctions, `-Build` vérifie, `-RemoveBackups` libère l'espace), `Rollback` (restaure les checkouts physiques). Précondition : lake-manifest.json identique sur TOUTES les deps transitives + même lean-toolchain. Ne jamais `lake update` un projet junctionné | | `scripts/check_grain_free.py` | **Grounding pré-dispatch** (une commande avant d'écrire un steer) : agrège l'état de l'issue, les **contraintes de forme** de son acceptance (cases `- [ ]` portant une négation — c'est ce que #10990 interdisait), les **PRs ouvertes** et **mergées** la référençant, et délègue les claims à `check_lane_claim.py` (autorité). `--lane` obligatoire. Exit `0` CLEAR / `1` FLAGGED / `2` UNKNOWN — une surface non interrogeable rend **UNKNOWN**, jamais une liste vide : « rien trouvé » et « pas regardé » ne partagent pas de valeur de retour. Complète `check_lane_claim.py --paths`, qui exige de connaître les chemins — ce qu'on n'a pas encore au moment de dispatcher | -| `scripts/check_adjoint_prevalidation.py` | **Gate d'entrée en review ai-01** (#16442, #16480) : exige un dossier `[ADJOINT PREFLIGHT]` complet de la lane adjointe, lié au SHA exact et à une empreinte des surfaces body/comments/reviews/threads/checks. `--queue ` dérive sans état persistant une file oldest-first (`READY` / `BLOCKED` / `DWELL_PENDING` / `STALE`) avec cause, tail, review exact-head, checks et métriques ; `--consume ` relit toutes les surfaces live sans cache juste avant le merge exact-head. Toute absence, mutation observable ou valeur non canonique échoue fermé ; un événement GitHub ensuite supprimé/reverté n'est pas reconstructible par cet organe stateless. `--template` génère le bloc complet et `--fingerprint` expose l'empreinte seule. Exit `0` READY/queue complète, `1` non-READY, `2` UNKNOWN. READY autorise la lecture finale du coordinateur, jamais le merge | +| `scripts/check_adjoint_prevalidation.py` | **Gate d'entrée en review ai-01** (#16442, #16480) : exige un dossier `[ADJOINT PREFLIGHT]` complet de la lane adjointe, lié au SHA exact et à une empreinte des surfaces body/comments/reviews/threads/checks. `--queue ` dérive sans état persistant une file oldest-first (`READY` / `REVIEW_READY` / `BLOCKED` / `DWELL_PENDING` / `STALE`) avec cause, tail, review exact-head, checks et métriques. `REVIEW_READY` signifie que toutes les portes prémâchées sont vertes et que seule la disposition formelle exact-head d'ai-01 manque : cette entrée alimente une rafale locale de reviews, jamais un redispatch worker ni une attente de l'adjoint. `--consume ` relit toutes les surfaces live sans cache juste avant le merge exact-head. Toute absence, mutation observable ou valeur non canonique échoue fermé ; un événement GitHub ensuite supprimé/reverté n'est pas reconstructible par cet organe stateless. `--template` génère le bloc complet et `--fingerprint` expose l'empreinte seule. Exit `0` READY/queue complète, `1` non-READY, `2` UNKNOWN. READY autorise la lecture finale du coordinateur, jamais le merge | | `scripts/check_pr_perimeter.py` | **Source de vérité périmètre pour reviews** (#11268) : énumère les fichiers effectifs d'une PR (`gh pr view --json files`), nomme tout `.github/workflows/**` dans une section dédiée, détecte les **mouvements de baseline/seuil** du diff avec leur sens (`sorry-baseline` 16→14 = TIGHTEN ; un desserrement sans `--baseline-justified` => CHANGES_REQUESTED), et confronte l'assertion de périmètre du reviewer (`--assert "..."`) à la liste réelle — la review #11227 (« 2 fichiers twins uniquement » sur 3 fichiers dont un workflow) ne peut plus être produite à l'insu. `--scan-thread` scanne le body PR + les reviews top-level et confronte chaque assertion trouvée à la liste effective ; câblé par `.github/workflows/perimeter-review-guard.yml` (déclenché sur `pull_request` + `pull_request_review`) — une fausse assertion devient un check rouge bloquant. Exit `0` OK / `1` FAIL (écart assertion, workflow non nommé, desserrement nu) / `2` erreur gh. À exécuter AVANT toute assertion de périmètre dans une review | | `scripts/ci/measure_runner_demand.py` | **Baseline exhaustive de demande GitHub Actions** (#12704) : collecte une fenêtre UTC avec bisection anti-cap 1 000 + pagination de tous les jobs, mesure attente (`started_at-created_at`), travail runner (`completed_at-started_at`), provenance same-repo/fork/unknown et dénominateurs ; replay offline par `--input`. Exit `0` mesure valide / `2` instrument ou snapshot incomplet. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) | | `scripts/ci/manage_self_hosted_runner.py` + `self_hosted_runner_profiles.json` | **Cycle de vie Windows des runners éphémères isolés** (#12704) : profils distribués po-2023..po-2026 avec archive/SHA-256 épinglés ; commandes `install`, `register`, `verify`, `teardown` en dry-run par défaut, mutations uniquement avec `--apply`; compte local dédié, ACL négatives `.secrets`/SSH/gh, tokens via `ACTIONS_RUNNER_INPUT_*`, extraction anti-Zip-Slip/ADS et teardown borné par manifeste. `register --apply` est le bouton d’activation séparé, jamais lancé pendant la préparation. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) | diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 6b4a820043..7f51c16c41 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -56,6 +56,7 @@ END = "[/ADJOINT PREFLIGHT]" SHA_RE = re.compile(r"[0-9a-f]{40}") READY = "READY" +REVIEW_READY = "REVIEW_READY" BLOCKED = "BLOCKED" DWELL_PENDING = "DWELL_PENDING" STALE = "STALE" @@ -117,6 +118,7 @@ class QueueEntry: b0_rc: int | None checks: str review_qualifying: bool + review_disposition: str grain_tag: str | None last_comment_is_dossier: bool tail_to_read: list[dict[str, Any]] @@ -413,8 +415,7 @@ def _checks_summary(snapshot: dict[str, Any]) -> tuple[str, list[str]]: return "latest-wins-green", [] -def _qualifying_review(snapshot: dict[str, Any]) -> bool: - head = snapshot.get("headRefOid") +def _latest_reviews(snapshot: dict[str, Any]) -> list[dict[str, Any]]: latest_by_author: dict[str, tuple[tuple[str, str], dict[str, Any]]] = {} for index, review in enumerate(snapshot.get("reviews") or []): author = (review.get("author") or {}).get("login", "") or f"unknown-{index}" @@ -422,14 +423,26 @@ def _qualifying_review(snapshot: dict[str, Any]) -> bool: current = latest_by_author.get(author) if current is None or order > current[0]: latest_by_author[author] = (order, review) - latest = [item[1] for item in latest_by_author.values()] + return [item[1] for item in latest_by_author.values()] + + +def _review_disposition(snapshot: dict[str, Any]) -> str: + head = snapshot.get("headRefOid") + latest = _latest_reviews(snapshot) if any(review.get("state") == "CHANGES_REQUESTED" for review in latest): - return False - return any( - review.get("state") == "APPROVED" - and (review.get("commit") or {}).get("oid") == head - for review in latest - ) + return "changes-requested" + approvals = [review for review in latest if review.get("state") == "APPROVED"] + if any((review.get("commit") or {}).get("oid") == head for review in approvals): + return "approved-exact-head" + if approvals: + return "approval-not-on-head" + if latest: + return "reviewed-without-disposition" + return "unreviewed" + + +def _qualifying_review(snapshot: dict[str, Any]) -> bool: + return _review_disposition(snapshot) == "approved-exact-head" def _grain_tag(snapshot: dict[str, Any]) -> str | None: @@ -478,7 +491,8 @@ def classify_snapshot( head = str(snapshot.get("headRefOid") or "") live_fingerprint = surfaces_fingerprint(snapshot) checks, check_errors = _checks_summary(snapshot) - review_qualifying = _qualifying_review(snapshot) + review_disposition = _review_disposition(snapshot) + review_qualifying = review_disposition == "approved-exact-head" if not dossiers: return QueueEntry( @@ -487,6 +501,7 @@ def classify_snapshot( surfaces_sha256=live_fingerprint, b0_rc=b0_result[0] if b0_result is not None else None, checks=checks, review_qualifying=review_qualifying, + review_disposition=review_disposition, grain_tag=_grain_tag(snapshot), last_comment_is_dossier=False, tail_to_read=[], dossier_age_minutes=None, dwell_until=None, reject_cause=["no [ADJOINT PREFLIGHT] dossier comment found"], @@ -553,8 +568,14 @@ def classify_snapshot( dwell_until = _iso(floor) dwell_pending = not dwell_waived and now < floor + review_reason = "no qualifying APPROVED review on exact head" if stale_reasons: status = STALE + elif reasons == [review_reason]: + # The adjoint has completed every delegable preflight surface. Formal + # exact-head review is coordinator work, not an external blocker and + # not a reason to stop the adjoint's continuous preparation pipeline. + status = REVIEW_READY elif reasons: status = BLOCKED elif dwell_pending: @@ -570,6 +591,7 @@ def classify_snapshot( surfaces_sha256=dossier.fields.get("surfaces-sha256", ""), b0_rc=b0_rc, checks=checks, review_qualifying=review_qualifying, + review_disposition=review_disposition, grain_tag=_grain_tag(snapshot), last_comment_is_dossier=dossier.comment_index == len(comments) - 1, tail_to_read=tail, dossier_age_minutes=dossier_age, @@ -607,7 +629,10 @@ def build_queue( ) as exc: unknown.append({"pr": pr, "error": f"UNKNOWN: {exc}"}) entries.sort(key=lambda entry: (entry.created_at, entry.pr)) - counts = {status: 0 for status in (READY, BLOCKED, DWELL_PENDING, STALE)} + counts = { + status: 0 + for status in (READY, REVIEW_READY, BLOCKED, DWELL_PENDING, STALE) + } for entry in entries: counts[entry.status] += 1 result = { diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 2285489435..7809982025 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -348,6 +348,39 @@ def test_queue_latest_started_check_wins_over_older_completed_success(): assert entry.checks == "in-flight" +def test_only_missing_exact_head_approval_is_review_ready(): + snapshot = _base_snapshot() + snapshot["reviews"] = [] + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T09:30:00Z" + )) + + entry = mod.classify_snapshot(snapshot, now=_now()) + + assert entry.verdict == mod.REVIEW_READY + assert not entry.review_qualifying + assert entry.review_disposition == "unreviewed" + assert entry.reject_cause == [ + "no qualifying APPROVED review on exact head" + ] + + +def test_review_ready_remains_visible_while_dwell_runs(): + snapshot = _base_snapshot() + snapshot["reviews"] = [] + snapshot["commits"] = [{ + "oid": HEAD, "committedDate": "2026-09-17T11:30:00Z" + }] + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T11:40:00Z" + )) + + entry = mod.classify_snapshot(snapshot, now=_now()) + + assert entry.verdict == mod.REVIEW_READY + assert entry.dwell_until == "2026-09-17T13:30:00Z" + + def test_latest_changes_requested_prevents_qualifying_review(): snapshot = _base_snapshot() snapshot["reviews"][1].update({ @@ -363,8 +396,26 @@ def test_latest_changes_requested_prevents_qualifying_review(): _body(snapshot), created_at="2026-09-17T09:30:00Z" )) entry = mod.classify_snapshot(snapshot, now=_now()) - assert entry.verdict == mod.BLOCKED + assert entry.verdict == mod.REVIEW_READY assert not entry.review_qualifying + assert entry.review_disposition == "changes-requested" + + +def test_approval_on_prior_head_is_distinct_review_ready_disposition(): + snapshot = _base_snapshot() + snapshot["reviews"] = [{ + "id": "approval", "submittedAt": "2026-09-17T08:00:00Z", + "author": {"login": "reviewer"}, "state": "APPROVED", + "commit": {"oid": "1" * 40}, + }] + snapshot["comments"].append(_comment( + _body(snapshot), created_at="2026-09-17T09:30:00Z" + )) + + entry = mod.classify_snapshot(snapshot, now=_now()) + + assert entry.verdict == mod.REVIEW_READY + assert entry.review_disposition == "approval-not-on-head" def test_later_approval_supersedes_same_reviewers_change_request(): From 34aa1606b86e2f057890421bf0daa7970e97fa6c Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 17 Sep 2026 12:51:45 +0200 Subject: [PATCH 4/4] fix(coordination): trust grain tag from PR body Prevent bot comments and dossier prose from overriding or manufacturing the PR author's authoritative Grain declaration. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check_adjoint_prevalidation.py | 10 +++------ .../tests/test_check_adjoint_prevalidation.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 7f51c16c41..cdc779012f 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -446,14 +446,10 @@ def _qualifying_review(snapshot: dict[str, Any]) -> bool: def _grain_tag(snapshot: dict[str, Any]) -> str | None: - texts = [snapshot.get("body") or ""] - texts.extend(comment.get("body") or "" for comment in snapshot.get("comments") or []) + """Return the PR author's authoritative grain declaration from the body.""" pattern = re.compile(r"(?im)^\s*Grain:\s*(.+?)\s*$") - for text in reversed(texts): - match = pattern.search(text) - if match: - return match.group(1) - return None + match = pattern.search(snapshot.get("body") or "") + return match.group(1) if match else None def _head_committed_at(snapshot: dict[str, Any]) -> datetime | None: diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 7809982025..7637597c89 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -348,6 +348,27 @@ def test_queue_latest_started_check_wins_over_older_completed_success(): assert entry.checks == "in-flight" +def test_grain_tag_comes_only_from_pr_body(): + snapshot = _base_snapshot() + snapshot["body"] = "Grain: MED/guard — lane author-lane" + snapshot["comments"].append(_comment( + "Bot instructions:\nGrain: / -- lane placeholder" + )) + + entry = mod.classify_snapshot(snapshot, now=_now()) + + assert entry.grain_tag == "MED/guard — lane author-lane" + + +def test_comment_cannot_create_a_missing_grain_tag(): + snapshot = _base_snapshot() + snapshot["comments"].append(_comment("Grain: DEEP/notebook-python -- lane bot")) + + entry = mod.classify_snapshot(snapshot, now=_now()) + + assert entry.grain_tag is None + + def test_only_missing_exact_head_approval_is_review_ready(): snapshot = _base_snapshot() snapshot["reviews"] = []