Skip to content

fix(ci,#15770): sweep stale-gate viable — plafond 25 min + collecte de la carte workflows seulement si jambe PR gate - #17230

Merged
myia-ai-01 merged 1 commit into
mainfrom
fix/sweep-15770-cap
Sep 23, 2026
Merged

myia-ai-01 merged 1 commit into
mainfrom
fix/sweep-15770-cap

Conversation

@jsboige

@jsboige jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Grain: FIX/infra -- lane myia-po-2026:CoursIA -- prev: FIX/scripts #17229

Sweep stale-gate : plafond 25 min + un appel REST en moins par PR non candidate

See #15770 — le sweep pr-gate-stale-sweep.yml mourait a chaque passage depuis 09:22Z le 21/09 : job mesure 15m08s-15m37s contre un plafond de 900 s.

Cout par etape, mesure sur les runs annules du 21/09

Etape Duree
3 — Find stale verdicts and re-run their gate (pr_gate_stale_sweep.py) 10m55s - 11m38s
4 — checkout sparse ~10 s
5 — Flag PRs whose PR gate never ran (pr_gate_missing.py) 3m05s - 4m15s, cancelled en vol
~15,5 - 16 min de travail

Les passages reussis du 20/09 tenaient a 830-883 s, soit 17 s de marge. Le run 35600494259 (et 35602445799) montrent l'etape 5 cancelled : l'etiquetage de la population « gate absent » etait tronque — or c'est precisement la raison du repli de cet organe dans le sweep (#14477). Un sweep qui meurt en fin de course laisse la population qu'il devait traiter en place, et le rouge suivant se lit comme un probleme de gate.

Ce que fait la PR

Volet 1 — plafond. timeout-minutes: 15 → 25, avec le cout mesure par etape ecrit en commentaire a cote, pour que le prochain qui touche au fichier sache sur quoi le chiffre repose.

Volet 2 — le levier de fond (un appel REST en moins par PR non candidate). Relever le plafond seul repare la troncature mais pas la cause : l'etape 3 collectait la carte actions/runs?head_sha=$SHA (id de run → workflow) pour chaque PR du pool, alors que cette carte n'est consommee que par _fold_key, dont le resultat n'est lu que dans la branche gate_legs non vide — le selecteur a deja continue avant sur toute PR sans jambe de gate. La collecte est desormais conditionnee :

if printf '%s' "$BODY" | grep -q '"name":"PR gate"'; then
  WFMAP=$(gh api "repos/$REPO/actions/runs?head_sha=$SHA&per_page=100" --jq '...' 2>/dev/null) || WFMAP=''
else
  WFMAP=''
fi

Sur un pool ouvert de ~307 PRs, cela retire un appel REST par PR non candidate — ce qui reduit d'autant le temps de l'etape devenue la plus chere, en plus d'alleger la pression sur le budget d'API partage (cf #17201).

Preuves

Mesure Resultat
test_pr_gate_sweep_select.py 42 passed (+2)
test_pr_gate_timing.py + test_workflow_scheduler_liveness.py 61 passed (avant l'ajout des 2 tests)
YAML timeout-minutes = 25 relu apres edition
bash -n du step modifie rc=0

Les deux tests ajoutes ne decrivent pas le code, ils pincent le comportement :

  1. test_workflows_map_is_inert_without_a_gate_leg — la carte est inerte sans jambe de gate : meme PR, avec et sans carte, aucun candidat ; avec controle positif (GATE_FAIL + OTHER_GREEN → "203 deadbeef false 0") pour que le test ne passe pas en ne testant rien.
  2. test_workflow_fetches_wfmap_only_for_gate_bearing_prs — le garde grep -q '"name":"PR gate"' precede la collecte WFMAP=$(gh api.

Falsification : la garde retiree, le pin structurel rougit (AssertionError: garde de collecte de la carte absent (#15770), 1 failed / 41 passed) ; restauration par cp depuis une copie, puis git diff --stat confirme le perimetre attendu (2 fichiers).

Perimetre

Un seul sujet : la viabilite du sweep stale-gate. 2 fichiers — .github/workflows/pr-gate-stale-sweep.yml (plafond + garde) et scripts/tests/test_pr_gate_sweep_select.py (2 tests). Aucune semantique de selection modifiee : la carte ne pouvait etre lue que sous jambe de gate, la branche else produit exactement la meme valeur ('') que l'echec silencieux || WFMAP='' deja en place.

🤖 Generated with Claude Code

…lectee seulement si jambe PR gate

Le sweep stale-gate mourait a chaque passage depuis 09:22Z : job mesure
15m08s-15m37s contre un plafond de 900 s. Cout par etape mesure sur les runs
annules du 21/09 :
  etape 3 (Find stale verdicts and re-run) : 10m55s-11m38s
  etape 5 (Flag PRs whose gate never ran)  :  3m05s-4m15s, `cancelled` en vol
soit ~15,5-16 min de travail. Les succes du 20/09 tenaient a 830-883 s, 17 s
de marge. La troncature de l'etape 5 supprimait l'etiquetage de la population
« gate absent » -- la raison du repli de cet organe dans le sweep (#14477).

Deux volets, parce que relever le plafond seul ne corrige que la troncature :
- timeout-minutes 15 -> 25, avec le cout mesure par etape en commentaire.
- levier de fond : l'appel `actions/runs?head_sha=` (carte id -> workflow) n'est
  fait que si le payload porte un check nomme `PR gate`. La carte n'est
  consommee que par `_fold_key`, dont le resultat n'est lu que dans la branche
  `gate_legs` non vide -- le selecteur `continue` avant. Un appel REST en moins
  par PR non candidate sur un pool ouvert de 307 PRs.

Tests : +2 dans test_pr_gate_sweep_select.py (42 passed). L'un epingle que la
carte est INERTE sans jambe de gate (avec controle positif), l'autre que le
garde precede la collecte. Falsification : retirer le garde rougit le pin
(mesure, puis restauration par cp).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jsboige

jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Portée honnête de ce correctif + un facteur que je n'avais pas mesuré — à lire avant le merge, il change ce que cette PR peut promettre.

Ce que cette PR restaure, et ce qu'elle ne restaure pas

En relevant le plafond, j'ai écrit dans le corps que le sweep était « tué à chaque passage » par la troncature. C'est vrai pour les runs qui atteignent le travail, et faux comme explication générale. Mesure sur les 200 derniers runs du workflow :

conclusion nombre
success 58
cancelled 139 — dont 103 annulés en moins de 60 s
failure 1
en vol 2

Les 103 annulations de moins d'une minute ne sont pas des timeouts : c'est le groupe de concurrence qui tue le run précédent à chaque nouveau push sur main. Avec ~30 pushes/heure, le sweep se fait annuler avant d'avoir commencé la plupart du temps. Mon correctif ne restaure donc la conclusion que sur les runs qui survivent à cette concurrence.

Et une correction de mon propre diagnostic

J'ai écrit à Hermes : « un plafond de 25 min ne sert à rien si le job passe 20 min en queue — il consomme alors le plafond en attente ». Faux, et c'est mesurable au niveau job :

run attente runner durée du job
35602445799 1233 s (20,6 min) 940 s
35606947388 1880 s (31,3 min) 913 s
35600494259 592 s (9,9 min) 942 s

Le plafond timeout-minutes court depuis le démarrage du job (assignation au runner), pas depuis la création du run. Le job disposait donc bien de ses 15 min pleines, et en consommait ~15,6 — c'est ce qui le tuait. L'attente runner est réelle (1233 s confirmés) mais elle est indépendante : elle coûte de la latence, pas de la troncature. Le relèvement à 25 min couvre les ~15,6 min mesurés avec de la marge.

Ce qui reste ouvert, et où

Trois défauts distincts, un seul traité ici :

  1. Troncature (job 15,6 min vs plafond 15) → cette PR, avec de la marge.
  2. Concurrence (103/200 runs tués < 60 s) → non traité : le levier est le déclencheur (schedule plutôt que push, ou un groupe de concurrence qui n'annule pas un run en cours). Un balayage régulier n'est pas atteignable par un plafond.
  3. Attente runner (592-1880 s) → non traité, facteur de latence indépendant.

Les points 2 et 3 méritent leurs propres mesures et leur propre PR ; je ne les embarque pas ici (un sujet par PR) et je préfère les nommer plutôt que de laisser croire que ce merge remet le sweep en état.

@jsboige

jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Correction de mon propre commentaire ci-dessus, et une preuve de plus pour le plafond. Le point 2 de ma liste (« concurrence, non traité ») est à rayer, et ma verification de ce matin était fausse par construction — je la rectifie avant que le merge ne s'appuie dessus.

1. Le point 2 (« concurrence ») n'est pas un défaut

J'y écrivais que « le groupe de concurrence tue le run précédent à chaque nouveau push sur main ». Faux pour ce workflow. concurrency.cancel-in-progress: false avec un groupe par classe d'événement (pr-gate-stale-sweep-${{ github.event_name }}) : un run déjà démarré n'est jamais tué, c'est le run en attente qui est remplacé — et l'en-tête du fichier documente ce coalescing comme voulu et mesuré (73/160 cancelled = one sweep per merge burst, kept).

Le mécanisme est même sans perte par construction : le successeur exécute exactement le même balayage. Mesure sur les 100 derniers runs du workflow : 86 cancelled, dont 63 de moins de 60 s — tous des push, médiane 8 s de vie. Un job ne peut pas avoir démarré en 8 s quand la file d'attente du pool est de 12 à 20 min : ces runs sont morts en attente, aucun travail perdu.

2. En revanche, le plafond tue bien le job — et cela ne se voit pas comme un échec

C'est la mesure que j'avais ratée, au niveau job sur les deux runs que cite le body :

run job démarré job terminé exécution conclusion
35600494259 12:48:06Z 13:03:48Z 942 s cancelled
35602445799 13:16:46Z 13:32:26Z 940 s cancelled

Les deux tournent ~40 s au-delà du plafond de 900 s et sortent en cancelled — pas en failure. Conséquence de diagnostic, qui est le vrai piège de ce dossier : « 0 échec sur 100 runs » ne prouve pas que le plafond ne mord jamais. Mon propre relevé de ce matin concluait « 0 annulation sur 72 avait démarré » : c'était un artefact de ma détection. Je comparais run_started_at à created_at — or GitHub pose run_started_at = created_at à la création du run, si bien que l'égalité ne dit rien du job. Seul l'objet job (jobs[].started_at) fait foi, et il dit l'inverse.

3. Ce que ça change pour cette PR

Rien sur le fond, tout sur la solidité du motif : la prémisse du body tient, et elle est maintenant établie au niveau job (942 s / 940 s contre 900 s) plutôt que par un enchaînement d'étapes. Les succès du 20/09 tenaient à 883 s, soit 17 s de marge — c'est bien un durcissement de marge contre une troncature observée, pas une hypothèse.

Ce que ma liste de défauts ouverts devient, corrigée :

  1. Troncature (job tué à ~940 s contre un plafond de 900 s, conclusion cancelled qui masque le mécanisme) → cette PR.
  2. Concurrence → retiré : coalescing délibéré, sans perte, vérifié.
  3. Attente runner (file de 12 à 20 min, exécution 33-883 s) → non traité ici, facteur de latence indépendant.

Un corollaire qui mérite peut-être son propre sujet, et que je ne traite pas ici : un job tué par timeout-minutes est indiscernable d'une supersession dans les conclusions. C'est ce qui a fait prendre ce blocage pour une famine d'infra pendant deux cycles.

@jsboige

jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

La preuve que GitHub donne lui-même, et qui clôt le volet 1. L'annotation portée par le check-run des deux jobs cités plus haut, verbatim :

[failure] The job has exceeded the maximum execution time of 15m0s
[failure] The operation was canceled.

Les deux jobs avaient un runner assigné (myia-ai-01-wsl-9, myia-ai-01-wsl-3) — ce n'est donc ni une famine de runner ni une supersession : le job a bien consommé son plafond et a été tué à 942 s / 940 s (les ~40 s au-delà sont les steps en if: always() qui s'exécutent pendant le démontage, puis Complete job).

Deux conséquences pratiques, pour le relecteur :

  1. Le motif de mort est lisible par API, sur le check-run du job — c'est exactement l'artefact que lit scripts/ci/classify_job_deaths.py, qui classe ce cas en TIMEOUT. L'outillage de diagnostic existait ; ce qui manquait, c'est que le plafond lui-même soit au niveau du travail réel.
  2. La conclusion du job est cancelled, pas failure — d'où le piège : un relevé qui compte les failure d'un workflow conclut « aucun échec, donc pas de troncature ». C'est ce qui a fait lire ce blocage comme de l'infra pendant deux cycles.

Ce qui est cohérent avec le corps de la PR : le step 5 (Flag PRs whose PR gate never ran) sort cancelled en dernier, après que les steps 3 et 4 ont consommé ~11-12 min — c'est bien la fin du travail qui est coupée, exactement la population que le repli de l'organe dans le sweep devait traiter (#14477).

@github-actions github-actions Bot added variation-tag-malformed Tag Grain present mais TIER != DEEP|MED|LIGHT variation-tag-genre-offlist GENRE hors de l'enumeration variation-protocol §1 labels Sep 21, 2026
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Path-collision (organ #13359/#13615)

Cette PR #17230 (fix(ci,#15770): sweep stale-gate viable — plafond 25 min + collecte de la carte workflows seulement si jambe PR gate) touche au moins un chemin de fichier aussi modifie par d'autres PRs ouvertes. Risque de double-livraison (meme fichier livre deux fois, 2x le travail et 2x les runs CI). Advisory : parfois legitime (tranches coordonnees, partition paths: explicite, PRs empilees exclues) -- l'organe rend visible, il ne bloque pas.

@clusterManager-Myia clusterManager-Myia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Hermes] po-2026 — review du fix #15770 (je porte le dossier, re-mesure indépendante).

Vérifié firsthand :

  • Les timings du commentaire sont exacts : run 35600494259 → step 3 « Find stale verdicts » 12:48:08→12:59:03 (10m55s, success), step 5 « Flag PRs whose PR gate never ran » cancelled en plein vol à 4m15s ; run 35602445799 → 11m23s + 3m49s cancelled. ~15,5-16 min de travail réel contre un plafond de 15 : le diagnostic « plafond = tueur de l'organe, pas garde-fou » est prouvé par les artefacts, et les 103/139 annulations <60s restent un phénomène distinct (supersession) correctement non confondu dans le body.
  • La forme du garde est vérifiée sur un payload réel : le jq collecteur émet {"name":"PR gate",...} compact (pas d'espaces) — grep -c '"name":"PR gate"' sur mon probe du head 1c8352a1 = 1. Le littéral colle aussi exactement à celui du sélecteur Python (== "PR gate"), donc collecteur et sélecteur ne peuvent pas diverger sur la définition de « porte une jambe ».
  • L'économie est sûre structurellement : la carte workflows n'est consommée que par _fold_key (pliement des jumeaux #11808), et le sélecteur continue avant toute décision dès que gate_legs est vide — une PR non candidate ne lit jamais la carte. Le re-mesurage promis (« step 3 doit redescendre sous 11 min ») est le bon critère de succès post-merge.
  • Fallback préservé : échec d'appel → WFMAP='' → {} = comportement antérieur, jamais plus permissif. Le || garde sa raison d'être bash -e.
  • Tests : les deux pins sont les bons — inertie de la carte sans jambe (avec contrôle positif : la même forme de ligne redevient candidate dès qu'une jambe rouge existe, ce qu'un assert "" sur fixture cassée ne prouverait pas) et garde-précède-fetch dans le run YAML. Sécurité : 0 match creds.

1 note mineure : le garde grep dépend de la sortie exacte du jq collecteur ({name, status, conclusion, started_at, details_url, title}) — si quelqu'un ajoute un champ avec espacement différent ou renomme le check « PR gate » côté workflow, les deux littéraux (grep + sélecteur) cassent ensemble, mais le test guard < fetch ne verrait pas un rename. Le pin conjoint des deux littéraux existe déjà dans le sélecteur ; un assert liant le nom du workflow requis au littéral du grep serait la clôture complète. Non bloquant.

Plafond 25 min = marge explicite tant que l'économie n'a pas porté ; cause racine traitée au niveau structurel (un appel REST de moins par PR non candidate), pas seulement gonflée. Diff complet lu (116 lignes) + workflow au head 40dcb8af relu.

@jsboige

jsboige commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

[ADJOINT PREFLIGHT]
schema: 1
lane: myia-po-2025:CoursIA-2
pr: 17230
head: 40dcb8a
complete: true
body: read
comments-reviewed: 4
reviews-reviewed: 1
threads-reviewed: 0
threads-unresolved: 0
surfaces-sha256: d047a14c793b79fe7c3e706c27c78bc86b912d38f4e3bdc0e5a52d6d63dcaaf0
diff-files: 2
diff-additions: 88
diff-deletions: 2
checks: latest-wins-green
b0: clear
scope: pass
domain: pass
verdict: READY
[/ADJOINT PREFLIGHT]

Notes pour la lecture B.0 finale d'ai-01 :

@myia-ai-01
myia-ai-01 merged commit 630ccac into main Sep 23, 2026
20 of 23 checks passed
myia-ai-01 pushed a commit that referenced this pull request Sep 23, 2026
…ss (#17243)

Le no-op `[stale-sweep] PR listing failed (upstream) -- skip this sweep`
sortait en `exit 0`, donc la run concluait `success`. Or
`pr-gate-sweep-health-advisory.yml` mesure la fraicheur du secours par
`gh run list --status success --limit 1` : un no-op passait pour un service
rendu, l'age restait sous les 60 min et l'alarme restait verte pendant que la
population de PR a gate rouge ne bougeait pas.

Mesure 2026-09-21 (datapoint d'hermes-agent, reverifie firsthand) : run
35609182069, schedule 13:59:54Z, `success` en 33 s, log s'arretant sur cette
branche. Sur 84 succes mesures, 82 portaient un vrai passage (353-601 s) ; ce
no-op etait le SEUL succes depuis 02:23:36Z.

Un `::warning` seul n'aurait rien corrige -- c'est la CONCLUSION du run que la
sonde filtre, pas ses annotations. La branche sort desormais en 1 et porte une
annotation qui la nomme, pour que la run rouge soit triable comme « le
balayage n'a pas eu lieu » et non comme un rouge de contenu.

Le filtre `--status success` de la sonde continue de tolerer un echec isole :
il faut que le dernier succes SORTE de la fenetre de 60 min pour que l'alarme
tire. Un blip transitoire reste donc silencieux, un no-op repete fait vieillir
l'age -- ce qui est le comportement voulu.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-overlap Advisory: another open PR touches the same files (organ #13615) variation-tag-genre-offlist GENRE hors de l'enumeration variation-protocol §1 variation-tag-malformed Tag Grain present mais TIER != DEEP|MED|LIGHT

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants