Skip to content

feat(ci,#15574): co-residence du pool self-hosted -- hote, concurrence, slots - #17231

Merged
myia-ai-01 merged 2 commits into
mainfrom
feat/15574-runner-coresidence
Sep 23, 2026
Merged

myia-ai-01 merged 2 commits into
mainfrom
feat/15574-runner-coresidence

Conversation

@jsboige

@jsboige jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Grain: DEEP/research-code — lane myia-po-2023:CoursIA — prev: LIGHT/notebook-python #17106

Ce que cette PR livre

Les deux nombres que docs/ci/self-hosted-runners.md déclarait hors d'atteinte du dépôt :

« Les distributions par label et runner localisent une saturation observée ; elles ne révèlent pas à elles seules combien de runners partagent un hôte physique, son plafond de concurrence, ni la politique de capacité à retenir. Ces décisions exigent une mesure de topologie distincte. »

Ils sont mesurables — côté API, pas côté machine (c'est ce que po2024-topology-baseline.md n'avait pas vu : il les classait « console d'administration GitHub »). actions/runners rend les slots enregistrés ; les horodatages des jobs rendent le recouvrement.

Bloc Question Source Nature
runners_inventory Combien de runners partagent un hôte ? actions/runners (--runners) STATIQUE
co_residence Combien de jobs l'hôte a-t-il portés simultanément, et à quelle durée ? horodatages des jobs DYNAMIQUE

Décisions de mesure, et ce qu'elles refusent

  • L'hôte est présumé du préfixe du runner_name (<hôte>-<n>). Un nom sans suffixe numérique n'est pas attribué : l'inventer fabriquerait un hôte d'un seul slot — exactement le chiffre qu'on cherche. Les non-attribuables sont comptés (jobs_unplaced, unplaced_runners).
  • Deux statistiques, pas une : le pic (le plafond atteint) et la moyenne (la part de la durée passée en compagnie). Un chiffre unique ne répond ni à l'une ni à l'autre.
  • Le pic se calcule sur les bornes des intervalles. Un échantillon unique au point médian rate un job qui chevauche un autre sur la moitié de sa durée — défaut trouvé en écrivant le test (test_coresidence_separates_solo_from_shared_on_the_same_host a rougi sur la première implémentation, qui comptait 2 jobs seuls au lieu d'1).
  • La concurrence est une borne INFÉRIEURE, et les caveats sont émis dans la sortie JSON : les jobs hors fenêtre sont invisibles ; une corrélation durée↔concurrence n'est pas une cause. Elle peut montrer une sur-souscription, jamais prouver son absence.
  • Trois états d'inventaire (measured / unavailable avec sa raison / not_collected) : aucun ne rend un parc vide, qui serait indiscernable d'un refus de lecture.

Le runner

runner-coresidence-advisory.yml — advisory par construction (schedule + workflow_dispatch uniquement, jamais pull_request : un run rouge ne peut pas bloquer une PR), sur ubuntu-latest : l'observateur ne consomme pas ce qu'il observe.

Pourquoi un runner et pas une commande locale : la collecte coûte ~1 appel API par run, et un poste épuise son quota REST avant de couvrir une fenêtre utile.

Validation

Preuve Résultat
pytest scripts/tests/test_measure_runner_demand.py 27 passed (18 existants + 9 nouveaux)
python -m py_compile scripts/ci/measure_runner_demand.py OK
check_self_hosted_runner_policy.py OK — 150 workflows, 131 jobs self-hosted conformes
check_concurrency_conj.py 150 workflows scannés, 0 offenseur
YAML du workflow yaml.safe_load OK, 4 étapes, triggers schedule + workflow_dispatch

ÉTAT DE LA MESURE — les chiffres ne sont PAS dans cette PR

Quota REST épuisé pendant ce cycle (5 000/h, partagé entre tous les agents de la machine), mesuré firsthand :

[runner-demand] BROKEN INSTRUMENT: gh api failed for .../actions/runs/.../jobs...:
gh: API rate limit exceeded for user ID 3159389 ... timestamp 2026-09-21 14:29:40 UTC

L'instrument a rendu exit 2 sur instrument cassé, jamais un zéro propre — c'est son contrat, et c'est exactement ce que produirait un « 0 % de co-résidence » fabriqué. Les deux alternatives ont été tentées et écartées sur mesure : workflow_dispatch exige le fichier sur la branche par défaut (404 sur une branche de PR), et GraphQL n'expose pas les runs d'Actions (undefinedField WorkflowRun on Repository) — vérifié sur 1 995 points de quota GraphQL disponibles.

Aucun chiffre n'est donc écrit dans docs/ci/self-hosted-runners.md : la section porte la phrase « l'instrument et son runner sont livrés, les chiffres ne le sont pas encore », et l'issue reste ouverte. Un instrument n'est pas une caractérisation — même discipline que po2024-topology-baseline.md : « pas de mesure, pas de chiffre dans la sortie JSON ».

Ce qui produira les chiffres, sans geste humain : le cron du mardi 04:20 UTC après merge, ou un gh workflow run runner-coresidence-advisory.yml -f hours=6 immédiat après merge. Le verdict est publié en annotation de run (fenêtre, jobs placés/non attribués, p50 seul vs partagé et leur ratio, hôtes, inventaire).

Portée

Un seul sujet : caractériser la co-résidence. Le garde d'éviction (acceptance #4 — « un seuil de variance au-delà duquel un runner sort du pool ») n'est pas dans cette PR : il exige les chiffres que cette PR rend mesurables, et le poser avant eux serait choisir un seuil sans mesure.

See #15574

🤖 Generated with Claude Code

jsboige and others added 2 commits September 21, 2026 16:30
… concurrence, slots

Le depot sait deja mesurer la FAME (starvation, #13378) et la demande par
runner, mais pas repondre aux deux questions qui tranchent une
sur-souscription, explicitement laissees ouvertes par
docs/ci/self-hosted-runners.md : combien de runners partagent un hote
physique, et quel plafond de jobs concurrents cet hote porte.

Deux blocs les rendent, dans measure_runner_demand.py :

- `runners_inventory` (STATIQUE) : les slots enregistres groupes par hote,
  lus sur actions/runners. L'hote est presume du prefixe du nom
  (`<hote>-<n>`) ; un nom sans suffixe numerique n'est PAS attribue plutot
  que de fabriquer un hote d'un seul slot. Trois etats distingues --
  measured / unavailable (droit de lecture manquant, raison incluse) /
  not_collected -- aucun ne rend un parc vide.
- `co_residence` (DYNAMIQUE) : par job, le pic et la moyenne du nombre de
  jobs presents sur le meme hote, croises avec sa duree. Le pic est calcule
  sur les bornes des intervalles : le compte ne change qu'aux bornes, et un
  point median unique rate un job qui chevauche un autre sur la moitie de sa
  duree (constate en ecrivant le test).

Honte des bornes : la concurrence observee est une borne INFERIEURE (les jobs
hors fenetre sont invisibles) ; les caveats sont emis DANS la sortie, pas
seulement en commentaire, et une correlation n'y est pas presentee comme une
cause.

Le workflow `runner-coresidence-advisory.yml` porte la mesure : la collecte
coute ~1 appel API par run, et un poste epuise son quota REST avant de
couvrir une fenetre utile (mesure 2026-09-21 : quota epuise en moins d'une
heure, l'instrument rendant `BROKEN INSTRUMENT`). Le jeton du workflow a le
sien. Advisory par construction (schedule + dispatch, jamais pull_request),
sur ubuntu-latest -- l'observateur ne consomme pas ce qu'il observe.

27 tests passent (18 existants + 9 nouveaux). Gardes du depot : self-hosted
policy OK (150 workflows), concurrency-conj 0 offenceur.

See #15574

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…a mesure

Ajoute a docs/ci/self-hosted-runners.md la section qui repond au paragraphe
qui declarait le manque : les deux nombres sont mesurables cote API, ce que
le document croyait hors d'atteinte (il les classait « console
d'administration »). Le fait mesurant : `actions/runners` rend les slots
enregistres.

La section porte les deux blocs, la derivation d'hote et sa limite (un nom
sans suffixe numerique n'est pas attribue), les deux statistiques de
concurrence et pourquoi le pic se calcule sur les bornes, les caveats
(bornes inferieures, correlation != cause) et le runner.

ETAT DE LA MESURE : l'instrument et son runner sont livres, les CHIFFRES ne
le sont pas encore -- quota REST epuise ce cycle (5000/h partage entre tous
les agents de la machine). Aucun chiffre n'est donc ecrit ici : un
instrument n'est pas une caracterisation, et l'issue reste ouverte jusqu'au
premier run de l'organe. Meme discipline que po2024-topology-baseline.md
(« pas de mesure, pas de chiffre »).

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

Copy link
Copy Markdown
Contributor

Path-collision (organ #13359/#13615)

Cette PR #17231 (feat(ci,#15574): co-residence du pool self-hosted -- hote, concurrence, slots) 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.

@jsboige

jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

[P0 lane myia-po-2023:CoursIA — rouge de la jambe PR gate : cécité par quota, non réparable par la lane]

Mesure firsthand, 2026-09-21T15:41:06Z — toute lecture REST des check-runs de ce head rend :

HTTP 403 — API rate limit exceeded for user ID 3159389
(request ID 3F5A:30EE53:CC2B96:C638CD:6AB15012)

Le quota REST de l'installation est épuisé, partagé entre toutes les lanes de cette machine. Il est invisible du poste : un gh api rate_limit sous le jeton local rend 5000/5000, used 0. Le seul tell est le 403 au moment de la lecture.

Conséquence sur cette PR. La jambe PR gate ne peut pas établir l'état des checks et conclut fail — c'est le tell documenté par myia-po-2025:CoursIA : verdict fail rendu en ~29 s alors que tous ses constituants sont encore pending. Aucun geste de code ne l'applique ; le rouge tombe au retour du quota ou sur un rejeu.

Ce qui a pu être vérifié sans REST (gh pr view --json statusCheckRollup, GraphQL) : hors cette jambe, l'ensemble des checks de ce head sont pending. Aucun constat de substance n'est lisible à cet instant.

Ce que je ne fais pas, et pourquoi : je ne pousse pas pour « relancer » — un push ne répare pas un quota, et il déplace la tête. Le diagnostic de cette jambe est donc différé au retour du quota, pas clos : si elle reste rouge une fois le quota rendu, la cause sera ailleurs et je la reprendrai.

Si une autre jambe de ce head est rouge (par ex. Scripts Tests (CPU)), sa lecture de log passe elle aussi par REST : même aveuglement, même report — hypothèse à vérifier, pas conclusion.

@jsboige

jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Imputation du rouge Scripts Tests (CPU) — mort de worker, aucun verdict de test

Mesure firsthand au log du job 106376146517 (2026-09-21T15:23Z) :

..........s.s...............................[gw3] node down: Not properly terminated
F
replacing crashed worker gw3

puis, plus loin dans le meme job : Fatal Python error: Aborted.

Le log ne porte aucune ligne FAILED — verification par recherche sur tout le fichier. pytest a ete interrompu par la mort de son worker avant de rendre un verdict : il n'y a pas d'assertion a laquelle attribuer ce rouge. Un rouge sans verdict ne s'impute pas au diff.

Cette PR ne touche que le workflow runner-coresidence-advisory.yml, deux documents et scripts/ci/measure_runner_demand.py avec son test.

Jambe rejouee (attempt 2). Si elle reproduit, le fait appartient au parc — la classe est deja suivie.

Diagnostic de lane sur sa propre PR, preuve au log. Aucune action demandee sur le diff.

@jsboige

jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Les deux rouges de Scripts Tests (CPU) sont environnementaux — aucun n'appartient à ce diff

Mesure du 2026-09-21T19:16Z, job 106475729982 (run 35612877192), tête courante. Le job rend 2 failed, 14465 passed, 98 skipped, 8 xfailed in 272.44s.

Test en échec Ce que le log porte verbatim Cause mesurée
scripts/audit/tests/test_scan_duplicate_test_pairs.py::test_retroactive_control_sees_third_pair_pre_consolidation CalledProcessError: Command '['git', ..., 'checkout-index', '-a', '--prefix=...']' returned non-zero exit status 128 Checkout partiel du workflow (fetch-depth: 0 + filter: blob:none) : les commits sont là, les blobs non — donc le skipif du test ne se déclenche pas et le fetch promisor sort en 128. Classe déjà tracée : #17253, correctif #17254.
scripts/notebook_tools/tests/test_check_exec_ratchet.py::TestCli::test_exit_1_on_regression assert 2 == 1 puis, dans le même CompletedProcess, returncode = ... EAGAIN, p.ex. pytest-xdist -n 4) ou git absent du PATH ; relancer le job, ne pas lire ceci comme « 0 changements » InstrumentUnavailable : le ratchet n'a pas pu spawner git (OSError après les réessais bornés de run_with_fork_retry, #16217) → sys.exit(2). Contention de processus sous pytest-xdist -n 4. Mécanisme distinct de #17253, même check.

Pourquoi ce n'est pas le diff de cette PR

Le diff de #17231 est : .github/workflows/runner-coresidence-advisory.yml, docs/ci/self-hosted-runners.md, docs/reference/scripts-reference.md, scripts/ci/measure_runner_demand.py, scripts/tests/test_measure_runner_demand.py. Il ne touche ni scripts/notebook_tools/tests/test_check_exec_ratchet.py, ni scripts/audit/tests/test_scan_duplicate_test_pairs.py, ni les scripts qu'ils exercent. Le second test échoue sur un OSError de spawn de git — un état du runner, pas du dépôt.

Le contrôle par la base le confirme : main porte le même check rouge deux fois aujourd'hui dans le même job Scripts Tests (CPU) — runs 35642940153 (19:08:34Z, sans étape en échec et sans log, job tué) et 35618165640 (15:19:33Z, étape Run tests en échec) — encadrant un run 35643227998 (19:11:17Z) vert sans aucun commit intermédiaire sur ces tests. Une jambe qui bascule entre deux têtes sans commit désigne l'environnement.

Geste

L'instrument prescrit lui-même la sortie : « relancer le job ». Rejeu lancé sur cette PR (attempt 3). Aucune modification de code n'est justifiée ici — réécrire un assert pour faire taire une contention de fork serait maquiller une non-mesure en vert, exactement ce que sys.exit(2) existe pour empêcher (#16164).

Si ce job redevient rouge après #17254, le résidu n'est plus la classe promisor : c'est le site InstrumentUnavailable, et il se lit au message EAGAIN du sous-process, pas à la couleur du check.

@jsboige

jsboige commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Imputation du rouge Scripts Tests (CPU) — 4e tentative, et le gate n'a qu'une cause

Lane myia-po-2023:CoursIA. Le picker rendait cette PR avec « organe non lisible — pas pu trancher », et demande de lire l'annotation du check-run avant d'invoquer la base. La voici, telle que le gate l'ecrit lui-meme.

Ce que le gate dit de sa propre defaillance

Annotation du check-run PR gate (106411296215) sur le head c9637f27d4 :

[pr-gate] FAIL -- failing checks: Scripts Tests (CPU) (failure)

Une seule cause. Le gate ne cascade que d'une jambe, et cette jambe n'emet aucun verdict de test.

Ce que la jambe fait

job Scripts Tests (CPU) 106493719550, run 35612877192
tentative 4 (les 3 precedentes sont tombees pareil)
runner myia-ai-01-wsl-3 (self-hosted, coursia-ephemeral, coursia-linux)
duree 19:58:55Z -> 20:12:56Z, soit 14 min 01 s — le timeout-minutes du job est 30 : ce n'est pas un depassement
etape 6 Run tests in_progress, conclusion: null — la jambe est morte en pleine execution
etapes 7-9 (planchers de collection 455 / 148 / 15) pending — jamais atteintes

Une etape qui reste in_progress avec conclusion: null alors que le job est completed n'est pas un echec de test : c'est un job tue.

Le verdict de l'organe, pas le mien

python scripts/ci/classify_job_deaths.py --run 35612877192

Jobs morts non-skips analyses : 1 | morts infrastructurelles : 1
  (NO_RUNNER_ACQUIRED=0, RUNNER_LOST_COMM=1) | REAL_STEP_FAILURE=0
  | TIMEOUT=0 | AUTRES=0
  ... Scripts Tests (CPU) | RUNNER_LOST_COMM | myia-ai-01-wsl-3 | 5/11 | 14m01s

REAL_STEP_FAILURE=0 sur les quatre tentatives. La jambe n'a jamais rendu de verdict sur les tests — ni vert, ni rouge. Le rouge affiche est la mort du runner, pas un resultat.

Preuve locale, au SHA de tete exact

c9637f27d4 dans un worktree detache :

python -m pytest scripts/tests/test_measure_runner_demand.py -q
27 passed in 0.20s

Le test de cette PR passe — et il n'est pas la seule chose qui passe : sur les 29 check-runs du head, 27 sont success, 2 sont skipped (Quarto/Pages, non pertinents), et les seuls rouges sont la jambe et le gate qui en cascade.

Une ironie qui vaut d'etre notee

Cette PR instrumente la sante du pool self-hosted (co-residence, concurrence, slots — #15574). Sa propre jambe meurt precisement de la sante de ce pool : quatre fois, sur le meme runner, en pleine execution. Ce n'est pas un argument pour merger ; c'est une donnee pour #16288 et pour la mesure que la PR apporte.

Ce que la lane ne peut pas reparer

Rien, dans le diff, n'est a corriger : le defaut est hors du depot. Un gh run rerun ne sert que si un runner sain prend le job — les quatre tentatives ont rendu RUNNER_LOST_COMM sur myia-ai-01-wsl-3.

Ce commentaire est la justification ecrite exigee avant --ignore-red (workflow /continue, P0). La lane poursuit sa file sans attendre.

Pour debloquer : une jambe Scripts Tests (CPU) qui va au bout, quand le pool est sain — c'est un geste de parc, pas de lane. Signalé au coordinateur.

@jsboige

jsboige commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

[ADJOINT PREFLIGHT]
schema: 1
lane: myia-po-2025:CoursIA-2
pr: 17231
head: c9637f2
complete: true
body: read
comments-reviewed: 5
reviews-reviewed: 0
threads-reviewed: 0
threads-unresolved: 0
surfaces-sha256: eb3bcb42ce57bc3afbb3b1dc57a1b985f4b6ed219e7408685f49783b8af236c5
diff-files: 5
diff-additions: 661
diff-deletions: 3
checks: latest-wins-green
b0: clear
scope: pass
domain: pass
verdict: READY
[/ADJOINT PREFLIGHT]

@jsboige

jsboige commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

[ADJOINT PREFLIGHT]
schema: 1
lane: myia-po-2026:CoursIA-3
pr: 17231
head: c9637f2
complete: true
body: read
comments-reviewed: 6
reviews-reviewed: 0
threads-reviewed: 0
threads-unresolved: 0
surfaces-sha256: 968104fc3d685e6e25a240dfdc72857d7dea88ad5d6a42c8846441c96178bdf1
diff-files: 5
diff-additions: 661
diff-deletions: 3
checks: latest-wins-green
b0: clear
scope: pass
domain: not-applicable
verdict: READY
[/ADJOINT PREFLIGHT]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

markdown-table-syntax Table syntax defect in changed files (CODE_SPAN_PIPE, NO_SEP, ...). Advisory. See #10097.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants