From 3713e164f9f7a78fe7d50500f0e5d3eecc2e3b86 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 28 Oct 2026 18:57:01 +0100 Subject: [PATCH 1/3] Feat(ci,#22937): la file de cohorte deja constituee est reorientee (volet c) Le `runs-on` se resout a la creation du job : un run de cohorte cree avant le basculement reste sur l'offre hebergee (mesure du coordinateur : 155 runs de cohorte en file, tous crees avant le flip). Le levier est mesure : une RELANCE relit `vars.*`. Run 40707135271 de `docs-transients-guard` (PR #23041 de la lane) -- job d'origine, cree a 14:33Z quand la variable valait `false` : `labels: [ubuntu-latest]`. Job recree par la relance a 17:48Z, variable a `true` : `labels: [self-hosted, coursia-ephemeral, coursia-linux]`, runner `myia-ai-01-wsl-4`. L'annulation precede la relance (un run en file n'accepte pas la relance). L'organe applique le geste dans ses bornes : variable a `true`, lecture des slots complete et non tronquee, au plus le nombre de slots locaux libres, age minimal (15 min), perimetre limite aux workflows de la cohorte (liste derivee du depot). En dry-run, les cibles sont annoncees et rien n'est execute. Chaque run vise est journalise (reoriented / failed avec motif) dans le rapport et le resume du job. Le job du controleur recoit `actions: write` : annuler et relancer un run exige ce droit. Le jeton d'ecriture de la variable n'est pas utilise pour cela -- deux canaux distincts. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/ci-overflow-controller.yml | 11 +- docs/coordination/ci-overflow-controller.md | 36 +++ scripts/ci/ci_overflow_controller.py | 270 +++++++++++++++++++ scripts/tests/test_ci_overflow_controller.py | 187 +++++++++++++ 4 files changed, 503 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-overflow-controller.yml b/.github/workflows/ci-overflow-controller.yml index 6ead6650f2b..ffe0c02a7fe 100644 --- a/.github/workflows/ci-overflow-controller.yml +++ b/.github/workflows/ci-overflow-controller.yml @@ -84,7 +84,16 @@ on: type: string permissions: - actions: read + # `actions: write` : le controleur ANNULE puis RELANCE des runs de cohorte + # (volet (c) de #22937). Le `runs-on` d'un job se resout a la CREATION du + # job : un run de cohorte cree avant le basculement reste sur l'offre + # hebergee. Or une RELANCE relit `vars.*` (mesure du 28/10, run + # 40707135271 : job d'origine `ubuntu-latest`, job recree + # `[self-hosted, coursia-ephemeral, coursia-linux]`). Sans ce droit, la + # reorientation est refusee et le rapport la nomme. + # Le declencheur est `schedule`/`workflow_dispatch` : aucun code de fork + # n'atteint ce job. + actions: write contents: read concurrency: diff --git a/docs/coordination/ci-overflow-controller.md b/docs/coordination/ci-overflow-controller.md index 0f110fa5219..824593862c1 100644 --- a/docs/coordination/ci-overflow-controller.md +++ b/docs/coordination/ci-overflow-controller.md @@ -340,6 +340,42 @@ Le dwell est passe de 30 a **90 min** (point Hermes du 2026-10-27, 12:53Z) : le Le dwell amortit l'oscillation entre deux passages ; il ne cadenche pas, et il ne doit pas faire croire qu'une file revenue a la normale reste gouvernee par une decision d'il y a deux heures. +## La reorientation de la file de cohorte deja constituee (volet (c)) + +Le `runs-on` d'un job se resout **a la creation du job** : un run de cohorte cree avant +le basculement de la variable reste sur l'offre hebergee, meme apres le basculement. +Mesure du coordinateur (28/10 17:15Z) : **155 runs de cohorte en file, tous crees avant +le flip de 16:55Z** -- le debordement ne sert que les jobs neufs. + +Le levier est la **relance**, et il est **mesure** (28/10 17:48Z, run `40707135271` de +`docs-transients-guard`, sur la PR #23041 de la lane) : + +| etape | job d'origine (cree a 14:33Z, variable a `false`) | job recree par la relance (17:48Z, variable a `true`) | +|---|---|---| +| `labels` | `["ubuntu-latest"]` | `["self-hosted", "coursia-ephemeral", "coursia-linux"]` | +| runner | `GitHub Actions 1000742860` | `myia-ai-01-wsl-4` | + +Une **relance relit `vars.*`** : le job recree est resolu avec la valeur COURANTE de la +variable. L'organe applique donc ce geste, dans ses bornes : + +| garde | regle | +|---|---| +| variable | le levier n'a de sens qu'a `true` : a `false`, le job recree retombait sur l'heberge | +| slots | la lecture des slots doit etre **complete et non tronquee** -- on ne borne pas par une borne qu'on n'a pas mesuree | +| borne | **au plus le nombre de slots locaux libres** : chaque run reoriente occupe un slot ; 155 runs d'un coup sur 22 slots, c'est le symptome que l'organe combat | +| age | un run en file depuis moins de `--reorient-min-age` (15 min) n'est pas reoriente : il se reparait tout seul | +| perimetre | **seulement** les runs dont le workflow appartient a la cohorte (liste derivee du depot) : un workflow statique heberge n'entre pas dans la file de cohorte | +| dry-run | `--apply` absent -> les cibles sont **annoncees**, aucune n'est executee | + +Le geste est **annulation puis relance** : un run encore en file n'accepte pas la +relance. Il a droit `actions: write` (portee du job du controleur) ; le jeton +d'ecriture de la variable (`ACTIONS_VARIABLES_PAT`) n'est **pas** utilise pour lui -- +ce sont deux canaux distincts, comme pour tout le reste. + +Chaque run vise est **journalise** dans le rapport JSON (`reorientation.reoriented`, +`reorientation.failed` avec le motif) et dans le resume du job : un echec partiel est +nomme, jamais masque. + ## Voir aussi - [#22921](https://github.com/jsboige/CoursIA/pull/22921) — phase A, la forme hybride des 25 workflows ; diff --git a/scripts/ci/ci_overflow_controller.py b/scripts/ci/ci_overflow_controller.py index 92071295246..5f148f60c3b 100644 --- a/scripts/ci/ci_overflow_controller.py +++ b/scripts/ci/ci_overflow_controller.py @@ -222,6 +222,12 @@ # attendre 90 min la prolonge. La LIBERATION n'echappe jamais au dwell. DEFAULT_PANIC_MINUTES = 30.0 +# Age minimal d'un run de cohorte en file pour etre REORIENTE (annulation +# puis relance) vers le parc local (volet (c) de #22937). Meme ordre de +# grandeur que le seuil d'age de saturation : un run plus jeune n'a pas +# encore consommé d'attente. +DEFAULT_REORIENT_MIN_AGE = 15.0 + @dataclass class HostedSample: @@ -313,6 +319,39 @@ def charge(self) -> int: return self.queued + self.in_progress +@dataclass +class Reorientation: + """Etat de la REORIENTATION de la file de cohorte deja constituee. + + Le `runs-on` d'un job se resout a la CREATION du job : un run de cohorte + cree avant le basculement de la variable reste sur `ubuntu-latest` meme + apres le basculement (mesure du coordinateur, 28/10 17:15Z : 155 runs de + cohorte en file, tous crees avant le flip). + + Le levier mesure (28/10 17:48Z, run 40707135271 de `docs-transients-guard`) : + **une RELANCE relit `vars.*`**. Le job d'origine (cree a 14:33Z, variable + a `false`) portait `labels: [ubuntu-latest]` ; le job recree par la + relance (17:48Z, variable a `true`) porte + `labels: [self-hosted, coursia-ephemeral, coursia-linux]` et a demarre + sur `myia-ai-01-wsl-4`. Annuler puis relancer un run reoriente donc son + job vers le parc local. + + `candidates` : runs de cohorte en file (tous ages). `targets` : ceux + qui passent le seuil d'age ET la borne des slots libres. `reoriented` : + ceux dont l'annulation puis la relance ont reussi. `failed` : les autres, + avec le motif -- un echec partiel est nomme, jamais masque. + """ + + measured: bool = False + candidates: int = 0 + targets: list = field(default_factory=list) + reoriented: list = field(default_factory=list) + failed: list = field(default_factory=list) + free_slots: int = 0 + min_age_min: float = 0.0 + reason: str = "" + + def hosted_margin(hosted: HostedSample, cap: int = HOSTED_CONCURRENCY_CAP) -> int: """Marge de l'offre hebergee : plafond moins les jobs en file et en cours. @@ -1231,6 +1270,132 @@ def in_cohort(row: crs.JobRow) -> bool: ) +def collect_queued_cohort_runs( + repo: str, + cohort_paths: set[str], + token: str | None, + max_pages: int = 5, +) -> tuple[list[dict], bool, str]: + """Runs EN FILE dont le workflow appartient a la cohorte. + + Le chemin du workflow est dans la reponse des runs (`path`) : le filtre + ne coute aucun appel de plus, et la liste des chemins de la cohorte est + DERIVEE du depot (jamais recopiee). + + Rend `(lignes, complet, motif)`. `complet=False` quand un listing est + illisible : la reorientation est alors bloquee (fail-CLOSED) -- on ne + peut pas affirmer « la file de cohorte est connue » sur une lecture + partielle. + """ + lines: list[dict] = [] + if not cohort_paths: + return lines, False, "liste de la cohorte vide" + for page in range(1, max_pages + 1): + rc, out, err = _gh_api( + [ + f"repos/{repo}/actions/runs?status=queued&per_page=100&page={page}" + ], + token=token, + ) + if rc != 0: + message = (err or out or "").strip().splitlines() + return lines, False, ( + "listing de la file illisible (" + + (message[-1] if message else "raison inconnue") + + ")" + ) + try: + data = json.loads(out) + except json.JSONDecodeError: + return lines, False, "reponse non-JSON" + runs = data.get("workflow_runs") if isinstance(data, dict) else None + if not isinstance(runs, list): + return lines, False, "reponse inattendue" + for run in runs: + if not isinstance(run, dict): + continue + path = run.get("path") + if not isinstance(path, str) or path not in cohort_paths: + continue + run_id = run.get("id") + created = run.get("created_at") + if run_id is None or not isinstance(created, str): + continue + lines.append({"id": run_id, "path": path, "created_at": created}) + if len(runs) < 100: + break + return lines, True, "" + + +def select_reorientation_targets( + lines: list[dict], + free_slots: int, + min_age_minutes: float, + now: datetime, +) -> list[dict]: + """Cibles de la reorientation : les plus ANCIENS d'abord, bornées par les + slots locaux libres. + + La borne est le nombre de slots LIBRES mesure : chaque run reoriente + occupe un slot du parc local. Sans borne, une file de 155 runs + declencherait 155 jobs locaux d'un coup sur un parc de 22 slots -- le + parc local sature est exactement le symptome que le controleur combat. + + Un run dont la date de creation est illisible est IGNORE (il ne peut + pas etre age) : reorienter un run d'age inconnu, c'est risquer de + reorienter un run qui vient de partir. + """ + if free_slots <= 0: + return [] + aged: list[dict] = [] + for line in lines: + try: + stamp = datetime.fromisoformat( + str(line["created_at"]).replace("Z", "+00:00") + ) + except (KeyError, ValueError): + continue + age = (now - stamp).total_seconds() / 60.0 + if age < min_age_minutes: + continue + aged.append({**line, "age_min": round(age, 1)}) + aged.sort(key=lambda row: (-row["age_min"], str(row["id"]))) + return aged[:free_slots] + + +def reorient_run(repo: str, token: str | None, run_id) -> tuple[bool, str]: + """ANNULE puis RELANCE un run : le job recree relit `vars.*`. + + L'annulation precede la relance : un run encore en file n'accepte pas la + relance (`rerun` ne porte que sur un run termine ou annule). Mesure du + 28/10 : le job recree porte les labels du parc local quand la variable + est a `true`. + """ + rc, out, err = _gh_api( + [f"repos/{repo}/actions/runs/{run_id}/cancel"], + token=token, + method="POST", + ) + if rc != 0: + message = (err or out or "").strip().splitlines() + return False, ( + "annulation refusee : " + + (message[-1] if message else "raison inconnue") + ) + rc, out, err = _gh_api( + [f"repos/{repo}/actions/runs/{run_id}/rerun"], + token=token, + method="POST", + ) + if rc != 0: + message = (err or out or "").strip().splitlines() + return False, ( + "relance refusee : " + + (message[-1] if message else "raison inconnue") + ) + return True, "run annule puis relance" + + def render_summary(report: dict, rc: int) -> str: """Rend le resume du job (markdown) a partir du rapport JSON. @@ -1305,6 +1470,18 @@ def render_summary(report: dict, rc: int) -> str: else "NON MESUREE (pas de seconde lecture)" ), ] + reorientation = report.get("reorientation", {}) + if reorientation: + lines.append( + f"- reorientation : {len(reorientation.get('reoriented') or [])} " + f"run(s) de cohorte reoriente(s) vers le parc local, " + f"{len(reorientation.get('failed') or [])} en echec, " + f"{reorientation.get('candidates')} candidat(s) en file, " + f"{reorientation.get('free_slots')} slot(s) libre(s) " + f"(age seuil {reorientation.get('min_age_min')} min)" + ) + if reorientation.get("reason"): + lines.append(f" - motif : {reorientation.get('reason')}") degraded = [ item for item in (report.get("write_blocked"), report.get("state_reason")) if item @@ -1408,6 +1585,18 @@ def main(argv: list[str] | None = None) -> int: "comportement d'un declenchement manuel)" ), ) + parser.add_argument( + "--reorient-min-age", + type=float, + default=DEFAULT_REORIENT_MIN_AGE, + metavar="MINUTES", + help=( + "age minimal d'un run de cohorte en file pour etre reoriente " + "(annulation puis relance) vers le parc local (volet (c)) : " + "reorienter un run qui vient d'etre cree n'apporte rien, il " + "reparait deja le run seul" + ), + ) parser.add_argument("--json", action="store_true") parser.add_argument( "--summary-out", @@ -1642,6 +1831,71 @@ def main(argv: list[str] | None = None) -> int: decision.action = "REFUSED" decision.reason = f"ecriture refusee : {write_error}" + # Volet (c) : REORIENTATION de la file de cohorte deja constituee. + # Le `runs-on` se resout a la creation du job : un run de cohorte cree + # avant le basculement reste sur l'offre hebergee. Le levier mesure est + # la RELANCE (elle relit `vars.*`, mesure du 28/10 17:48Z) -- donc + # annuler puis relancer, dans la limite des slots locaux libres. + # Rien n'est fait si la variable n'est pas a `true`, si la lecture des + # slots est tronquee, ou en mode dry-run. + effective = (decision.target if written else current) or "" + reorientation = Reorientation( + free_slots=local.free, min_age_min=args.reorient_min_age + ) + if not cohort_paths: + reorientation.reason = ( + "liste de la cohorte derivee du depot vide ou illisible " + f"({cohort_reason}) : aucune reorientation" + ) + elif effective != "true": + reorientation.reason = ( + f"variable {args.variable} a {effective or 'inconnue'!r} : le " + "levier de reorientation n'a de sens qu'a `true`" + ) + elif not (local.complete and not local.truncated): + reorientation.reason = ( + "lecture des slots locaux tronquee ou illisible " + f"({local.reason}) : aucune reorientation" + ) + elif local.free <= 0: + reorientation.reason = "aucun slot local libre : rien a reorienter" + else: + lines, complete, reason = collect_queued_cohort_runs( + args.repo, cohort_paths, token + ) + reorientation.measured = True + reorientation.candidates = len(lines) + if not complete: + reorientation.reason = reason + else: + targets = select_reorientation_targets( + lines, local.free, args.reorient_min_age, now + ) + reorientation.targets = targets + if not targets: + reorientation.reason = ( + "aucun run de cohorte en file depuis plus de " + f"{args.reorient_min_age:.0f} min" + ) + elif not args.apply: + reorientation.reason = ( + f"{len(targets)} cible(s) identifiee(s) -- mode dry-run : " + "aucune reorientation executee" + ) + else: + for target in targets: + ok, why = reorient_run(args.repo, token, target["id"]) + entry = {**target, "ok": ok, "reason": why} + if ok: + reorientation.reoriented.append(entry) + else: + reorientation.failed.append(entry) + reorientation.reason = ( + f"{len(reorientation.reoriented)} run(s) de cohorte " + f"reoriente(s) vers le parc local, " + f"{len(reorientation.failed)} en echec" + ) + report = { "verdict": decision.action, "reason": decision.reason, @@ -1722,6 +1976,18 @@ def main(argv: list[str] | None = None) -> int: "workflows": len(local_paths), "derivation": local_paths_reason, }, + # La REORIENTATION de la file de cohorte deja constitutee (volet (c)) : + # ce qui a ete vise, ce qui a ete reoriente, ce qui a echoue. + "reorientation": { + "measured": reorientation.measured, + "candidates": reorientation.candidates, + "targets": reorientation.targets, + "reoriented": reorientation.reoriented, + "failed": reorientation.failed, + "free_slots": reorientation.free_slots, + "min_age_min": reorientation.min_age_min, + "reason": reorientation.reason, + }, "thresholds": { "saturation_jobs": args.saturation_jobs, "saturation_minutes": args.saturation_minutes, @@ -1776,6 +2042,10 @@ def main(argv: list[str] | None = None) -> int: print(f" ecriture : REFUSEE -- {write_blocked}") if state_reason and not write_blocked: print(f" etat : {state_reason}") + print( + f" reorient.: {len(reorientation.reoriented)} run(s) reoriente(s), " + f"{len(reorientation.failed)} en echec -- {reorientation.reason}" + ) if decision.action == "UNREADABLE": rc = EXIT_UNREADABLE diff --git a/scripts/tests/test_ci_overflow_controller.py b/scripts/tests/test_ci_overflow_controller.py index 1fc1e0d8237..73580757744 100644 --- a/scripts/tests/test_ci_overflow_controller.py +++ b/scripts/tests/test_ci_overflow_controller.py @@ -1206,3 +1206,190 @@ def test_un_ecart_non_etabli_ne_bloque_pas_le_passage(monkeypatch, capsys): "--min-interval", "15"]) report = json.loads(capsys.readouterr().out) assert report["verdict"] != "SKIPPED" + + +# --- Volet (c) : reorientation de la file de cohorte deja constituee ------- + +COHORT_PATH = ".github/workflows/cohorte.yml" +STATIC_PATH = ".github/workflows/statique.yml" + + +def _queued_rows(count: int, minutes_old: float = 60.0, path: str = COHORT_PATH, + first_id: int = 1000): + stamp = (NOW - timedelta(minutes=minutes_old)).strftime("%Y-%m-%dT%H:%M:%SZ") + return [ + {"id": first_id + i, "path": path, "created_at": stamp} + for i in range(count) + ] + + +def test_trente_runs_de_cohorte_et_huit_slots_libres_huit_sont_reorientes( + monkeypatch, capsys +): + """La borne est le nombre de slots LIBRES mesure : 30 runs en file, + 8 slots libres -> 8 reorientes, pas plus.""" + _env(monkeypatch, write_token="pat-variables") + monkeypatch.setattr(ctl, "read_variable", lambda *a, **k: ("false", OLD, "")) + monkeypatch.setattr(ctl, "collect_hosted", + lambda *a, **k: hosted(queued=9, old=6, oldest=41.0)) + monkeypatch.setattr(ctl, "collect_local", + lambda *a, **k: local(free=8, online=22)) + monkeypatch.setattr(ctl, "derive_cohort_workflows", + lambda root: ({COHORT_PATH}, "")) + monkeypatch.setattr(ctl, "derive_local_capable_workflows", + lambda root: ({COHORT_PATH}, "")) + monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (True, "ok")) + monkeypatch.setattr(ctl, "collect_queued_cohort_runs", + lambda *a, **k: (_queued_rows(30), True, "")) + reoriented = [] + monkeypatch.setattr( + ctl, "reorient_run", + lambda repo, token, run_id: (reoriented.append(run_id), (True, "ok"))[1], + ) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + report = json.loads(capsys.readouterr().out) + assert rc == ctl.EXIT_OK + assert report["verdict"] == "SWITCH_TRUE" + assert report["reorientation"]["candidates"] == 30 + assert len(report["reorientation"]["reoriented"]) == 8 + assert len(reoriented) == 8 + assert report["reorientation"]["failed"] == [] + + +def test_un_run_statique_heberge_n_est_jamais_touche(monkeypatch, capsys): + """Un workflow qui ne lit pas la variable reste sur l'offre hebergee : + il n'entre pas dans la file de cohorte, donc il n'est pas reoriente.""" + _env(monkeypatch, write_token="pat-variables") + monkeypatch.setattr(ctl, "read_variable", lambda *a, **k: ("false", OLD, "")) + monkeypatch.setattr(ctl, "collect_hosted", + lambda *a, **k: hosted(queued=9, old=6, oldest=41.0)) + monkeypatch.setattr(ctl, "collect_local", + lambda *a, **k: local(free=8, online=22)) + monkeypatch.setattr(ctl, "derive_cohort_workflows", + lambda root: ({COHORT_PATH}, "")) + monkeypatch.setattr(ctl, "derive_local_capable_workflows", + lambda root: ({COHORT_PATH}, "")) + monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (True, "ok")) + rows = _queued_rows(30) + _queued_rows(5, path=STATIC_PATH, first_id=2000) + monkeypatch.setattr(ctl, "collect_queued_cohort_runs", + lambda *a, **k: (rows, True, "")) + reoriented = [] + monkeypatch.setattr( + ctl, "reorient_run", + lambda repo, token, run_id: (reoriented.append(run_id), (True, "ok"))[1], + ) + ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + static_ids = {row["id"] for row in _queued_rows(5, path=STATIC_PATH, first_id=2000)} + assert not (static_ids & set(reoriented)) + + +def test_une_lecture_des_slots_tronquee_ne_reoriente_rien(monkeypatch, capsys): + """Slots illisibles -> aucune reorientation : on ne peut pas borner par + une borne qu'on n'a pas mesuree.""" + _env(monkeypatch, write_token="pat-variables") + monkeypatch.setattr(ctl, "read_variable", lambda *a, **k: ("true", OLD, "")) + monkeypatch.setattr(ctl, "collect_hosted", + lambda *a, **k: hosted(queued=9, old=6, oldest=41.0)) + monkeypatch.setattr(ctl, "collect_local", + lambda *a, **k: local(free=8, online=22, truncated=True, + reason="budget de lecture atteint")) + monkeypatch.setattr(ctl, "derive_cohort_workflows", + lambda root: ({COHORT_PATH}, "")) + monkeypatch.setattr(ctl, "derive_local_capable_workflows", + lambda root: ({COHORT_PATH}, "")) + monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (True, "ok")) + monkeypatch.setattr(ctl, "collect_queued_cohort_runs", lambda *a, **k: ( + (_ for _ in ()).throw( + AssertionError("aucune lecture de file quand les slots sont illisibles")) + )) + monkeypatch.setattr(ctl, "reorient_run", lambda *a, **k: ( + (_ for _ in ()).throw(AssertionError("aucune reorientation sans slots"))) + ) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + report = json.loads(capsys.readouterr().out) + assert rc == ctl.EXIT_OK + assert report["reorientation"]["reoriented"] == [] + assert "tronquee" in report["reorientation"]["reason"] + + +def test_le_dry_run_annonce_les_cibles_sans_reorienter(monkeypatch, capsys): + _env(monkeypatch) + monkeypatch.setattr(ctl, "read_variable", lambda *a, **k: ("true", OLD, "")) + monkeypatch.setattr(ctl, "collect_hosted", + lambda *a, **k: hosted(queued=9, old=6, oldest=41.0)) + monkeypatch.setattr(ctl, "collect_local", + lambda *a, **k: local(free=8, online=22)) + monkeypatch.setattr(ctl, "derive_cohort_workflows", + lambda root: ({COHORT_PATH}, "")) + monkeypatch.setattr(ctl, "derive_local_capable_workflows", + lambda root: ({COHORT_PATH}, "")) + monkeypatch.setattr(ctl, "collect_queued_cohort_runs", + lambda *a, **k: (_queued_rows(30), True, "")) + monkeypatch.setattr(ctl, "reorient_run", lambda *a, **k: ( + (_ for _ in ()).throw(AssertionError("aucune reorientation en dry-run")) + )) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--json"]) + report = json.loads(capsys.readouterr().out) + assert rc == ctl.EXIT_OK + assert len(report["reorientation"]["targets"]) == 8 + assert report["reorientation"]["reoriented"] == [] + assert "dry-run" in report["reorientation"]["reason"] + + +def test_la_collecte_ne_garde_que_les_workflows_de_la_cohorte(monkeypatch): + """Le filtre est sur le `path` du run : un workflow statique heberge + n'entre pas dans la file de cohorte.""" + payload = { + "workflow_runs": [ + {"id": 1, "path": COHORT_PATH, "created_at": "2026-10-27T10:00:00Z"}, + {"id": 2, "path": STATIC_PATH, "created_at": "2026-10-27T10:00:00Z"}, + {"id": 3, "path": ".github/workflows/appelant.yml", + "created_at": "2026-10-27T10:00:00Z"}, + ] + } + monkeypatch.setattr( + ctl, "_gh_api", lambda *a, **k: (0, json.dumps(payload), "") + ) + lines, complete, reason = ctl.collect_queued_cohort_runs( + "jsboige/CoursIA", {COHORT_PATH}, "t" + ) + assert complete is True and reason == "" + assert [row["id"] for row in lines] == [1] + + +def test_une_file_illisible_bloque_la_reorientation(monkeypatch): + monkeypatch.setattr(ctl, "_gh_api", lambda *a, **k: (1, "", "boom")) + lines, complete, reason = ctl.collect_queued_cohort_runs( + "jsboige/CoursIA", {COHORT_PATH}, "t" + ) + assert complete is False + assert "illisible" in reason + + +def test_la_reorientation_annule_puis_relance(monkeypatch): + """L'ordre est ANNULATION puis RELANCE : un run encore en file n'accepte + pas la relance.""" + calls = [] + + def fake(args, token=None, method=None, payload=None): + calls.append((args[-1], method)) + return (0, "", "") + + monkeypatch.setattr(ctl, "_gh_api", fake) + ok, reason = ctl.reorient_run("jsboige/CoursIA", "t", 4242) + assert ok is True + assert len(calls) == 2 + assert calls[0][0].endswith("/actions/runs/4242/cancel") + assert calls[1][0].endswith("/actions/runs/4242/rerun") + assert [method for _path, method in calls] == ["POST", "POST"] + + +def test_les_cibles_sont_les_plus_anciennes_et_respectent_le_seuil_d_age(): + rows = _queued_rows(3, minutes_old=60.0) + _queued_rows(2, minutes_old=5.0) + targets = ctl.select_reorientation_targets(rows, free_slots=10, + min_age_minutes=15.0, now=NOW) + assert len(targets) == 3 + assert all(row["age_min"] >= 15.0 for row in targets) + assert [row["age_min"] for row in targets] == sorted( + [row["age_min"] for row in targets], reverse=True + ) From 94c44f5b435593455a21a27ea3076985c6b17751 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 28 Oct 2026 18:59:08 +0100 Subject: [PATCH 2/3] Fix(ci,#22937): les fantomes de la file de cohorte ne mangent pas les slots Mesure live du 28/10 (dry-run du controleur) : 51 runs de cohorte en file, dont 45 crees le 13/09 -- 45 JOURS. Ce sont des PR disparues, jamais servies : ils faussent le compte de file (constat du coordinateur) et consommeraient la borne des slots libres pour rien. Un run de plus de `--reorient-max-age` (6 h par defaut) est donc exclu et COMPTE comme fantome dans le rapport. Le filtre est mesure, pas suppose : l'age vient de `created_at`, comme le seuil minimal. Co-Authored-By: Claude Haiku 5.5 --- docs/coordination/ci-overflow-controller.md | 1 + scripts/ci/ci_overflow_controller.py | 64 ++++++++++++++++---- scripts/tests/test_ci_overflow_controller.py | 49 +++++++++------ 3 files changed, 85 insertions(+), 29 deletions(-) diff --git a/docs/coordination/ci-overflow-controller.md b/docs/coordination/ci-overflow-controller.md index 824593862c1..756a005ddcf 100644 --- a/docs/coordination/ci-overflow-controller.md +++ b/docs/coordination/ci-overflow-controller.md @@ -364,6 +364,7 @@ variable. L'organe applique donc ce geste, dans ses bornes : | slots | la lecture des slots doit etre **complete et non tronquee** -- on ne borne pas par une borne qu'on n'a pas mesuree | | borne | **au plus le nombre de slots locaux libres** : chaque run reoriente occupe un slot ; 155 runs d'un coup sur 22 slots, c'est le symptome que l'organe combat | | age | un run en file depuis moins de `--reorient-min-age` (15 min) n'est pas reoriente : il se reparait tout seul | +| fantome | un run de plus de `--reorient-max-age` (6 h) est **exclu** : sa PR a disparu. Mesure du 28/10 : sur 51 runs de cohorte en file, 45 avaient **45 jours** -- ils faussent le compte de file et consommeraient la borne des slots pour une PR qui n'existe plus | | perimetre | **seulement** les runs dont le workflow appartient a la cohorte (liste derivee du depot) : un workflow statique heberge n'entre pas dans la file de cohorte | | dry-run | `--apply` absent -> les cibles sont **annoncees**, aucune n'est executee | diff --git a/scripts/ci/ci_overflow_controller.py b/scripts/ci/ci_overflow_controller.py index 5f148f60c3b..6c3e4b6c132 100644 --- a/scripts/ci/ci_overflow_controller.py +++ b/scripts/ci/ci_overflow_controller.py @@ -227,6 +227,12 @@ # grandeur que le seuil d'age de saturation : un run plus jeune n'a pas # encore consommé d'attente. DEFAULT_REORIENT_MIN_AGE = 15.0 +# Age MAXIMAL d'un run reoriente. Au-dela, le run est un FANTOME : mesure +# du 28/10, 51 runs de cohorte en file, dont 45 crees le 13/09 (45 jours) -- +# des runs de PR disparues, jamais servis. Ils faussent le compte de file +# (coordinateur, 28/10) ET consommeraient la borne des slots libres pour +# rien : on ne reressuscite pas une PR qui n'existe plus. +DEFAULT_REORIENT_MAX_AGE = 360.0 @dataclass @@ -349,6 +355,8 @@ class Reorientation: failed: list = field(default_factory=list) free_slots: int = 0 min_age_min: float = 0.0 + max_age_min: float = 0.0 + ghosts: int = 0 reason: str = "" @@ -1332,8 +1340,9 @@ def select_reorientation_targets( free_slots: int, min_age_minutes: float, now: datetime, -) -> list[dict]: - """Cibles de la reorientation : les plus ANCIENS d'abord, bornées par les + max_age_minutes: float = DEFAULT_REORIENT_MAX_AGE, +) -> tuple[list[dict], int]: + """Cibles de la reorientation : les plus ANCIENS d'abord, bornees par les slots locaux libres. La borne est le nombre de slots LIBRES mesure : chaque run reoriente @@ -1341,13 +1350,23 @@ def select_reorientation_targets( declencherait 155 jobs locaux d'un coup sur un parc de 22 slots -- le parc local sature est exactement le symptome que le controleur combat. - Un run dont la date de creation est illisible est IGNORE (il ne peut - pas etre age) : reorienter un run d'age inconnu, c'est risquer de - reorienter un run qui vient de partir. + Deux filtres d'age, tous deux mesures : + + - **plus vieux que `min_age_minutes`** : le run a deja consomme de + l'attente, c'est lui qu'il faut reparer ; + - **plus vieux que `max_age_minutes`** : le run est un **fantome** (sa PR + a disparu ; mesure du 28/10 : 45 des 51 runs de cohorte en file + avaient 45 jours). Le reorienter consommerait un slot pour une PR + qui n'existe plus, et ferait passer un vrai travail pour un fantome. + + Rend `(cibles, fantomes_exclus)`. Un run dont la date de creation est + illisible est IGNORE (il ne peut pas etre age) : reorienter un run + d'age inconnu, c'est risquer de reorienter un run qui vient de partir. """ if free_slots <= 0: - return [] + return [], 0 aged: list[dict] = [] + ghosts = 0 for line in lines: try: stamp = datetime.fromisoformat( @@ -1358,9 +1377,12 @@ def select_reorientation_targets( age = (now - stamp).total_seconds() / 60.0 if age < min_age_minutes: continue + if age > max_age_minutes: + ghosts += 1 + continue aged.append({**line, "age_min": round(age, 1)}) aged.sort(key=lambda row: (-row["age_min"], str(row["id"]))) - return aged[:free_slots] + return aged[:free_slots], ghosts def reorient_run(repo: str, token: str | None, run_id) -> tuple[bool, str]: @@ -1477,8 +1499,10 @@ def render_summary(report: dict, rc: int) -> str: f"run(s) de cohorte reoriente(s) vers le parc local, " f"{len(reorientation.get('failed') or [])} en echec, " f"{reorientation.get('candidates')} candidat(s) en file, " - f"{reorientation.get('free_slots')} slot(s) libre(s) " - f"(age seuil {reorientation.get('min_age_min')} min)" + f"{reorientation.get('free_slots')} slot(s) libre(s), " + f"{reorientation.get('ghosts')} fantome(s) exclu(s) " + f"(age seuil {reorientation.get('min_age_min')} min, " + f"max {reorientation.get('max_age_min')} min)" ) if reorientation.get("reason"): lines.append(f" - motif : {reorientation.get('reason')}") @@ -1597,6 +1621,17 @@ def main(argv: list[str] | None = None) -> int: "reparait deja le run seul" ), ) + parser.add_argument( + "--reorient-max-age", + type=float, + default=DEFAULT_REORIENT_MAX_AGE, + metavar="MINUTES", + help=( + "age maximal d'un run de cohorte pour etre reoriente : au-dela, " + "le run est un FANTOME (sa PR a disparu) -- le reorienter " + "consommerait un slot local pour rien" + ), + ) parser.add_argument("--json", action="store_true") parser.add_argument( "--summary-out", @@ -1840,7 +1875,8 @@ def main(argv: list[str] | None = None) -> int: # slots est tronquee, ou en mode dry-run. effective = (decision.target if written else current) or "" reorientation = Reorientation( - free_slots=local.free, min_age_min=args.reorient_min_age + free_slots=local.free, min_age_min=args.reorient_min_age, + max_age_min=args.reorient_max_age, ) if not cohort_paths: reorientation.reason = ( @@ -1868,10 +1904,12 @@ def main(argv: list[str] | None = None) -> int: if not complete: reorientation.reason = reason else: - targets = select_reorientation_targets( - lines, local.free, args.reorient_min_age, now + targets, ghosts = select_reorientation_targets( + lines, local.free, args.reorient_min_age, now, + args.reorient_max_age, ) reorientation.targets = targets + reorientation.ghosts = ghosts if not targets: reorientation.reason = ( "aucun run de cohorte en file depuis plus de " @@ -1986,6 +2024,8 @@ def main(argv: list[str] | None = None) -> int: "failed": reorientation.failed, "free_slots": reorientation.free_slots, "min_age_min": reorientation.min_age_min, + "max_age_min": reorientation.max_age_min, + "ghosts": reorientation.ghosts, "reason": reorientation.reason, }, "thresholds": { diff --git a/scripts/tests/test_ci_overflow_controller.py b/scripts/tests/test_ci_overflow_controller.py index 73580757744..0f3fb339c9b 100644 --- a/scripts/tests/test_ci_overflow_controller.py +++ b/scripts/tests/test_ci_overflow_controller.py @@ -13,6 +13,7 @@ import ci_overflow_controller as ctl # noqa: E402 NOW = datetime(2026, 10, 27, 12, 0, tzinfo=timezone.utc) +NOW_ISO = "2026-10-27T12:00:00+00:00" # Derniere ecriture de la variable, hors fenetre de dwell : le controleur # n'est pas bloque par son propre dwell dans ces tests de CLI. OLD = NOW - timedelta(hours=2) @@ -167,7 +168,7 @@ def test_cli_dry_run_ne_bascule_pas(monkeypatch, capsys): written = [] monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (written.append(a), (True, "ok"))[1]) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--json", "--now", NOW_ISO]) assert rc == ctl.EXIT_OK assert written == [] report = json.loads(capsys.readouterr().out) @@ -190,7 +191,7 @@ def fake_write(repo, name, value, token): return True, "variable mise a jour" monkeypatch.setattr(ctl, "write_variable", fake_write) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) assert rc == ctl.EXIT_OK assert seen["value"] == "true" # L'ecriture passe par le jeton DEDIE, pas par le jeton de mesure. @@ -210,7 +211,7 @@ def test_cli_ecriture_refusee_rend_refused(monkeypatch, capsys): lambda *a, **k: local(free=7, online=14)) monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (False, "HTTP 403: Resource not accessible")) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) assert rc == ctl.EXIT_REFUSED report = json.loads(capsys.readouterr().out) assert report["verdict"] == "REFUSED" @@ -230,7 +231,7 @@ def test_cli_lecture_impossible_rend_le_verdict_sans_ecrire(monkeypatch, capsys) lambda *a, **k: local(free=7, online=14)) monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (_ for _ in ()).throw( AssertionError("aucune ecriture si l'etat de la variable est inconnu"))) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) report = json.loads(capsys.readouterr().out) assert report["verdict"] == "SWITCH_TRUE" assert report["written"] is False @@ -254,7 +255,7 @@ def test_etat_inconnu_bloque_l_ecriture_meme_avec_le_secret_present(monkeypatch, lambda *a, **k: local(free=7, online=14)) monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (_ for _ in ()).throw( AssertionError("aucune ecriture si l'etat de la variable est inconnu"))) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) report = json.loads(capsys.readouterr().out) assert report["verdict"] == "SWITCH_TRUE" assert report["written"] is False @@ -271,7 +272,7 @@ def test_cli_mesure_illisible_sort_en_1(monkeypatch, capsys): lambda *a, **k: hosted(complete=False, reason="listing en echec")) monkeypatch.setattr(ctl, "collect_local", lambda *a, **k: local(free=7, online=14)) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--json", "--now", NOW_ISO]) assert rc == ctl.EXIT_UNREADABLE assert json.loads(capsys.readouterr().out)["verdict"] == "UNREADABLE" @@ -348,7 +349,7 @@ def test_cli_sans_jeton_d_inventaire_sort_unreadable_nomme(monkeypatch, capsys): ctl.crs, "fetch_inventory", lambda repo, label, token: _inventory_unavailable(), ) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--json", "--now", NOW_ISO]) assert rc == ctl.EXIT_UNREADABLE report = json.loads(capsys.readouterr().out) assert report["verdict"] == "UNREADABLE" @@ -377,7 +378,7 @@ def fake_write(repo, name, value, token): return True, "variable mise a jour" monkeypatch.setattr(ctl, "write_variable", fake_write) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) assert rc == ctl.EXIT_OK assert seen["value"] == "true" assert seen["token"] == "pat-variables" @@ -396,7 +397,7 @@ def test_secret_absent_verdict_seul_et_aucune_ecriture(monkeypatch, capsys): lambda *a, **k: local(free=7, online=14)) monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (_ for _ in ()).throw( AssertionError("aucune ecriture sans jeton d'ecriture"))) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) assert rc == ctl.EXIT_OK report = json.loads(capsys.readouterr().out) # Le VERDICT est rendu... @@ -420,7 +421,7 @@ def test_secret_absent_et_vide_est_traite_comme_absent(monkeypatch, capsys): lambda *a, **k: local(free=7, online=14)) monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (_ for _ in ()).throw( AssertionError("aucune ecriture avec un secret vide"))) - ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) report = json.loads(capsys.readouterr().out) assert report["written"] is False assert report["write_token_present"] is False @@ -963,7 +964,7 @@ def test_cli_liste_de_cohorte_illisible_rend_unreadable(monkeypatch, capsys): lambda root: (set(), "repertoire des workflows absent")) monkeypatch.setattr(ctl, "write_variable", lambda *a, **k: (_ for _ in ()).throw( AssertionError("aucune ecriture sur une cohorte non etablie"))) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) assert rc == ctl.EXIT_UNREADABLE report = json.loads(capsys.readouterr().out) assert report["verdict"] == "UNREADABLE" @@ -1246,7 +1247,7 @@ def test_trente_runs_de_cohorte_et_huit_slots_libres_huit_sont_reorientes( ctl, "reorient_run", lambda repo, token, run_id: (reoriented.append(run_id), (True, "ok"))[1], ) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) report = json.loads(capsys.readouterr().out) assert rc == ctl.EXIT_OK assert report["verdict"] == "SWITCH_TRUE" @@ -1278,7 +1279,7 @@ def test_un_run_statique_heberge_n_est_jamais_touche(monkeypatch, capsys): ctl, "reorient_run", lambda repo, token, run_id: (reoriented.append(run_id), (True, "ok"))[1], ) - ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) static_ids = {row["id"] for row in _queued_rows(5, path=STATIC_PATH, first_id=2000)} assert not (static_ids & set(reoriented)) @@ -1305,7 +1306,7 @@ def test_une_lecture_des_slots_tronquee_ne_reoriente_rien(monkeypatch, capsys): monkeypatch.setattr(ctl, "reorient_run", lambda *a, **k: ( (_ for _ in ()).throw(AssertionError("aucune reorientation sans slots"))) ) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--apply", "--json", "--now", NOW_ISO]) report = json.loads(capsys.readouterr().out) assert rc == ctl.EXIT_OK assert report["reorientation"]["reoriented"] == [] @@ -1328,7 +1329,7 @@ def test_le_dry_run_annonce_les_cibles_sans_reorienter(monkeypatch, capsys): monkeypatch.setattr(ctl, "reorient_run", lambda *a, **k: ( (_ for _ in ()).throw(AssertionError("aucune reorientation en dry-run")) )) - rc = ctl.main(["--repo", "jsboige/CoursIA", "--json"]) + rc = ctl.main(["--repo", "jsboige/CoursIA", "--json", "--now", NOW_ISO]) report = json.loads(capsys.readouterr().out) assert rc == ctl.EXIT_OK assert len(report["reorientation"]["targets"]) == 8 @@ -1386,10 +1387,24 @@ def fake(args, token=None, method=None, payload=None): def test_les_cibles_sont_les_plus_anciennes_et_respectent_le_seuil_d_age(): rows = _queued_rows(3, minutes_old=60.0) + _queued_rows(2, minutes_old=5.0) - targets = ctl.select_reorientation_targets(rows, free_slots=10, - min_age_minutes=15.0, now=NOW) + targets, ghosts = ctl.select_reorientation_targets( + rows, free_slots=10, min_age_minutes=15.0, now=NOW + ) assert len(targets) == 3 + assert ghosts == 0 assert all(row["age_min"] >= 15.0 for row in targets) assert [row["age_min"] for row in targets] == sorted( [row["age_min"] for row in targets], reverse=True ) + + +def test_les_fantomes_sont_exclus_et_ne_consomment_pas_la_borne(): + """Un run de 45 jours (PR disparue) ne doit pas manger un slot local : + il est compte comme fantome, pas reoriente.""" + rows = _queued_rows(2, minutes_old=60.0) + _queued_rows(45, minutes_old=45 * 24 * 60) + targets, ghosts = ctl.select_reorientation_targets( + rows, free_slots=2, min_age_minutes=15.0, now=NOW + ) + assert ghosts == 45 + assert len(targets) == 2 + assert all(row["age_min"] <= 360.0 for row in targets) From 3d0c1fcfab3fa982d75664b7a20a2ed4b67b62f8 Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 29 Oct 2026 07:54:38 +0100 Subject: [PATCH 3/3] Fix(ci,#23155): la relance d'un run annule attend l'etat effectif et retente une fois Suite au premier point de la revue Hermes (fenetre entre l'annulation et la relance) : - l'annulation n'est pas instantanee -- un run passe par l'etat `cancelling` avant `completed` -- et une relance demandee pendant la transition est refusee : le run restait alors annule SANS relance, perdu pour la passe (le motif etait journalise, le run ne l'etait pas) ; - la relance est d'abord demandee telle quelle (voie rapide, aucun appel supplementaire) ; si elle est refusee, l'organe attend l'etat effectif de l'annulation (borne 30 s, lecture toutes les 5 s) et retente UNE fois ; - si l'annulation n'aboutit pas dans la borne, aucune relance n'est demandee : le motif est nomme et le run reste pour la passe suivante. Le cout est nul sur la voie normale : la lecture d'etat n'a lieu que dans le cas de la relance refusee. Deux tests : la relance refusee puis reussie apres attente, et l'annulation non effective qui ne declenche aucune relance inutile. Co-Authored-By: Claude Haiku 5.5 --- scripts/ci/ci_overflow_controller.py | 70 +++++++++++++++++++- scripts/tests/test_ci_overflow_controller.py | 55 +++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/scripts/ci/ci_overflow_controller.py b/scripts/ci/ci_overflow_controller.py index 6c3e4b6c132..4c4b54c157b 100644 --- a/scripts/ci/ci_overflow_controller.py +++ b/scripts/ci/ci_overflow_controller.py @@ -199,6 +199,13 @@ # inerte, un decor et non une protection. Regle appliquee : le dwell depasse # la cadence (90 > 60). DEFAULT_DWELL_MINUTES = 90.0 +# Annulation -> relance : l'annulation n'est pas instantanee. Un run passe +# par l'etat `cancelling` avant `completed`, et une relance demandee pendant +# la transition est refusee -- le run resterait alors annule SANS relance, +# perdu pour la passe (reserve Hermes du 28/10). Bornes : 30 s d'attente de +# l'etat, un seul essai supplementaire de relance. +CANCEL_WAIT_TIMEOUT_S = 30.0 +CANCEL_POLL_S = 5.0 # Plafond de concurrence de l'offre hebergee, en jobs. Mesure du 2026-10-16 : # 19 jobs heberges en concurrence au pic observe ; on retient 20 (arrondi # superieur de la mesure). La MARGE hebergee est ce plafond moins les jobs @@ -1385,6 +1392,48 @@ def select_reorientation_targets( return aged[:free_slots], ghosts +def _wait_for_completed( + repo: str, + token: str | None, + run_id, + timeout_s: float | None = None, +) -> tuple[bool, str]: + """Attend la fin EFFECTIVE de l'annulation (etat `completed`). + + L'etat se lit sur le run lui-meme. Un etat non atteint dans la borne + rend l'echec : la relance serait refusee, autant le dire plutot que de + la demander en vain. La borne est lue AU MOMENT de l'appel (pas en + argument par defaut) : les tests peuvent la raccourcir. + """ + timeout_s = CANCEL_WAIT_TIMEOUT_S if timeout_s is None else timeout_s + deadline = time.monotonic() + timeout_s + while True: + rc, out, err = _gh_api( + [f"repos/{repo}/actions/runs/{run_id}"], + token=token, + ) + if rc != 0: + message = (err or out or "").strip().splitlines() + return False, ( + "etat du run illisible (" + + (message[-1] if message else "raison inconnue") + + ")" + ) + try: + data = json.loads(out) + except json.JSONDecodeError: + return False, "etat du run illisible (reponse non-JSON)" + status = data.get("status") if isinstance(data, dict) else None + if status == "completed": + return True, "" + if time.monotonic() >= deadline: + return False, ( + f"annulation non effective apres {timeout_s:.0f} s " + f"(etat {status})" + ) + time.sleep(CANCEL_POLL_S) + + def reorient_run(repo: str, token: str | None, run_id) -> tuple[bool, str]: """ANNULE puis RELANCE un run : le job recree relit `vars.*`. @@ -1409,13 +1458,30 @@ def reorient_run(repo: str, token: str | None, run_id) -> tuple[bool, str]: token=token, method="POST", ) + if rc == 0: + return True, "run annule puis relance" + # La transition d'etat peut encore etre en cours cote serveur : on + # attend l'etat effectif de l'annulation, puis on retente UNE fois. + # Sans cette attente, un run annule resterait SANS relance -- perdu + # pour la passe (reserve Hermes du 28/10). + waited, why = _wait_for_completed(repo, token, run_id) + if not waited: + return False, "relance refusee : annulation non effective (" + why + ")" + rc, out, err = _gh_api( + [f"repos/{repo}/actions/runs/{run_id}/rerun"], + token=token, + method="POST", + ) if rc != 0: message = (err or out or "").strip().splitlines() return False, ( - "relance refusee : " + "relance refusee apres attente de l'annulation : " + (message[-1] if message else "raison inconnue") ) - return True, "run annule puis relance" + return True, ( + "run annule puis relance (relance refusee au premier essai, " + "reussie apres attente de l'annulation)" + ) def render_summary(report: dict, rc: int) -> str: diff --git a/scripts/tests/test_ci_overflow_controller.py b/scripts/tests/test_ci_overflow_controller.py index 0f3fb339c9b..b7642407ca1 100644 --- a/scripts/tests/test_ci_overflow_controller.py +++ b/scripts/tests/test_ci_overflow_controller.py @@ -1385,6 +1385,61 @@ def fake(args, token=None, method=None, payload=None): assert [method for _path, method in calls] == ["POST", "POST"] +def test_la_relance_refusee_est_reessayee_apres_attente_de_l_annulation(monkeypatch): + """La transition d'etat peut refuser une relance lancee trop tot : le + run resterait annule SANS relance, perdu pour la passe. On attend l'etat + effectif, puis on retente une fois.""" + calls = [] + + def fake(args, token=None, method=None, payload=None): + path = args[-1] + calls.append((path, method)) + if path.endswith("/cancel"): + return (0, "", "") + if path.endswith("/rerun"): + # premier essai refuse, second accepte + if sum(1 for p, _m in calls if p.endswith("/rerun")) == 1: + return (1, "", "run is still in progress") + return (0, "", "") + if path.endswith("/actions/runs/4242"): + return (0, json.dumps({"status": "completed"}), "") + raise AssertionError(f"appel inattendu : {path}") + + monkeypatch.setattr(ctl, "_gh_api", fake) + ok, reason = ctl.reorient_run("jsboige/CoursIA", "t", 4242) + assert ok is True + assert "reussie apres attente" in reason + paths = [p for p, _m in calls] + assert paths[0].endswith("/actions/runs/4242/cancel") + assert paths[1].endswith("/actions/runs/4242/rerun") + assert paths[2].endswith("/actions/runs/4242") + assert paths[3].endswith("/actions/runs/4242/rerun") + + +def test_une_annulation_non_effective_ne_declenche_pas_de_relance_inutile(monkeypatch): + """Si l'annulation n'aboutit pas dans la borne, on ne relance pas : le + motif est nomme, et le run reste pour la passe suivante.""" + monkeypatch.setattr(ctl, "CANCEL_WAIT_TIMEOUT_S", 0.01) + monkeypatch.setattr(ctl, "CANCEL_POLL_S", 0.01) + calls = [] + + def fake(args, token=None, method=None, payload=None): + path = args[-1] + calls.append((path, method)) + if path.endswith("/cancel"): + return (0, "", "") + if path.endswith("/rerun"): + return (1, "", "run is still in progress") + return (0, json.dumps({"status": "cancelling"}), "") + + monkeypatch.setattr(ctl, "_gh_api", fake) + ok, reason = ctl.reorient_run("jsboige/CoursIA", "t", 4242) + assert ok is False + assert "annulation non effective" in reason + # une seule relance demandee : celle qui a ete refusee + assert sum(1 for p, _m in calls if p.endswith("/rerun")) == 1 + + def test_les_cibles_sont_les_plus_anciennes_et_respectent_le_seuil_d_age(): rows = _queued_rows(3, minutes_old=60.0) + _queued_rows(2, minutes_old=5.0) targets, ghosts = ctl.select_reorientation_targets(