Repository navigation
Fix(#21742): check_lane_claim -- enumeration complete des PRs ouvertes + verdict UNKNOWN fail-closed - #21749
Conversation
…s + verdict UNKNOWN fail-closed
Le mode `--paths` rendait un faux "Path is free" sur des chemins detenus par des
PRs ouvertes. Deux truncations silencieuses, mesurees sur 362 PRs ouvertes :
- la LISTE des PRs : `gh pr list --limit 200` -> 162 PRs invisibles ;
- la liste de FICHIERS d'une PR : `--json files` plafonne a 100 chemins
(positions mesurees : 204/323 sur #21413, 422/597 sur #21555).
Une borne n'est pas une mesure : la completude est desormais ETABLIE par
pagination GraphQL (jusqu'a `hasNextPage == false`) + refetch REST des seules
PRs que `files.totalCount` designe comme plafonnees (en parallele, nombre de
chemins controle contre le total). Quand la completude ne peut pas etre etablie
(plafond, budget de temps, reponse en erreur, ecart de comptage), l'enumeration
leve `OpenPrsIncompleteError` et les appelants rendent `UNKNOWN` (exit 1) --
fail-closed, jamais "CLEAR".
Le cache #19643 passe sous la meme regle : format v2 autodescriptif (completude
explicite) ; une liste nue (format heritage, issue de la fenetre tronquee) n'est
plus une preuve de liberte -- elle est ignoree, et le fetch complet la reecrit au
format v2 (ecriture atomique). Memo de processus : le fetch est unique par
processus, donc le mode batch ne paie plus N fetches.
Avant/apres mesure sur le meme chemin (detenu par la PR ouverte #20802) :
avant -> "CLEAR: no OPEN PR of another lane touches [...]" (rc 0)
apres -> BLOCKED, collision #20802 (rc 2)
Sur l'issue #21742 elle-meme, l'ancien organe ne voyait aucune collision
(`open_pr_collisions: []`) la ou le corrige voit #21413 et #21555 -- un
recouvrement de contenu nul (patch #19643 deja merge dans main) que la
troncature cachait.
Tests : 11 tests pines (pagination multi-pages, refetch des PRs plafonnees,
ecart de comptage, plafond de securite, cache heritage non trusted, cache v2
trusted, cache v2 incomplet, verdict UNKNOWN en mode `--paths` et
`--open-prs-on`, memo, instance fondatrice bout en bout) + 6 tests existants
adaptes en conservant leur intention (cache sans appel `gh`, encodage cp1252,
borne de timeout).
Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com>
|
No organ-duplication: no added def/class collides with another series organ API (scripts/audit/organ_api_index.yaml). Detector: |
clusterManager-Myia
left a comment
There was a problem hiding this comment.
[Hermes] Review — fix de l'organe de verrou check_lane_claim.py
Rejeu intégral du head (fonds vérifiés)
Instance fondatrice reproduite en live. Sur le chemin ICT-48-WolframClasses-ThermoSignature-Python.ipynb (détenu par #20802, hors fenêtre) : organe base (main) → CLEAR, rc 0 (le faux libre, reproduit) ; organe head (73f3147) → BLOCKED, rc 2, collisions #20802 + #20921 + #20962 listées. Le verdict du body (CLEAR/0 → BLOCKED/2) est exact, mesuré de première main.
Refetch des PRs plafonnées vérifié par collision réelle. --paths scripts/check_lane_claim.py au head rend #21413 et #21555 (listes de fichiers >100 tronquées à la base) + #21749 — la base ne voyait QUE #21749. La détection des PRs à liste plafonnée fonctionne sur le dépôt réel.
Verdict UNKNOWN fail-closed prouvé par exécution. Cache v2 complete: false → rc 1, UNKNOWN au stderr, "verdict": "UNKNOWN", "complete": false dans le JSON, aucun CLEAR — exactement le contrat annoncé. Cache héritage (liste nue) → non trusted, fetch complet exécuté, fichier réécrit en v2 ({"format": "open-prs-v2", "complete": true} avec 353 PRs dont #20802 et #21749) — mesure vivante de la réécriture atomique.
Suite rejouée : 343 passed + 13 failed + 1 skipped. Les 13 échecs sont hérités de main (set diff base↔head sur liste de tests échoués : identique 13/13, 0 introduit) et artefacts de mon extraction partielle (les mêmes 13 rougissent sur l'organe main dans le même environnement ; ils exigent une racine git complète). Le delta réel de la PR : +12 tests passants (324→336), 357 collectés au head = le compte du body (356 passed + 1 skipped en environnement complet).
Pool mesuré : 353 open (Link header rel="last" page=353), le body dit 362 — mesuré à un autre moment, écart plausible (PRs mergées/fermées entre-temps), pas un défaut.
Bloquant : la garde perimeter lit le body comme une assertion de périmètre
Always-on guards -- 16 organes est FAILURE au head 73f3147 (PR gate rouge en agrégat) : perimeter review guard (#11268) rend rc=1 — « l'assertion pretend 1070 fichier(s), la liste effective en compte 2 » et « pretend 323 fichier(s) ». Rejoué en local avec l'organe du head (check_pr_perimeter.py 21749 --scan-thread) : reproduit à l'identique, rc=1.
Cause : deux formulations du body décrivent des PRs externes mais ne remplissent aucune exemption du garde :
- « une PR à 1070 fichiers n'en montrait que 100 » (§ truncations) ;
- « 323 lignes vides pour 323 fichiers » (note d'outillage).
Les formes parenthésées (« #21413 (323 fichiers) », « #21555 (597 fichiers) ») sont correctement exemptées par _count_is_other_pr (parenthèse suivant une réf #N ≤20 chars) — mais pas ces deux-là. Le remède est rédactionnel et mécanique (remède éprouvé de la classe #21390) : mettre les comptes sous la forme exemptée, p. ex. « une PR (#20687) à 1070 fichiers » ou « #20687 : 1070 fichiers », et « 323 lignes vides — #21413 (323 fichiers) ».
Le merge de main (44c9624) laisse les 2 fichiers de la PR byte-identiques (blob-sha e73a71a3/cfd299c9 vérifiés aux deux refs) — le contenu est stable, seul le body doit être ajusté puis le CI rejoué.
Verdict
CHANGES_REQUESTED sur le seul point body/perimeter : le correctif lui-même est authentique (instance fondatrice + refetch + UNKNOWN fail-closed tous reproduits par exécution sur le dépôt réel), les 12 tests neufs passent, 0 échec introduit. Le body doit être reformulé (comptes d'autres PRs sous forme exemptée) et la jambe Always-on guards relancée — le fond est prêt.
— Hermes (myia-ai-01:hermes-agent), review au head 44c9624 (blobs 73f3147).
jsboige
left a comment
There was a problem hiding this comment.
Re-evaluation du perimetre sur le corps corrige.
Les comptes que le corps citait portaient sur les listes de fichiers d'autres PR (#20687, #21413, #21555), pas sur la liste effective de cette PR. Ils sont desormais exprimes en « chemins » et la liste reelle de cette PR est enoncee chemin par chemin, sans cardinal.
python scripts/check_pr_perimeter.py 21749 --scan-thread sur le corps courant : VERDICT: OK.
Ce commentaire ne porte aucune approbation : il sert a declencher la re-evaluation de l'organe perimeter review guard (#11268), dont le workflow n'ecoute que l'evenement de revue (le declencheur pull_request decrit dans l'en-tete du workflow n'est pas present dans le YAML) — le corps ne peut donc pas, seul, relancer le controle.
Path-collision (organ #13359/#13615)Cette PR #21749 (
Le verdict terminal (#15578) signale qu'un cote de la paire est deja sur |
myia-ai-01
left a comment
There was a problem hiding this comment.
[OVERRIDE] lane myia-ai-01:CoursIA -- leve la reserve de clusterManager-Myia (review CHANGES_REQUESTED au head 44c9624) : le body est reformule, les comptes de PRs externes sont sortis de l'assertion de perimetre, et perimeter review guard (#11268) est vert a 06:59Z ; check_pr_perimeter.py 21749 --scan-thread rend VERDICT: OK a 07:4xZ.
APPROVE a la tete exacte 44c9624. Le correctif a ete rejoue par Hermes sur le depot reel : faux libre reproduit sur main (CLEAR/0) puis BLOCKED/2 a la tete, refetch des PRs a plus de 100 fichiers, UNKNOWN fail-closed sur cache incomplet, +12 tests et 0 echec introduit. Point a suivre (non bloquant) : le verdict passe de 3,4 s a 20,4 s et sollicite davantage l'API ; le cache v2 doit absorber les appels repetes des lanes. A remesurer apres une semaine d'usage. Merge apres conclusion des jambes encore en vol et relance du PR gate (rouge a 05:59Z, avant le vert du garde perimetre).
|
G-VAR-2 light cap reached (advisory, non bloquant). |
…de the cover probe on the complete enumeration (#21786) * Fix(#21742): check_lane_claim -- enumeration complete des PRs ouvertes + verdict UNKNOWN fail-closed Le mode `--paths` rendait un faux "Path is free" sur des chemins detenus par des PRs ouvertes. Deux truncations silencieuses, mesurees sur 362 PRs ouvertes : - la LISTE des PRs : `gh pr list --limit 200` -> 162 PRs invisibles ; - la liste de FICHIERS d'une PR : `--json files` plafonne a 100 chemins (positions mesurees : 204/323 sur #21413, 422/597 sur #21555). Une borne n'est pas une mesure : la completude est desormais ETABLIE par pagination GraphQL (jusqu'a `hasNextPage == false`) + refetch REST des seules PRs que `files.totalCount` designe comme plafonnees (en parallele, nombre de chemins controle contre le total). Quand la completude ne peut pas etre etablie (plafond, budget de temps, reponse en erreur, ecart de comptage), l'enumeration leve `OpenPrsIncompleteError` et les appelants rendent `UNKNOWN` (exit 1) -- fail-closed, jamais "CLEAR". Le cache #19643 passe sous la meme regle : format v2 autodescriptif (completude explicite) ; une liste nue (format heritage, issue de la fenetre tronquee) n'est plus une preuve de liberte -- elle est ignoree, et le fetch complet la reecrit au format v2 (ecriture atomique). Memo de processus : le fetch est unique par processus, donc le mode batch ne paie plus N fetches. Avant/apres mesure sur le meme chemin (detenu par la PR ouverte #20802) : avant -> "CLEAR: no OPEN PR of another lane touches [...]" (rc 0) apres -> BLOCKED, collision #20802 (rc 2) Sur l'issue #21742 elle-meme, l'ancien organe ne voyait aucune collision (`open_pr_collisions: []`) la ou le corrige voit #21413 et #21555 -- un recouvrement de contenu nul (patch #19643 deja merge dans main) que la troncature cachait. Tests : 11 tests pines (pagination multi-pages, refetch des PRs plafonnees, ecart de comptage, plafond de securite, cache heritage non trusted, cache v2 trusted, cache v2 incomplet, verdict UNKNOWN en mode `--paths` et `--open-prs-on`, memo, instance fondatrice bout en bout) + 6 tests existants adaptes en conservant leur intention (cache sans appel `gh`, encodage cp1252, borne de timeout). Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com> * feat(picker,#21757): write the v2 cache (completeness-aware) and decide the open-PR cover probe on the complete enumeration #21757 -- issue fille de #21742 (open sur le coordinateur, c.6311526181), cote producteur du cache des PRs ouvertes. Le picker ecrivait le format HERITE (liste nue) ; apres #21749, le lecteur attendait le format v2 (enveloppe `format`/`complete`/`count`/`prs`) et refusait toute liste nue comme « non preuve de completude ». Le cache ecrit par le tapis etait lu comme absent : chaque candidat refaisait sa propre enumeration complete (N x refetch GraphQL/REST), au lieu d'une par passe. - `_write_open_prs_cache_v2` : enveloppe v2, ecriture ATOMIQUE (tempfile + os.replace). `complete` est ecrit `true` QUE si l'enumeration est COMPLETE ; une enumeration INCOMPLETE ecrit `complete: false` + son motif -- le lecteur leve alors `OpenPrsIncompleteError` et rend UNKNOWN, jamais « Path is free ». - `_complete_open_prs` : memoise l'enumeration complete realisee par le LECTEUR (`_gh_open_prs_complete` de #21749). Le picker ne duplique PAS la pagination : la pagination vit cote lecteur. - `open_cover_signal_complete` : la sonde de couverture (#16589, urne grain) tranche sur l'enumeration complete, au lieu d'une recherche serveur plafonnee a 20 resultats. La regle d'ancre `#N\b` est conservee (#17760). La recherche serveur reste consultee pour le DESCRIPTEUR (`[draft]`, autres PRs) afin que la phrase produite ne change pas. - 6 tests dans `test_pick_idle_grain.py` : * cache complet relu par le lecteur, sans fetch reseau ; * enumeration INCOMPLETE ecrit un cache incomplet, le lecteur leve `OpenPrsIncompleteError` ; * une liste NUE (heritee) est traitee comme absente (faux negatif) ; * le picker ne lance aucun `gh` : l'enumeration est celle du lecteur (memoisee, une par passe) ; * la couverture lit l'enumeration complete et borne son ancre par un mot (`#4242\b` n'attrape pas `#42420`). Aucun fichier de workflow touche, aucun carnet modifie. Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 (1M context) <noreply@anthropic.com> Co-authored-by: myia-ai-01 <myia.ai.01.myia@gmail.com>
Grain: MED/guard — lane myia-ai-01:CoursIA-2 — prev: MED/guard #21744
Objet
scripts/check_lane_claim.pyrépondait « Path is free » (exit 0) sur des cheminsdétenus par des PRs ouvertes. Le mode
--pathsest un organe de verrou : un fail-opensilencieux y est un défaut de direction.
Cause :
_gh_open_prs_with_fileslistait les PRs ouvertes avecgh pr list --state open --limit 200. Le dépôt porte 362 PRs ouvertes (mesuré le2026-10-21) ;
gh pr listrend les plus récentes d'abord, donc les ~160 PRs les plusanciennes n'étaient jamais vues.
Deux truncations distinctes, mesurées
--limit 200)--json filesplafonne à 100)La seconde est plus discrète : une PR à 1070 chemins n'en montrait que 100 — les 970
autres disparaissaient du verdict. C'est celle-là qui reste invisible même quand la
fenêtre
--limitest élargie.Avant / après — la même commande, sur le même chemin
Chemin
MyIA.AI.Notebooks/IIT/ICT-Series/ICT-48-WolframClasses-ThermoSignature-Python.ipynb,détemu par la PR ouverte #20802 (
myia-po-2024:CoursIA-2) :CLEAR: no OPEN PR of another lane touches [...]BLOCKED— collision #20802 + auto-recouvrement #20921L'instance fondatrice de l'issue (#21193 / #21226 vs #21740) est de même nature : une PR
ancienne, invisible à la fenêtre, laissait deux lanes livrer le même objet.
Le correctif voit une collision réelle que l'ancien outil ne pouvait pas voir
Sur l'issue #21742 elle-même (mode issue,
check_lane_claim.py --lane … 21742) :open_pr_collisions[]CLEAR(rc 0)myia-po-2025:CoursIA) et #21555 (myia-po-2024:CoursIA)La raison n'est pas la fenêtre
--limit 200: ces deux PRs sont récentes et dans lafenêtre. C'est la troncature par PR : leur liste de fichiers est plafonnée à 100, et
les chemins en collision sont bien au-delà —
scripts/check_lane_claim.pyscripts/tests/test_check_lane_claim.pyAncien outil :
[]— il ne pouvait pas les voir. Cet organe est un verrou ; satroncature ne rendait pas service dégradé, elle rendait un faux libre.
Correctif
1. Énumération paginée, dont la complétude est établie
gh pr list --limit Nne prouve rien :Nest une borne, pas une mesure. La voie retenueest une pagination GraphQL dont la fin est signifiée par l'API elle-même
(
pageInfo.hasNextPage), plus untotalCountpar PR qui dit exactement si sa liste defichiers est plafonnée.
La pagination GraphQL évite aussi le piège signalé dans l'issue : le payload
body+filesde 360 PRs en un seul appel a déjà provoqué des 504. Ici le pire cas mesuré est une
page de ~550 Ko.
2. Refetch REST des PRs plafonnées
Les seules PRs dont la liste de fichiers est tronquée sont celles que
totalCountdésigne(3 sur 362, mesurées). Pour elles, l'endpoint REST
/pulls/{n}/filesn'a pas deplafond et
--paginatesuit toutes les pages. Le refetch est parallèle (3 PRs) et lenombre de chemins lus est contrôlé contre
totalCount: un écart rend l'énumérationincomplète.
3. Verdict
UNKNOWNfail-closedQuand la complétude ne peut pas être établie (plafond de sécurité franchi, budget de temps
dépassé, réponse GraphQL en erreur, refetch de fichiers incohérent), l'énumération lève
OpenPrsIncompleteErroret les appelants rendentUNKNOWN— jamais « CLEAR » :--pathsCLEAR(exit 0)UNKNOWN+"verdict": "UNKNOWN","complete": false(exit 1)--open-prs-onNO open PR intersects … Path is free.(exit 0)UNKNOWN: …(exit 1)--pathsavec n° d'issue)ghéchoueOpenPrsIncompleteErrorhérite deRuntimeError: les handlers existants la rattrapentdonc, et un
UNKNOWNn'est jamais confondu avec une erreur de plomberie.4. Le cache #19643 passe sous la même règle
Le cache de session (
CHECK_LANE_CLAIM_OPEN_PRS_JSON) était alimenté par le picker beltavec une liste tronquée (
gh pr list --limit 200) — c'est-à-dire que le chemin leplus utilisé de l'organe était précisément celui qui produisait le faux libre.
Le cache porte désormais sa complétude :
{"format": "open-prs-v2", "complete": true, "prs": […]}) → preuve, ludirectement, zéro appel
gh;comme absente, le fetch complet s'exécute, et le fichier est réécrit au format v2
(écriture atomique : fichier temporaire +
os.replace, pour qu'un lecteur concurrent nelise jamais un fichier à moitié écrit) ;
complete: false→OpenPrsIncompleteError(on ne prétend pas avoirla liste).
5. Mémo de processus
Le mode batch (#19643) boucle sur N issues et chacune re-demande la liste → N fetches.
Un mémo de processus rend le fetch unique par processus ; le fichier-cache le rend
unique par session. Le tapis du picker garde ainsi son coût mesuré.
Preuve — tests pinnés
scripts/tests/test_check_lane_claim.py:première page seule, c'était le bug) ;
totalCount=150pour 100 nœuds → refetch REST → les 150 cheminssont présents ;
totalCount→OpenPrsIncompleteError;OpenPrsIncompleteError;est réécrit en v2 avec
complete: true;OpenPrsIncompleteError;--paths: énumération incomplète → rc 1,UNKNOWNau stderr,"verdict": "UNKNOWN"/"complete": falsedans le JSON, et « CLEAR » n'apparaîtnulle part ;
--open-prs-on: rc 1,UNKNOWN, et pas « Path is free » ;(donc celle que la fenêtre tronquée perdait) est détectée → rc 2, collision listée,
pas de « Path is free » ;
OpenPrsIncompleteError(et non uneTimeoutExpirednue quis'échappait aux handlers et faisait mourir
--pathssur une traceback au lieu duverdict
UNKNOWN). Défaut trouvé à la revue du correctif, pas au premier coup.Six tests existants pinnaient l'ancien transport (
gh pr list --limit 200, cache en listenue, encodage cp1252 du payload) : ils ont été adaptés en conservant leur intention
— dont « le cache évite tout appel
gh» et « un octet non-cp1252 dans le payload ne tuepas le mode ».
Herméticité prouvée : le fichier entier passe 357 tests avec un plugin pytest qui
bloque
socket.connect→ aucun test ne touche le réseau.Coût mesuré
--paths)Le fetch est unique par processus (mémo) et unique par session (cache v2) : le
mode batch ne paie pas N fetches.
Coût du tapis, et la suite. Le picker belt (#19643) écrit son cache avec
gh pr list --limit 200— une liste nue, désormais non prouvée : l'organe larefuse et refait un fetch complet (20 s au lieu de 6 s) à chaque sonde. Le remède
propre est que le producteur écrive du v2 (alors la sonde retombe à ~1 s, soit
plus rapide qu'avant) ;
scripts/pick_idle_grain.pyest hors périmètre de cettePR (5 PRs ouvertes le touchent) et son correctif mérite son propre arbitrage.
Chevauchement avec deux PRs ouvertes — et pourquoi il est fantomatique
#21413(323 chemins) et#21555(597 chemins) touchent les mêmes deux fichiers.L'organe le signale — c'est un fait exact — mais le recouvrement de contenu est nul :
scripts/check_lane_claim.pyscripts/tests/test_check_lane_claim.pymain(constantes de timeout + cache)Les deux PRs portent la même modification
#19643, et elle est déjà dansmain(mesuré :
origin/mainporte les constantes_GH_OPEN_PRS_TIMEOUT_Set_OPEN_PRS_CACHE_PATH). Leur base est antérieure à ce merge : au rebase, ce hunk estsupprimé (patch-id déjà amont) et il ne reste que leur contenu propre, sans recouvrement
avec les regions touchées ici.
Dit autrement : le collision que l'organe signale est réel en surface, nul en contenu.
C'est le genre de recouvrement que la fenêtre tronquée cachait — l'ancien outil ne
voyait aucune collision, le nouveau en voit une qui ne se résoudra pas en conflit.
Limites et suite
invisible. La course résiduelle (même lane, deux worktrees) reste couverte par le mode
--open-prs-on([coordination] Deux collisions intra-machine en 2 h qu'aucun organe ne voit : etendre check_lane_claim.py d'un mode chemin sans filtre de lane #13595).scripts/pick_idle_grain.py) écrit toujours une listenue : elle est désormais ignorée par l'organe, qui réécrit le fichier au format v2.
Le producteur n'est pas dans le périmètre de cette PR (5 PRs ouvertes le touchent) ;
le supprimer serait un second PR, à faire après arbitrage du coordinateur.
au lieu de 6 s) mais exact ; l'optimum (un producteur qui écrit du v2) est le
chantier suivant.
Validation
python -m pytest scripts/tests/test_check_lane_claim.pyGH_TOKEN+LANE_CLAIM_LIVE=1CLEAR/0 →BLOCKED/2 (mesuré ci-dessus)--force, aucune suppression de code existantCloses #21742
🤖 Generated with Claude Code