From e9d6c31ff77da47c4a7b42be3f30ac8fe3dcd7a2 Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 27 Oct 2026 22:40:01 +0100 Subject: [PATCH 1/6] Fix(ci,#22937): vague 2 -- forme hybride de debordement sur les workflows a fort volume Phase A (PR #22461) a pose la forme hybride auditee sur quelques workflows. Cette vague l'etend a 6 workflows / 8 jobs, qui representaient 40 des 81 jobs heberges en file (mesure du 27/10 a 22h40Z). Le point de conception asked par le coordinateur est tranche par la mesure : le "pendule du 16/10" n'est pas un probleme de taille de cohorte, c'est un defaut de SENS DE LA COMMANDE. La phase A est une bascule EXCLUSIVE (local XOR heberge), et la condition de liberation du controleur exigeait une file hebergee VIDE -- une condition structurellement inatteignable, le depot ayant toujours des jobs heberges exclusifs (CodeQL, Lean CI, advisories). La variable ne pouvait donc jamais revenir a `false` : une bascule a sens unique. La liberation devient l'AGE de la file hebergee (aucun job en file depuis plus de --saturation-minutes), symetrique de la condition de saturation, avec une bande morte d'1 a 2 jobs. Mesure live du 27/10 a 23h50Z sur le controleur corrige : variable a `true`, 4 jobs en file, aucun de plus de 15 min d'age, 29 slots locaux libres -> SWITCH_FALSE. Sous l'ancienne regle, ce meme etat donnait HOLD. Pas de seconde cohorte : elle ajouterait une falaise, pas un mecanisme. Co-Authored-By: Claude Haiku 5.5 --- .../workflows/always-on-metadata-guards.yml | 16 ++++-- .github/workflows/ci-overflow-controller.yml | 3 +- .github/workflows/enrich-quality-gate.yml | 11 +++- .../workflows/notebook-execution-required.yml | 33 +++++++++--- .github/workflows/notebook-validation.yml | 10 +++- .../quarto-render-list-freshness.yml | 11 +++- .github/workflows/twin-parity.yml | 31 ++++++++--- docs/coordination/ci-overflow-controller.md | 28 ++++++++-- scripts/ci/check_self_hosted_runner_policy.py | 22 ++++++++ scripts/ci/ci_overflow_controller.py | 43 ++++++++------- scripts/tests/test_ci_overflow_controller.py | 52 +++++++++++++++++++ 11 files changed, 215 insertions(+), 45 deletions(-) diff --git a/.github/workflows/always-on-metadata-guards.yml b/.github/workflows/always-on-metadata-guards.yml index 4f9a054ac45..a4b6591babc 100644 --- a/.github/workflows/always-on-metadata-guards.yml +++ b/.github/workflows/always-on-metadata-guards.yml @@ -78,14 +78,22 @@ jobs: always-on-metadata-guards: name: "Always-on metadata guards -- 3 organes, 1 checkout" # Routage #14283 tranche 3c (ai-01 2026-09-02) : jambe Linux auto-hebergee. - # Garde anti-fork au niveau job + runs-on STATIQUE (une expression leverait - # DYNAMIC_RUNS_ON dans check_self_hosted_runner_policy.py). - # Retour arriere = remettre `runs-on: ubuntu-latest` + retrait de l'allowlist. + # Garde anti-fork au niveau job + runs-on sous la FORME HYBRIDE AUDITEE + # (une expression non auditee leverait DYNAMIC_RUNS_ON dans + # check_self_hosted_runner_policy.py). # Rapatrie sur l'offre GitHub le 2026-10-25 (#17397, consigne user : le CI local pallie # les limites de l'offre hebergee, il ne la remplace pas). Mesure : 3 jobs heberges en # cours pour un plafond d'environ 20, 42 jobs sur 42 en file sur coursia-linux. # L'entree reste dans SELF_HOSTED_WORKFLOW_ALLOWLIST : retour = remettre le label. - runs-on: ubuntu-latest + # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- + # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; + # tout le reste (variable absente ou fausse, forks, push/schedule/ + # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : + # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. + # Retour arriere = variable a `false` ou supprimee : routage 100 % + # heberge, sans toucher ce fichier. Limite : runs-on se resout a la + # creation du job, les runs deja en file ne bougent pas. + runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository timeout-minutes: 10 diff --git a/.github/workflows/ci-overflow-controller.yml b/.github/workflows/ci-overflow-controller.yml index f749a58aed5..cd466d2a226 100644 --- a/.github/workflows/ci-overflow-controller.yml +++ b/.github/workflows/ci-overflow-controller.yml @@ -9,7 +9,8 @@ name: CI overflow controller (phase B) # QUI ECRIT -- option (a) de l'arbitrage user du 27/10 (c.6441695482) : # le job **ecrit** la variable, avec le secret `ACTIONS_VARIABLES_PAT` (droit # fin `Variables: read-write` sur `jsboige/CoursIA` SEUL, pose le -# 2026-10-27T17:05:48Z, expiration 2027-01-25). Le secret est expose AU SEUL +# 2026-10-27T17:05:48Z, expiration 2027-10-27 -- regeneree a 12 mois). +# Le secret est expose AU SEUL # pas qui ecrit : le checkout et le setup-python ne le voient pas. # # Les portees `permissions:` restent `actions: read` + `contents: read` : la diff --git a/.github/workflows/enrich-quality-gate.yml b/.github/workflows/enrich-quality-gate.yml index 93311fb8c04..1d5aa687482 100644 --- a/.github/workflows/enrich-quality-gate.yml +++ b/.github/workflows/enrich-quality-gate.yml @@ -56,7 +56,16 @@ permissions: jobs: enrich-quality: name: "No enrich-quality regression in changed notebooks" - runs-on: ubuntu-latest + # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- + # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; + # tout le reste (variable absente ou fausse, forks, push/schedule/ + # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : + # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. + # Retour arriere = variable a `false` ou supprimee : routage 100 % + # heberge, sans toucher ce fichier. Limite : runs-on se resout a la + # creation du job, les runs deja en file ne bougent pas. + runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} + if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository steps: - name: Checkout diff --git a/.github/workflows/notebook-execution-required.yml b/.github/workflows/notebook-execution-required.yml index 6792895c55c..13a3042bad8 100644 --- a/.github/workflows/notebook-execution-required.yml +++ b/.github/workflows/notebook-execution-required.yml @@ -53,11 +53,19 @@ jobs: detect-changes: name: Detect notebook changes # Routage #14283 tranche 3c (ai-01 2026-09-02) : jambe Linux auto-hebergee. - # Garde anti-fork au niveau job + runs-on STATIQUE (une expression leverait - # DYNAMIC_RUNS_ON dans check_self_hosted_runner_policy.py). - # Retour arriere = remettre `runs-on: ubuntu-latest` + retrait de l'allowlist. + # Garde anti-fork au niveau job + runs-on sous la FORME HYBRIDE AUDITEE + # (une expression non auditee leverait DYNAMIC_RUNS_ON dans + # check_self_hosted_runner_policy.py). # Offre GitHub d'abord (#17397, consigne user du 2026-10-25) : le CI local pallie ses limites. Retour = remettre le label. - runs-on: ubuntu-latest + # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- + # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; + # tout le reste (variable absente ou fausse, forks, push/schedule/ + # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : + # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. + # Retour arriere = variable a `false` ou supprimee : routage 100 % + # heberge, sans toucher ce fichier. Limite : runs-on se resout a la + # creation du job, les runs deja en file ne bougent pas. + runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository outputs: notebooks: ${{ steps.detect.outputs.notebooks }} @@ -98,11 +106,20 @@ jobs: validate-static: name: Static validation (H.1/H.3/C.1) # Routage #14283 tranche 3c (ai-01 2026-09-02) : jambe Linux auto-hebergee. - # Garde anti-fork au niveau job + runs-on STATIQUE (une expression leverait - # DYNAMIC_RUNS_ON dans check_self_hosted_runner_policy.py). - # Retour arriere = remettre `runs-on: ubuntu-latest` + retrait de l'allowlist. + # Garde anti-fork au niveau job + runs-on sous la FORME HYBRIDE AUDITEE + # (une expression non auditee leverait DYNAMIC_RUNS_ON dans + # check_self_hosted_runner_policy.py). # Offre GitHub d'abord (#17397, consigne user du 2026-10-25) : le CI local pallie ses limites. Retour = remettre le label. - runs-on: ubuntu-latest + # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- + # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; + # tout le reste (variable absente ou fausse, forks, push/schedule/ + # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : + # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. + # Retour arriere = variable a `false` ou supprimee : routage 100 % + # heberge, sans toucher ce fichier. Limite : runs-on se resout a la + # creation du job, les runs deja en file ne bougent pas. + runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} + if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository needs: detect-changes if: (github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository) && needs.detect-changes.outputs.has_notebooks > 0 diff --git a/.github/workflows/notebook-validation.yml b/.github/workflows/notebook-validation.yml index c1bf971930c..4cc850025a7 100644 --- a/.github/workflows/notebook-validation.yml +++ b/.github/workflows/notebook-validation.yml @@ -28,7 +28,15 @@ jobs: # saute les PRs de fork (pr_gate.py compte `skipped` comme OK) : aucun code de # fork n'atteint le runner. Retour arriere = revert de la PR. # Offre GitHub d'abord (#17397, consigne user du 2026-10-25) : le CI local pallie ses limites. Retour = remettre le label. - runs-on: ubuntu-latest + # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- + # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; + # tout le reste (variable absente ou fausse, forks, push/schedule/ + # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : + # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. + # Retour arriere = variable a `false` ou supprimee : routage 100 % + # heberge, sans toucher ce fichier. Limite : runs-on se resout a la + # creation du job, les runs deja en file ne bougent pas. + runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository steps: diff --git a/.github/workflows/quarto-render-list-freshness.yml b/.github/workflows/quarto-render-list-freshness.yml index a9b93a692f0..76a053d7122 100644 --- a/.github/workflows/quarto-render-list-freshness.yml +++ b/.github/workflows/quarto-render-list-freshness.yml @@ -77,7 +77,16 @@ jobs: # regeneration de _quarto.yml -- exactement le conflit que ce garde veut # eteindre (arbitrage ai-01, review 5469480925 sur #19901). name: _quarto.yml render list freshness (advisory) - runs-on: ubuntu-latest + # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- + # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; + # tout le reste (variable absente ou fausse, forks, push/schedule/ + # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : + # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. + # Retour arriere = variable a `false` ou supprimee : routage 100 % + # heberge, sans toucher ce fichier. Limite : runs-on se resout a la + # creation du job, les runs deja en file ne bougent pas. + runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} + if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository timeout-minutes: 5 steps: - name: Checkout diff --git a/.github/workflows/twin-parity.yml b/.github/workflows/twin-parity.yml index 3786f93be12..55ba510b85f 100644 --- a/.github/workflows/twin-parity.yml +++ b/.github/workflows/twin-parity.yml @@ -76,16 +76,24 @@ jobs: twin-parity: name: "Twin parity audit (#8057)" # Routage #14283 tranche 3c (ai-01 2026-09-02) : jambe Linux auto-hebergee. - # Garde anti-fork au niveau job + runs-on STATIQUE (une expression leverait - # DYNAMIC_RUNS_ON dans check_self_hosted_runner_policy.py). + # Garde anti-fork au niveau job + runs-on sous la FORME HYBRIDE AUDITEE + # (une expression non auditee leverait DYNAMIC_RUNS_ON dans + # check_self_hosted_runner_policy.py). # 2026-10-27 (mesure ai-01) : migration vers `ubuntu-latest` par Q35 etape 2 # (#17397) -- `scripts/ci/audit_self_hosted_needs.py` certifie ce workflow # comme n'ayant besoin de RIEN qui n'existe que sur le parc local, et le parc # local etait sature PAR SES CHECKOUTS (mesure 00:45Z : 11 des 12 slots # occupes dans `actions/checkout`, 614 runs en file). La garde anti-fork # ci-dessous est CONSERVEE : aucun code de fork ne change de surface. - # Retour arriere = revert du commit (et remise de l'entree d'allowlist). - runs-on: ubuntu-latest + # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- + # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; + # tout le reste (variable absente ou fausse, forks, push/schedule/ + # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : + # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. + # Retour arriere = variable a `false` ou supprimee : routage 100 % + # heberge, sans toucher ce fichier. Limite : runs-on se resout a la + # creation du job, les runs deja en file ne bougent pas. + runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository steps: @@ -246,9 +254,18 @@ jobs: # pour le precedent advisory-then-blocking. name: "Twin parity SHA mismatch (#9399 volet b, advisory)" # Routage #14283 tranche 3c (ai-01 2026-09-02) : jambe Linux auto-hebergee. - # Garde anti-fork au niveau job + runs-on STATIQUE (une expression leverait - # DYNAMIC_RUNS_ON dans check_self_hosted_runner_policy.py). - runs-on: ubuntu-latest + # Garde anti-fork au niveau job + runs-on sous la FORME HYBRIDE AUDITEE + # (une expression non auditee leverait DYNAMIC_RUNS_ON dans + # check_self_hosted_runner_policy.py). + # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- + # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; + # tout le reste (variable absente ou fausse, forks, push/schedule/ + # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : + # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. + # Retour arriere = variable a `false` ou supprimee : routage 100 % + # heberge, sans toucher ce fichier. Limite : runs-on se resout a la + # creation du job, les runs deja en file ne bougent pas. + runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository steps: diff --git a/docs/coordination/ci-overflow-controller.md b/docs/coordination/ci-overflow-controller.md index 21e677074fa..d1c7077f04b 100644 --- a/docs/coordination/ci-overflow-controller.md +++ b/docs/coordination/ci-overflow-controller.md @@ -21,9 +21,29 @@ La phase A est **manuelle** (un operateur pose la variable). La phase B, decrite | etat mesure | decision | |---|---| | offre hebergee **saturee** (au moins `--saturation-jobs` jobs `ubuntu-latest` en file depuis plus de `--saturation-minutes`) **et** parc local libre (au moins `--min-free-slots` slots en ligne **et libres**) | variable a `true` | -| file hebergee **videe** (au plus `--drain-jobs` job en file) | variable a `false` | +| offre hebergee **qui suit** (aucun job `ubuntu-latest` en file depuis plus de `--saturation-minutes`) | variable a `false` | | parc local **lui-meme affame** (des jobs `coursia-linux` attendent) | variable a `false` (on rend la main a l'heberge) | -| sinon | **HOLD** (rien n'est ecrit) | +| sinon (1 ou 2 jobs ages : bande morte) | **HOLD** (rien n'est ecrit) | + +### La liberation se mesure par l'AGE de la file, pas par son VIDE (#22937 vague 2) + +La premiere version exigeait une file hebergee **videe** pour rendre la main. Cette +premise est **inatteignable** : le depot porte toujours des jobs heberges hors +debordement (CodeQL en setup par defaut, Lean CI, les advisories de starvation), donc +la variable ne pouvait plus jamais revenir a `false` une fois posee -- le controleur +etait un **commutateur a sens unique**. + +Mesure du 27/10 a 22h50Z : variable a `true` depuis 11:04Z, `hosted.queued = 74`, +`hosted.old = 0` (le plus ancien job attendait 15,0 min -- l'offre **suit**) et +**29 slots locaux libres**. Le parc local gardait la cohorte pendant que l'offre +hebergee avait de la marge. + +La liberation est donc desormais indexee sur l'**age** de la file : l'offre hebergee +rend la main des qu'aucun job n'y attend au-dela du seuil de saturation. C'est la +meme information que la saturation, lue dans l'autre sens -- et elle est +symetrique avec elle (un job de plus de 15 min en file est, par definition, un job +en file), ce qui donne une **bande morte** de 1 a 2 jobs ages entre les deux +premises. Un **dwell** de `--dwell-minutes` (90 par defaut) interdit deux bascules rapprochees : une variable qui oscille est pire qu'une variable stable. Le dwell est **coherent avec la cadence** : il la depasse (90 > 60 min), sinon il ne peut jamais opposer quoi que ce soit -- deux passages consecutifs d'un cron horaire sont toujours a plus de 60 min, et un dwell de 30 min etait donc inerte (point Hermes du 2026-10-27, 12:53Z). @@ -33,7 +53,7 @@ Une lecture **tronquee** (budget de lecture atteint) n'est pas une mesure illisi | lecture tronquee | conclusion bloquee | conclusion conservee | |---|---|---| -| offre hebergee | « la file hebergee est **videe** » -> pas de bascule vers `false` | « l'offre hebergee est **saturee** » -> la bascule vers `true` reste possible | +| offre hebergee | « l'offre hebergee **suit** » -> pas de bascule vers `false` | « l'offre hebergee est **saturee** » -> la bascule vers `true` reste possible | | parc local | « le parc local **n'est pas affame** » -> pas de bascule vers `true` | « le parc local **est affame** » -> la bascule vers `false` reste possible | Mesure du 2026-10-27 sur la file reelle, avant correction : lecture hebergee complete (`budget_unread = 0`) mais lecture **locale tronquee a 15 runs**, ages de 15 a 20 min -- donc **au-dessus** du seuil de 15 min, donc desertes par une lecture complete. Le compte `starved = 0` etait alors une **borne inferieure** : « le parc local n'est pas affame » n'etait pas etabli, et la garde de securite s'ouvrait en faux. Le budget local a ete aligne sur le budget hebergee (40 -> 120) pour que la lecture locale soit complete dans le regime nominal : a 40, le parc local (44 a 55 runs eligibles mesures) etait a la limite exacte du plafond, donc la troncature etait une loterie. @@ -61,7 +81,7 @@ En **local**, l'operateur n'a rien a poser : le jeton `jsboige` a le droit sur l ## Qui ecrit — arbitrage (a) du user (c.6441695482, 27/10) -**Le workflow automatise ecrit la variable.** Option (a) de l'arbitrage user du 2026-10-27 : le secret `ACTIONS_VARIABLES_PAT` existe dans Actions (pose le 2026-10-27T17:05:48Z) et porte le droit fin `Variables: read-write` sur `jsboige/CoursIA` **seul** (expiration 2027-01-25, renouvellement suivi par le coordinateur). Il est expose **au seul pas qui ecrit la variable** : ni le checkout ni le `setup-python` ne le voient. +**Le workflow automatise ecrit la variable.** Option (a) de l'arbitrage user du 2026-10-27 : le secret `ACTIONS_VARIABLES_PAT` existe dans Actions (pose le 2026-10-27T17:05:48Z) et porte le droit fin `Variables: read-write` sur `jsboige/CoursIA` **seul** (expiration **2027-10-27** -- regeneree a 12 mois ; renouvellement suivi par le coordinateur). Il est expose **au seul pas qui ecrit la variable** : ni le checkout ni le `setup-python` ne le voient. Les portees du job restent `actions: read` + `contents: read` : la portee `variables` **n'existe pas** dans la liste des portees admises sous `permissions:` (reference de syntaxe des workflows GitHub : actions, attestations, checks, contents, deployments, discussions, id-token, issues, packages, pages, pull-requests, security-events, statuses). Une cle hors liste rend le fichier entier invalide : la porter ici aurait suffi a empecher le workflow de demarrer. C'est donc un **PAT** qu'il faut, pas une portee. diff --git a/scripts/ci/check_self_hosted_runner_policy.py b/scripts/ci/check_self_hosted_runner_policy.py index e7f3a96b446..971a2d5ff0b 100644 --- a/scripts/ci/check_self_hosted_runner_policy.py +++ b/scripts/ci/check_self_hosted_runner_policy.py @@ -637,6 +637,28 @@ "translation-drift.yml", "twin-parity.yml", "unique-check-run-names-guard.yml", + # 2026-10-27 (#22937 vague 2, lane myia-ai-01:CoursIA-2) : meme forme + # hybride de debordement que la phase A (#22461), appliquee aux + # workflows a fort volume qui n'etaient PAS dans la phase A. + # Les quatre autres fichiers de la vague 2 (twin-parity.yml, + # notebook-execution-required.yml, always-on-metadata-guards.yml, + # notebook-validation.yml) sont deja dans l'allowlist -- ils y etaient + # depuis la tranche 3c (#14283), avant leur deport Q35 du 27/10. + # Perimetre : 6 workflows, 8 jobs, 40 des 81 jobs heberges en file + # (mesure du 27/10 a 22h40Z). Exclus, avec leur motif : + # - codeql.yml : le setup par defaut de GitHub-heberge le job, la + # ligne `runs-on` du fichier n'est pas honorée ; + # - lean-ci-matrix.yml : la toolchain Lean vit dans une image distincte + # (label `coursia-lean`), pas dans le pool `coursia-linux` ; + # - local-path-waiver-guard.yml et perimeter-review-guard.yml : + # declencheurs `issue_comment` et `pull_request_review`, classes + # FORK_REACHABLE par l'instrument (default-deny) ; + # - les deux starvation-advisory : un chien de garde de la file ne + # doit pas occuper la file qu'il observe ; + # - ict-tests.yml : venv + pyphi/numpy/scipy, 8,1 min medianes -- le + # debordement absorbe d'abord les jobs courts. + "quarto-render-list-freshness.yml", + "enrich-quality-gate.yml", } GITHUB_HOSTED_LABELS = { "ubuntu-latest", diff --git a/scripts/ci/ci_overflow_controller.py b/scripts/ci/ci_overflow_controller.py index b8d7db6c288..df98a664457 100644 --- a/scripts/ci/ci_overflow_controller.py +++ b/scripts/ci/ci_overflow_controller.py @@ -122,9 +122,6 @@ # depuis plus de `--saturation-minutes`, l'offre hebergee est saturee. DEFAULT_SATURATION_JOBS = 3 DEFAULT_SATURATION_MINUTES = 15.0 -# En dessous de ce nombre de jobs hebergees en file, l'offre hebergee est -# consideree comme videe (elle rend la main). -DEFAULT_DRAIN_JOBS = 0 # Nombre de slots locaux libres minimum pour accepter le debordement. DEFAULT_MIN_FREE_SLOTS = 2 # Dwell : minimum entre deux bascules (anti-oscillation). @@ -188,7 +185,6 @@ def evaluate( *, saturation_jobs: int = DEFAULT_SATURATION_JOBS, saturation_minutes: float = DEFAULT_SATURATION_MINUTES, - drain_jobs: int = DEFAULT_DRAIN_JOBS, min_free_slots: int = DEFAULT_MIN_FREE_SLOTS, dwell_minutes: float = DEFAULT_DWELL_MINUTES, last_change_at: datetime | None = None, @@ -257,17 +253,31 @@ def switch(target: str, reason: str) -> Decision: f"coursia-linux en file) -- on rend la main a l'offre hebergee", ) - # 2. Offre hebergee drainee : elle suffit, on libere le local. - # PREMISSE NEGATIVE (« aucun job en file ») : elle n'est etablie que - # par une lecture COMPLETE. Sous troncature, le compte lu est une - # borne INFERIEURE -- la file peut contenir des jobs non lus. On ne - # peut donc pas conclure « file videe », et cette bascule est - # bloquee. - if hosted.queued <= drain_jobs and not hosted.truncated: + # 2. L'offre hebergee SUIT : elle rend la main. + # PREMISSE NEGATIVE (« aucun job n'attend au-dela du seuil de + # saturation ») : elle n'est etablie que par une lecture COMPLETE. + # Sous troncature, le compte lu est une borne INFERIEURE -- un job + # age peut etre dans la part non lue. On ne peut donc pas conclure + # « l'offre suit », et cette bascule est bloquee. + # + # #22937 vague 2 -- la premise devient l'AGE de la file, pas son + # VIDE. Mesure du 27/10 a 22h50Z : la variable etait a `true` depuis + # 11:04Z, et le controleur lisait `hosted.old = 0` (74 jobs en file, + # AUCUN n'attendait depuis plus de 15 min -> l'offre hebergee suit) + # avec 29 slots locaux LIBRES. L'ancienne premise -- « file hebergee + # videe », soit ZERO job en file -- ne pouvait plus jamais s'etablir : + # le depot a toujours des jobs heberges hors debordement (CodeQL, + # Lean CI, les advisories), donc la variable ne pouvait plus jamais + # revenir a `false`. Le controleur etait un commutateur a SENS UNIQUE, + # et le parc local gardait la cohorte pendant que l'offre hebergee + # unusedait ses propres slots. Avec la vague 2 (40 jobs de plus dans la + # cohorte), cette borne aurait ete totale. + if hosted.old == 0 and not hosted.truncated: return switch( "false", - f"file hebergee videe ({hosted.queued} job(s) en file) -- " - f"l'offre GitHub suffit, le parc local se libere", + f"l'offre hebergee suit ({hosted.queued} job(s) en file, aucun " + f"n'attend depuis plus de {saturation_minutes:.0f} min) -- " + f"elle suffit, le parc local se libere", ) # 3. Offre hebergee saturee ET parc local libre : le local absorbe. @@ -298,8 +308,8 @@ def switch(target: str, reason: str) -> Decision: ) if hosted.truncated: notes.append( - f"lecture hebergee TRONQUEE ({hosted.reason}) : « la file " - f"hebergee est videe » n'est pas etablie -> pas de bascule vers " + f"lecture hebergee TRONQUEE ({hosted.reason}) : « l'offre " + f"hebergee suit » n'est pas etablie -> pas de bascule vers " f"l'offre hebergee" ) return hold( @@ -567,7 +577,6 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--saturation-minutes", type=float, default=DEFAULT_SATURATION_MINUTES ) - parser.add_argument("--drain-jobs", type=int, default=DEFAULT_DRAIN_JOBS) parser.add_argument("--min-free-slots", type=int, default=DEFAULT_MIN_FREE_SLOTS) parser.add_argument("--dwell-minutes", type=float, default=DEFAULT_DWELL_MINUTES) parser.add_argument( @@ -677,7 +686,6 @@ def main(argv: list[str] | None = None) -> int: current, saturation_jobs=args.saturation_jobs, saturation_minutes=args.saturation_minutes, - drain_jobs=args.drain_jobs, min_free_slots=args.min_free_slots, dwell_minutes=args.dwell_minutes, last_change_at=updated_at, @@ -748,7 +756,6 @@ def main(argv: list[str] | None = None) -> int: "thresholds": { "saturation_jobs": args.saturation_jobs, "saturation_minutes": args.saturation_minutes, - "drain_jobs": args.drain_jobs, "min_free_slots": args.min_free_slots, "dwell_minutes": args.dwell_minutes, }, diff --git a/scripts/tests/test_ci_overflow_controller.py b/scripts/tests/test_ci_overflow_controller.py index ffe10d114c0..5cc64c2c0e3 100644 --- a/scripts/tests/test_ci_overflow_controller.py +++ b/scripts/tests/test_ci_overflow_controller.py @@ -474,3 +474,55 @@ def test_troncature_locale_ne_bloque_pas_la_constatation_d_affamescence(): reason="15 run(s) non lu(s)"), current="true", now=NOW) assert d.action == "SWITCH_FALSE" and d.target == "false" + + +# --- #22937 vague 2 : la premise de liberation est l'AGE, pas le VIDE ------ + +def test_offre_hebergee_suit_bascule_vers_false_malgre_une_file_non_vide(): + """Le cas MESURE du 27/10 : la file hebergee est pleine mais RIEN n'y + attend au-dela du seuil de saturation. + + Mesure de 22h50Z sur ce siege : variable a `true` depuis 11:04Z, + `hosted.queued = 74`, `hosted.old = 0` (le plus ancien attend 15,0 min), + 29 slots locaux LIBRES. L'offre hebergee suit : elle rend la main. + + Avant ce correctif, la premise exigeait une file hebergee VIDE + (`--drain-jobs 0`) -- inatteignable, le depot portant toujours des jobs + heberges hors debordement (CodeQL, Lean CI, les advisories). La variable + ne pouvait donc plus jamais revenir a `false` : le controleur etait un + commutateur a sens unique. + """ + d = ctl.evaluate(hosted(queued=74, old=0, oldest=15.0), + local(free=29, online=32), current="true", now=NOW) + assert d.action == "SWITCH_FALSE" and d.target == "false" + assert "suit" in d.reason + + +def test_offre_hebergee_en_retard_de_1_a_2_jobs_ne_bascule_pas(): + """Bande morte entre les deux premises : 1 ou 2 jobs ages ne declenchent + ni la liberation (l'offre suit -> aucun job de plus de 15 min) ni le + debordement (seuil de saturation = 3). Le controleur attend.""" + d = ctl.evaluate(hosted(queued=9, old=2, oldest=20.0), + local(free=7, online=14), current="false", now=NOW) + assert d.action == "HOLD" + + +def test_offre_hebergee_suit_mais_troncuree_ne_bascule_pas(): + """« l'offre hebergee suit » est une premise NEGATIVE : sous troncature, + un job age peut etre dans la part non lue -> on ne rend pas la main.""" + d = ctl.evaluate(hosted(queued=74, old=0, oldest=15.0, truncated=True, + reason="3 run(s) non lu(s)"), + local(free=29, online=32), current="true", now=NOW) + assert d.action == "HOLD" + assert "TRONQUEE" in d.reason + + +def test_l_offre_hebergee_suit_prime_sur_la_saturation(): + """Les deux premises ne peuvent pas etre vraies en meme temps : un job + age de plus de 15 min est par definition un job en file. Ce test + verifie l'ordre des regles quand la lecture est incoherente (old > 0 et + oldest < seuil) : la saturation -- premise POSITIVE -- l'emporte, elle + est confirmee par les jobs effectivement lus.""" + d = ctl.evaluate(hosted(queued=9, old=6, oldest=41.0), + local(free=7, online=14), current="false", now=NOW) + assert d.action == "SWITCH_TRUE" and d.target == "true" From f9a1cfb8cad6ce4a6545254b2243fb9b6dbda855 Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 27 Oct 2026 22:57:36 +0100 Subject: [PATCH 2/6] Revert(ci,#22937): twin-parity.yml sort du perimetre de la vague 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le fichier est deja porte par la PR #22944 (forme hybride de twin-parity.yml, seule), qui est dans la file de merge du coordinateur. Les deux PRs ne peuvent pas toucher le meme workflow : la vague 2 garde les 5 autres workflows, ce qui est le perimetre accorde (« 5 workflows par volume decroissant, puis le reste en seconde PR »). Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/twin-parity.yml | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/.github/workflows/twin-parity.yml b/.github/workflows/twin-parity.yml index 55ba510b85f..3786f93be12 100644 --- a/.github/workflows/twin-parity.yml +++ b/.github/workflows/twin-parity.yml @@ -76,24 +76,16 @@ jobs: twin-parity: name: "Twin parity audit (#8057)" # Routage #14283 tranche 3c (ai-01 2026-09-02) : jambe Linux auto-hebergee. - # Garde anti-fork au niveau job + runs-on sous la FORME HYBRIDE AUDITEE - # (une expression non auditee leverait DYNAMIC_RUNS_ON dans - # check_self_hosted_runner_policy.py). + # Garde anti-fork au niveau job + runs-on STATIQUE (une expression leverait + # DYNAMIC_RUNS_ON dans check_self_hosted_runner_policy.py). # 2026-10-27 (mesure ai-01) : migration vers `ubuntu-latest` par Q35 etape 2 # (#17397) -- `scripts/ci/audit_self_hosted_needs.py` certifie ce workflow # comme n'ayant besoin de RIEN qui n'existe que sur le parc local, et le parc # local etait sature PAR SES CHECKOUTS (mesure 00:45Z : 11 des 12 slots # occupes dans `actions/checkout`, 614 runs en file). La garde anti-fork # ci-dessous est CONSERVEE : aucun code de fork ne change de surface. - # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- - # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; - # tout le reste (variable absente ou fausse, forks, push/schedule/ - # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : - # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. - # Retour arriere = variable a `false` ou supprimee : routage 100 % - # heberge, sans toucher ce fichier. Limite : runs-on se resout a la - # creation du job, les runs deja en file ne bougent pas. - runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} + # Retour arriere = revert du commit (et remise de l'entree d'allowlist). + runs-on: ubuntu-latest if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository steps: @@ -254,18 +246,9 @@ jobs: # pour le precedent advisory-then-blocking. name: "Twin parity SHA mismatch (#9399 volet b, advisory)" # Routage #14283 tranche 3c (ai-01 2026-09-02) : jambe Linux auto-hebergee. - # Garde anti-fork au niveau job + runs-on sous la FORME HYBRIDE AUDITEE - # (une expression non auditee leverait DYNAMIC_RUNS_ON dans - # check_self_hosted_runner_policy.py). - # 2026-10-27 (#22937 vague 2, phase A) : forme hybride de debordement -- - # PR meme-depot ET vars.CI_OVERFLOW_LOCAL == 'true' -> jambe Linux locale ; - # tout le reste (variable absente ou fausse, forks, push/schedule/ - # dispatch) retombe sur ubuntu-latest (l'etat du 27/10). Bascule : - # `gh variable set CI_OVERFLOW_LOCAL --body true|false --repo jsboige/CoursIA`. - # Retour arriere = variable a `false` ou supprimee : routage 100 % - # heberge, sans toucher ce fichier. Limite : runs-on se resout a la - # creation du job, les runs deja en file ne bougent pas. - runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} + # Garde anti-fork au niveau job + runs-on STATIQUE (une expression leverait + # DYNAMIC_RUNS_ON dans check_self_hosted_runner_policy.py). + runs-on: ubuntu-latest if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository steps: From 18012cf64798997499397c4d7c7643ed6355d1fd Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 27 Oct 2026 23:04:17 +0100 Subject: [PATCH 3/6] Docs(ci,#22937): le perimetre de la vague 2 est 5 workflows, 6 jobs twin-parity.yml reste porte par #22944 (PR dediee, deja en file de merge chez le coordinateur) ; la vague 2 garde les cinq autres workflows, ce qui est le perimetre accorde. Le commentaire de l'allowlist porte maintenant le perimetre reel et le motif d'exclusion de quarto-pages-deploy.yml (le service Pages attache `build` et `deploy` a l'offre hebergee). Co-Authored-By: Claude Haiku 5.5 --- scripts/ci/check_self_hosted_runner_policy.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/ci/check_self_hosted_runner_policy.py b/scripts/ci/check_self_hosted_runner_policy.py index 971a2d5ff0b..7c423155007 100644 --- a/scripts/ci/check_self_hosted_runner_policy.py +++ b/scripts/ci/check_self_hosted_runner_policy.py @@ -640,14 +640,17 @@ # 2026-10-27 (#22937 vague 2, lane myia-ai-01:CoursIA-2) : meme forme # hybride de debordement que la phase A (#22461), appliquee aux # workflows a fort volume qui n'etaient PAS dans la phase A. - # Les quatre autres fichiers de la vague 2 (twin-parity.yml, - # notebook-execution-required.yml, always-on-metadata-guards.yml, + # Les trois autres fichiers de la vague 2 + # (notebook-execution-required.yml, always-on-metadata-guards.yml, # notebook-validation.yml) sont deja dans l'allowlist -- ils y etaient # depuis la tranche 3c (#14283), avant leur deport Q35 du 27/10. - # Perimetre : 6 workflows, 8 jobs, 40 des 81 jobs heberges en file - # (mesure du 27/10 a 22h40Z). Exclus, avec leur motif : + # `twin-parity.yml` y est aussi, mais il est porte par une PR + # dediee (#22944) : les deux PRs ne peuvent pas toucher le meme + # workflow, et le perimetre accorde est « 5 workflows par volume + # decroissant ». + # Perimetre : 5 workflows, 6 jobs. Exclus, avec leur motif : # - codeql.yml : le setup par defaut de GitHub-heberge le job, la - # ligne `runs-on` du fichier n'est pas honorée ; + # ligne `runs-on` du fichier n'est pas honoree ; # - lean-ci-matrix.yml : la toolchain Lean vit dans une image distincte # (label `coursia-lean`), pas dans le pool `coursia-linux` ; # - local-path-waiver-guard.yml et perimeter-review-guard.yml : @@ -656,7 +659,10 @@ # - les deux starvation-advisory : un chien de garde de la file ne # doit pas occuper la file qu'il observe ; # - ict-tests.yml : venv + pyphi/numpy/scipy, 8,1 min medianes -- le - # debordement absorbe d'abord les jobs courts. + # debordement absorbe d'abord les jobs courts ; + # - quarto-pages-deploy.yml : `build` et `deploy` dependent du + # service GitHub Pages (upload-pages-artifact, deploy-pages + OIDC) ; + # seul `validate-pr` est portable (Quarto par tarball curl). "quarto-render-list-freshness.yml", "enrich-quality-gate.yml", } From f0279de1e43769b6d7dfc300ba78eebc3fae61dd Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 28 Oct 2026 02:35:26 +0100 Subject: [PATCH 4/6] Fix(ci,#22937): vague 2 -- la forme hybride merge seule (volet controleur retire) Le volet controleur sort de cette PR pour qu'elle merge seule et vite. Le diff porte desormais uniquement la FORME HYBRIDE des workflows de garde (la variable de depot CI_OVERFLOW_LOCAL decide entre parc local et offre GitHub-hosted) et l'allowlist de l'instrument de politique self-hosted. Retire de ce push : - scripts/ci/ci_overflow_controller.py et scripts/tests/test_ci_overflow_controller.py (revenus a l'identique de origin/main) ; - les deux lignes `if:` anti-fork ajoutees (enrich-quality-gate.yml, quarto-render-list-freshness.yml) ; - un TROISIEME `if:` ajoute que la revue n'avait pas compte : un doublon de cle YAML sur le job `validate-static` de notebook-execution-required.yml (la cle `if:` y existait deja) -- retire lui aussi. Mesure : 5 workflows portent la forme hybride, 6 jobs au total. Aucune ligne `if:` ajoutee ni supprimee par ce diff. Controles locaux : - python scripts/ci/check_self_hosted_runner_policy.py -> OK (workflows=168 jobs=213 self_hosted=135) - pytest scripts/tests/test_ci_overflow_controller.py -> 31 passed - 6 workflows parses sans cle YAML dupliquee Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/enrich-quality-gate.yml | 1 - .../workflows/notebook-execution-required.yml | 1 - .../quarto-render-list-freshness.yml | 1 - docs/coordination/ci-overflow-controller.md | 26 ++-------- scripts/ci/ci_overflow_controller.py | 43 +++++++-------- scripts/tests/test_ci_overflow_controller.py | 52 ------------------- 6 files changed, 21 insertions(+), 103 deletions(-) diff --git a/.github/workflows/enrich-quality-gate.yml b/.github/workflows/enrich-quality-gate.yml index 1d5aa687482..80e21ac5dbe 100644 --- a/.github/workflows/enrich-quality-gate.yml +++ b/.github/workflows/enrich-quality-gate.yml @@ -65,7 +65,6 @@ jobs: # heberge, sans toucher ce fichier. Limite : runs-on se resout a la # creation du job, les runs deja en file ne bougent pas. runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} - if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository steps: - name: Checkout diff --git a/.github/workflows/notebook-execution-required.yml b/.github/workflows/notebook-execution-required.yml index 13a3042bad8..fc0d89eb5cd 100644 --- a/.github/workflows/notebook-execution-required.yml +++ b/.github/workflows/notebook-execution-required.yml @@ -119,7 +119,6 @@ jobs: # heberge, sans toucher ce fichier. Limite : runs-on se resout a la # creation du job, les runs deja en file ne bougent pas. runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} - if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository needs: detect-changes if: (github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository) && needs.detect-changes.outputs.has_notebooks > 0 diff --git a/.github/workflows/quarto-render-list-freshness.yml b/.github/workflows/quarto-render-list-freshness.yml index 76a053d7122..5ec166f361b 100644 --- a/.github/workflows/quarto-render-list-freshness.yml +++ b/.github/workflows/quarto-render-list-freshness.yml @@ -86,7 +86,6 @@ jobs: # heberge, sans toucher ce fichier. Limite : runs-on se resout a la # creation du job, les runs deja en file ne bougent pas. runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} - if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository timeout-minutes: 5 steps: - name: Checkout diff --git a/docs/coordination/ci-overflow-controller.md b/docs/coordination/ci-overflow-controller.md index d1c7077f04b..d53a71a926a 100644 --- a/docs/coordination/ci-overflow-controller.md +++ b/docs/coordination/ci-overflow-controller.md @@ -21,29 +21,9 @@ La phase A est **manuelle** (un operateur pose la variable). La phase B, decrite | etat mesure | decision | |---|---| | offre hebergee **saturee** (au moins `--saturation-jobs` jobs `ubuntu-latest` en file depuis plus de `--saturation-minutes`) **et** parc local libre (au moins `--min-free-slots` slots en ligne **et libres**) | variable a `true` | -| offre hebergee **qui suit** (aucun job `ubuntu-latest` en file depuis plus de `--saturation-minutes`) | variable a `false` | +| file hebergee **videe** (au plus `--drain-jobs` job en file) | variable a `false` | | parc local **lui-meme affame** (des jobs `coursia-linux` attendent) | variable a `false` (on rend la main a l'heberge) | -| sinon (1 ou 2 jobs ages : bande morte) | **HOLD** (rien n'est ecrit) | - -### La liberation se mesure par l'AGE de la file, pas par son VIDE (#22937 vague 2) - -La premiere version exigeait une file hebergee **videe** pour rendre la main. Cette -premise est **inatteignable** : le depot porte toujours des jobs heberges hors -debordement (CodeQL en setup par defaut, Lean CI, les advisories de starvation), donc -la variable ne pouvait plus jamais revenir a `false` une fois posee -- le controleur -etait un **commutateur a sens unique**. - -Mesure du 27/10 a 22h50Z : variable a `true` depuis 11:04Z, `hosted.queued = 74`, -`hosted.old = 0` (le plus ancien job attendait 15,0 min -- l'offre **suit**) et -**29 slots locaux libres**. Le parc local gardait la cohorte pendant que l'offre -hebergee avait de la marge. - -La liberation est donc desormais indexee sur l'**age** de la file : l'offre hebergee -rend la main des qu'aucun job n'y attend au-dela du seuil de saturation. C'est la -meme information que la saturation, lue dans l'autre sens -- et elle est -symetrique avec elle (un job de plus de 15 min en file est, par definition, un job -en file), ce qui donne une **bande morte** de 1 a 2 jobs ages entre les deux -premises. +| sinon | **HOLD** (rien n'est ecrit) | Un **dwell** de `--dwell-minutes` (90 par defaut) interdit deux bascules rapprochees : une variable qui oscille est pire qu'une variable stable. Le dwell est **coherent avec la cadence** : il la depasse (90 > 60 min), sinon il ne peut jamais opposer quoi que ce soit -- deux passages consecutifs d'un cron horaire sont toujours a plus de 60 min, et un dwell de 30 min etait donc inerte (point Hermes du 2026-10-27, 12:53Z). @@ -53,7 +33,7 @@ Une lecture **tronquee** (budget de lecture atteint) n'est pas une mesure illisi | lecture tronquee | conclusion bloquee | conclusion conservee | |---|---|---| -| offre hebergee | « l'offre hebergee **suit** » -> pas de bascule vers `false` | « l'offre hebergee est **saturee** » -> la bascule vers `true` reste possible | +| offre hebergee | « la file hebergee est **videe** » -> pas de bascule vers `false` | « l'offre hebergee est **saturee** » -> la bascule vers `true` reste possible | | parc local | « le parc local **n'est pas affame** » -> pas de bascule vers `true` | « le parc local **est affame** » -> la bascule vers `false` reste possible | Mesure du 2026-10-27 sur la file reelle, avant correction : lecture hebergee complete (`budget_unread = 0`) mais lecture **locale tronquee a 15 runs**, ages de 15 a 20 min -- donc **au-dessus** du seuil de 15 min, donc desertes par une lecture complete. Le compte `starved = 0` etait alors une **borne inferieure** : « le parc local n'est pas affame » n'etait pas etabli, et la garde de securite s'ouvrait en faux. Le budget local a ete aligne sur le budget hebergee (40 -> 120) pour que la lecture locale soit complete dans le regime nominal : a 40, le parc local (44 a 55 runs eligibles mesures) etait a la limite exacte du plafond, donc la troncature etait une loterie. diff --git a/scripts/ci/ci_overflow_controller.py b/scripts/ci/ci_overflow_controller.py index df98a664457..b8d7db6c288 100644 --- a/scripts/ci/ci_overflow_controller.py +++ b/scripts/ci/ci_overflow_controller.py @@ -122,6 +122,9 @@ # depuis plus de `--saturation-minutes`, l'offre hebergee est saturee. DEFAULT_SATURATION_JOBS = 3 DEFAULT_SATURATION_MINUTES = 15.0 +# En dessous de ce nombre de jobs hebergees en file, l'offre hebergee est +# consideree comme videe (elle rend la main). +DEFAULT_DRAIN_JOBS = 0 # Nombre de slots locaux libres minimum pour accepter le debordement. DEFAULT_MIN_FREE_SLOTS = 2 # Dwell : minimum entre deux bascules (anti-oscillation). @@ -185,6 +188,7 @@ def evaluate( *, saturation_jobs: int = DEFAULT_SATURATION_JOBS, saturation_minutes: float = DEFAULT_SATURATION_MINUTES, + drain_jobs: int = DEFAULT_DRAIN_JOBS, min_free_slots: int = DEFAULT_MIN_FREE_SLOTS, dwell_minutes: float = DEFAULT_DWELL_MINUTES, last_change_at: datetime | None = None, @@ -253,31 +257,17 @@ def switch(target: str, reason: str) -> Decision: f"coursia-linux en file) -- on rend la main a l'offre hebergee", ) - # 2. L'offre hebergee SUIT : elle rend la main. - # PREMISSE NEGATIVE (« aucun job n'attend au-dela du seuil de - # saturation ») : elle n'est etablie que par une lecture COMPLETE. - # Sous troncature, le compte lu est une borne INFERIEURE -- un job - # age peut etre dans la part non lue. On ne peut donc pas conclure - # « l'offre suit », et cette bascule est bloquee. - # - # #22937 vague 2 -- la premise devient l'AGE de la file, pas son - # VIDE. Mesure du 27/10 a 22h50Z : la variable etait a `true` depuis - # 11:04Z, et le controleur lisait `hosted.old = 0` (74 jobs en file, - # AUCUN n'attendait depuis plus de 15 min -> l'offre hebergee suit) - # avec 29 slots locaux LIBRES. L'ancienne premise -- « file hebergee - # videe », soit ZERO job en file -- ne pouvait plus jamais s'etablir : - # le depot a toujours des jobs heberges hors debordement (CodeQL, - # Lean CI, les advisories), donc la variable ne pouvait plus jamais - # revenir a `false`. Le controleur etait un commutateur a SENS UNIQUE, - # et le parc local gardait la cohorte pendant que l'offre hebergee - # unusedait ses propres slots. Avec la vague 2 (40 jobs de plus dans la - # cohorte), cette borne aurait ete totale. - if hosted.old == 0 and not hosted.truncated: + # 2. Offre hebergee drainee : elle suffit, on libere le local. + # PREMISSE NEGATIVE (« aucun job en file ») : elle n'est etablie que + # par une lecture COMPLETE. Sous troncature, le compte lu est une + # borne INFERIEURE -- la file peut contenir des jobs non lus. On ne + # peut donc pas conclure « file videe », et cette bascule est + # bloquee. + if hosted.queued <= drain_jobs and not hosted.truncated: return switch( "false", - f"l'offre hebergee suit ({hosted.queued} job(s) en file, aucun " - f"n'attend depuis plus de {saturation_minutes:.0f} min) -- " - f"elle suffit, le parc local se libere", + f"file hebergee videe ({hosted.queued} job(s) en file) -- " + f"l'offre GitHub suffit, le parc local se libere", ) # 3. Offre hebergee saturee ET parc local libre : le local absorbe. @@ -308,8 +298,8 @@ def switch(target: str, reason: str) -> Decision: ) if hosted.truncated: notes.append( - f"lecture hebergee TRONQUEE ({hosted.reason}) : « l'offre " - f"hebergee suit » n'est pas etablie -> pas de bascule vers " + f"lecture hebergee TRONQUEE ({hosted.reason}) : « la file " + f"hebergee est videe » n'est pas etablie -> pas de bascule vers " f"l'offre hebergee" ) return hold( @@ -577,6 +567,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--saturation-minutes", type=float, default=DEFAULT_SATURATION_MINUTES ) + parser.add_argument("--drain-jobs", type=int, default=DEFAULT_DRAIN_JOBS) parser.add_argument("--min-free-slots", type=int, default=DEFAULT_MIN_FREE_SLOTS) parser.add_argument("--dwell-minutes", type=float, default=DEFAULT_DWELL_MINUTES) parser.add_argument( @@ -686,6 +677,7 @@ def main(argv: list[str] | None = None) -> int: current, saturation_jobs=args.saturation_jobs, saturation_minutes=args.saturation_minutes, + drain_jobs=args.drain_jobs, min_free_slots=args.min_free_slots, dwell_minutes=args.dwell_minutes, last_change_at=updated_at, @@ -756,6 +748,7 @@ def main(argv: list[str] | None = None) -> int: "thresholds": { "saturation_jobs": args.saturation_jobs, "saturation_minutes": args.saturation_minutes, + "drain_jobs": args.drain_jobs, "min_free_slots": args.min_free_slots, "dwell_minutes": args.dwell_minutes, }, diff --git a/scripts/tests/test_ci_overflow_controller.py b/scripts/tests/test_ci_overflow_controller.py index 5cc64c2c0e3..ffe10d114c0 100644 --- a/scripts/tests/test_ci_overflow_controller.py +++ b/scripts/tests/test_ci_overflow_controller.py @@ -474,55 +474,3 @@ def test_troncature_locale_ne_bloque_pas_la_constatation_d_affamescence(): reason="15 run(s) non lu(s)"), current="true", now=NOW) assert d.action == "SWITCH_FALSE" and d.target == "false" - - -# --- #22937 vague 2 : la premise de liberation est l'AGE, pas le VIDE ------ - -def test_offre_hebergee_suit_bascule_vers_false_malgre_une_file_non_vide(): - """Le cas MESURE du 27/10 : la file hebergee est pleine mais RIEN n'y - attend au-dela du seuil de saturation. - - Mesure de 22h50Z sur ce siege : variable a `true` depuis 11:04Z, - `hosted.queued = 74`, `hosted.old = 0` (le plus ancien attend 15,0 min), - 29 slots locaux LIBRES. L'offre hebergee suit : elle rend la main. - - Avant ce correctif, la premise exigeait une file hebergee VIDE - (`--drain-jobs 0`) -- inatteignable, le depot portant toujours des jobs - heberges hors debordement (CodeQL, Lean CI, les advisories). La variable - ne pouvait donc plus jamais revenir a `false` : le controleur etait un - commutateur a sens unique. - """ - d = ctl.evaluate(hosted(queued=74, old=0, oldest=15.0), - local(free=29, online=32), current="true", now=NOW) - assert d.action == "SWITCH_FALSE" and d.target == "false" - assert "suit" in d.reason - - -def test_offre_hebergee_en_retard_de_1_a_2_jobs_ne_bascule_pas(): - """Bande morte entre les deux premises : 1 ou 2 jobs ages ne declenchent - ni la liberation (l'offre suit -> aucun job de plus de 15 min) ni le - debordement (seuil de saturation = 3). Le controleur attend.""" - d = ctl.evaluate(hosted(queued=9, old=2, oldest=20.0), - local(free=7, online=14), current="false", now=NOW) - assert d.action == "HOLD" - - -def test_offre_hebergee_suit_mais_troncuree_ne_bascule_pas(): - """« l'offre hebergee suit » est une premise NEGATIVE : sous troncature, - un job age peut etre dans la part non lue -> on ne rend pas la main.""" - d = ctl.evaluate(hosted(queued=74, old=0, oldest=15.0, truncated=True, - reason="3 run(s) non lu(s)"), - local(free=29, online=32), current="true", now=NOW) - assert d.action == "HOLD" - assert "TRONQUEE" in d.reason - - -def test_l_offre_hebergee_suit_prime_sur_la_saturation(): - """Les deux premises ne peuvent pas etre vraies en meme temps : un job - age de plus de 15 min est par definition un job en file. Ce test - verifie l'ordre des regles quand la lecture est incoherente (old > 0 et - oldest < seuil) : la saturation -- premise POSITIVE -- l'emporte, elle - est confirmee par les jobs effectivement lus.""" - d = ctl.evaluate(hosted(queued=9, old=6, oldest=41.0), - local(free=7, online=14), current="false", now=NOW) - assert d.action == "SWITCH_TRUE" and d.target == "true" From 6a6c73f44d9b76d68d32aff5f5d0f9989ddb13ad Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 28 Oct 2026 02:38:30 +0100 Subject: [PATCH 5/6] Fix(ci,#22937): la forme hybride seule -- le volet controleur part en PR dediee Dernier retrait : le fichier de workflow du controleur et sa doc sortent aussi de cette PR. Le diff ne porte plus QUE la forme hybride (5 workflows, 6 jobs) et l'allowlist de l'instrument de politique self-hosted. Le volet controleur (regle de liberation par l'AGE de la file, budget de lecture local, dwell a 90 min, tests associes, doc) n'est pas perdu : il part dans une PR dediee, pour que les deux se merge independamment et sans qu'un fichier soit modifie par les deux. Mesure : 5 workflows portent la forme hybride, 6 jobs au total. Aucune ligne `if:` ajoutee ni supprimee par ce diff. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/ci-overflow-controller.yml | 3 +-- docs/coordination/ci-overflow-controller.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-overflow-controller.yml b/.github/workflows/ci-overflow-controller.yml index cd466d2a226..f749a58aed5 100644 --- a/.github/workflows/ci-overflow-controller.yml +++ b/.github/workflows/ci-overflow-controller.yml @@ -9,8 +9,7 @@ name: CI overflow controller (phase B) # QUI ECRIT -- option (a) de l'arbitrage user du 27/10 (c.6441695482) : # le job **ecrit** la variable, avec le secret `ACTIONS_VARIABLES_PAT` (droit # fin `Variables: read-write` sur `jsboige/CoursIA` SEUL, pose le -# 2026-10-27T17:05:48Z, expiration 2027-10-27 -- regeneree a 12 mois). -# Le secret est expose AU SEUL +# 2026-10-27T17:05:48Z, expiration 2027-01-25). Le secret est expose AU SEUL # pas qui ecrit : le checkout et le setup-python ne le voient pas. # # Les portees `permissions:` restent `actions: read` + `contents: read` : la diff --git a/docs/coordination/ci-overflow-controller.md b/docs/coordination/ci-overflow-controller.md index d53a71a926a..21e677074fa 100644 --- a/docs/coordination/ci-overflow-controller.md +++ b/docs/coordination/ci-overflow-controller.md @@ -61,7 +61,7 @@ En **local**, l'operateur n'a rien a poser : le jeton `jsboige` a le droit sur l ## Qui ecrit — arbitrage (a) du user (c.6441695482, 27/10) -**Le workflow automatise ecrit la variable.** Option (a) de l'arbitrage user du 2026-10-27 : le secret `ACTIONS_VARIABLES_PAT` existe dans Actions (pose le 2026-10-27T17:05:48Z) et porte le droit fin `Variables: read-write` sur `jsboige/CoursIA` **seul** (expiration **2027-10-27** -- regeneree a 12 mois ; renouvellement suivi par le coordinateur). Il est expose **au seul pas qui ecrit la variable** : ni le checkout ni le `setup-python` ne le voient. +**Le workflow automatise ecrit la variable.** Option (a) de l'arbitrage user du 2026-10-27 : le secret `ACTIONS_VARIABLES_PAT` existe dans Actions (pose le 2026-10-27T17:05:48Z) et porte le droit fin `Variables: read-write` sur `jsboige/CoursIA` **seul** (expiration 2027-01-25, renouvellement suivi par le coordinateur). Il est expose **au seul pas qui ecrit la variable** : ni le checkout ni le `setup-python` ne le voient. Les portees du job restent `actions: read` + `contents: read` : la portee `variables` **n'existe pas** dans la liste des portees admises sous `permissions:` (reference de syntaxe des workflows GitHub : actions, attestations, checks, contents, deployments, discussions, id-token, issues, packages, pages, pull-requests, security-events, statuses). Une cle hors liste rend le fichier entier invalide : la porter ici aurait suffi a empecher le workflow de demarrer. C'est donc un **PAT** qu'il faut, pas une portee. From bd7e0f27db08a1640c396d07b24cef9e8f5d431b Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 28 Oct 2026 06:03:49 +0100 Subject: [PATCH 6/6] Fix(ci,#23016): borne de duree sur les quatre jobs hybrides sans timeout-minutes Review 5677732011 : quatre des six jobs qui atteignent le parc local en forme hybride n'avaient pas de `timeout-minutes` -- ils heritaient de 360 min. Le 15/10, des jobs sans borne ont tenu 16 slots ephemeres sur 18 pendant des heures. Bornes posees (celle proposee par la review, ~3x la duree mesuree sur les 8 derniers succes, plus la marge d'un clone froid) : - enrich-quality (enrich-quality-gate.yml) : 10 (mesure 0-1 min) - detect-changes (notebook-execution-required.yml) : 5 (mesure 0-1 min) - validate-static (notebook-execution-required.yml) : 10 (mesure 1 min) - validate-notebooks (notebook-validation.yml) : 20 (mesure 4-6 min) Les deux jobs deja bornes (always-on-metadata-guards 10, check 5) sont inchanges. Aucune ligne `if:` ni `runs-on:` touchee ; la garde de politique runner reste verte (168 workflows, 213 jobs, 135 self-hosted). Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/enrich-quality-gate.yml | 7 +++++++ .github/workflows/notebook-execution-required.yml | 12 ++++++++++++ .github/workflows/notebook-validation.yml | 6 ++++++ 3 files changed, 25 insertions(+) diff --git a/.github/workflows/enrich-quality-gate.yml b/.github/workflows/enrich-quality-gate.yml index 80e21ac5dbe..bcebc282f90 100644 --- a/.github/workflows/enrich-quality-gate.yml +++ b/.github/workflows/enrich-quality-gate.yml @@ -66,6 +66,13 @@ jobs: # creation du job, les runs deja en file ne bougent pas. runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} + # Borne de duree (#23016, review 5677732011) : sans `timeout-minutes`, un + # job herite de 360 min et peut figer un slot ephemere du parc local -- + # 16 slots sur 18 ont ete tenus pendant des heures le 15/10. La borne vaut + # ~3x la duree mesuree (0-1 min sur les 8 derniers succes), plus la marge + # d'un clone froid. + timeout-minutes: 10 + steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/notebook-execution-required.yml b/.github/workflows/notebook-execution-required.yml index fc0d89eb5cd..7ddd45cd6dd 100644 --- a/.github/workflows/notebook-execution-required.yml +++ b/.github/workflows/notebook-execution-required.yml @@ -67,6 +67,12 @@ jobs: # creation du job, les runs deja en file ne bougent pas. runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository + # Borne de duree (#23016, review 5677732011) : sans `timeout-minutes`, un + # job herite de 360 min et peut figer un slot ephemere du parc local -- + # 16 slots sur 18 ont ete tenus pendant des heures le 15/10. La borne vaut + # ~3x la duree mesuree (0-1 min sur les 8 derniers succes), plus la marge + # d'un clone froid. + timeout-minutes: 5 outputs: notebooks: ${{ steps.detect.outputs.notebooks }} has_notebooks: ${{ steps.detect.outputs.has_notebooks }} @@ -121,6 +127,12 @@ jobs: runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} needs: detect-changes if: (github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository) && needs.detect-changes.outputs.has_notebooks > 0 + # Borne de duree (#23016, review 5677732011) : sans `timeout-minutes`, un + # job herite de 360 min et peut figer un slot ephemere du parc local -- + # 16 slots sur 18 ont ete tenus pendant des heures le 15/10. La borne vaut + # ~3x la duree mesuree (1 min sur les 8 derniers succes), plus la marge + # d'un clone froid. + timeout-minutes: 10 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/notebook-validation.yml b/.github/workflows/notebook-validation.yml index 4cc850025a7..e9b54f73548 100644 --- a/.github/workflows/notebook-validation.yml +++ b/.github/workflows/notebook-validation.yml @@ -38,6 +38,12 @@ jobs: # creation du job, les runs deja en file ne bougent pas. runs-on: ${{ (github.event.pull_request.head.repo.full_name == github.repository && vars.CI_OVERFLOW_LOCAL == 'true') && fromJSON('["self-hosted","coursia-ephemeral","coursia-linux"]') || 'ubuntu-latest' }} if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository + # Borne de duree (#23016, review 5677732011) : sans `timeout-minutes`, un + # job herite de 360 min et peut figer un slot ephemere du parc local -- + # 16 slots sur 18 ont ete tenus pendant des heures le 15/10. La borne vaut + # ~3x la duree mesuree (4-6 min sur les 8 derniers succes), plus la marge + # d'un clone froid. + timeout-minutes: 20 steps: - uses: actions/checkout@v4