diff --git a/.github/workflows/coursia-waiter-starvation-advisory.yml b/.github/workflows/coursia-waiter-starvation-advisory.yml index cea83240061..5b71d72d9c7 100644 --- a/.github/workflows/coursia-waiter-starvation-advisory.yml +++ b/.github/workflows/coursia-waiter-starvation-advisory.yml @@ -40,25 +40,59 @@ name: Waiter runner starvation advisory # porte "advisory" (ADVISORY_MARKER, cf pr-gate-sweep-health-advisory.yml). # # #15332 : le scheduler GitHub a cesse de livrer l'evenement schedule le -# 2026-09-09 (4 h sans AUCUN declenchement planifie, depot entier) -- un -# organe porte par schedule seul meurt de la panne qu'il observe. Le -# heartbeat workflow_run s'ancre sur DEUX workflows, par jambe : -# - "Secret Scan" : chaque push et pull_request, jobs sur le parc -# self-hosted (coursia-ephemeral) -- jambe vivante quand le scheduler -# se tait et que le parc vit. -# - "Notebook outputs required (H.4 schema)" : push/PR touchant un -# notebook et jobs ubuntu-latest -- jambe GitHub-hosted de bout en bout : -# ce job tourne sur ubuntu-latest, donc le heartbeat survit AUSSI au parc -# eteint tant que le flux notebook pousse. +# 2026-09-09 (4 h sans AUCUN declenchement planifie, depot entier, 159 +# workflows) -- un organe porte par schedule seul meurt de la panne qu'il +# observe. Le heartbeat workflow_run s'ancre sur « Secret Scan », qui se +# declenche sur CHAQUE push et CHAQUE pull_request du depot, par jambe : +# - jambe « pull_request » : elle se declenche sur l'activite PR, qui +# continue quand le scheduler se tait -- mesure de la panne du +# 2026-09-09 : 47 fins push/main ET ~53 fins pull_request de l'ancre +# entre 00:00Z et 06:00Z, dont la fenetre de panne (01:13Z -> 05:22Z). +# C'est cette jambe qui compte le plus : un pool eteint bloque les jobs +# de PR, donc les merges s'arretent -- une jambe « push » seule ne +# declencherait plus rien, et l'observateur mourrait de la panne qu'il +# mesure ; +# - jambe « push » : sur un push vers main, les jobs de Secret Scan +# tournent sur ubuntu-latest (mesure : 20/20 jobs lus) -- elle survit +# donc a un parc self-hosted eteint. +# L'ancre « Notebook outputs required (H.4 schema) » est RETIREE de la liste : +# ses jobs sont sur le parc self-hosted depuis #21112 (« carrier routing », +# forme hybride Linux) -- mesure : 20/20 jobs de ses runs push/main sur +# [self-hosted, coursia-ephemeral, coursia-linux]. La « jambe GitHub-hosted +# de bout en bout » que ce header annoncait n'existe donc plus (le routage +# l'a annulee sans que le header soit corrige) : le heartbeat ne survit +# aujourd'hui a la panne du SCHEDULER, pas a celle du parc. Et l'ensemble +# de declenchements de cette ancre est un SOUS-ENSEMBLE de celui de +# « Secret Scan » -- elle ne se declenche que sur les evenements qui +# touchent un *.ipynb, que Secret Scan voit aussi -- la garder ne doublait +# que le volume. # La cadence SERVIE est mesuree par le script lui-meme (runs event=schedule # uniquement -- les runs workflow_run ne faussent pas la mesure). +# +# PORTE DE MESURE (#22937) : le heartbeat se declenche sur chaque fin +# d'ancre -- mesure sur 24 h (fenetre `created=` cote serveur, paginee : +# le listing tronque a 100 runs, et une fenetre non paginee donne un +# FAUX 4,2/h la ou l'incident en montre 48/h) : 418 fins « Secret Scan » +# (309 pull_request + 109 push), soit 17,4 runs/h par advisory, 35 pour la +# paire. Chaque +# passage complet paie ~9 appels API (compteur `gh_calls` du script ; +# l'inventaire des runners en ajoute 1, volontairement non compte) sur le +# jeton de depot (budget 1000 req/h) : la paire consommait ~480 appels/h, +# et le 2026-10-28 22:04Z le jeton a ete sature (« API rate limit +# exceeded for installation ») -> 18 rouges sur main en 10 min. La porte +# (MEASURE_EVERY_N_RUNS) borne la MESURE : un passage complet sur 4, les +# autres ne paient que la lecture de la cadence servie (1 appel). Le RUN +# existe toujours et son rapport de cadence servie (#15332) est emis a +# chaque passage -- c'est la SURVIVANCE de l'observateur qui est la +# garantie, pas la frequence de la lecture complete. La jambe `schedule` +# n'est jamais gatee : elle est la cadence de reference de l'advisory. on: schedule: - cron: '11,41 * * * *' workflow_dispatch: workflow_run: - workflows: ["Secret Scan", "Notebook outputs required (H.4 schema)"] + workflows: ["Secret Scan"] types: [completed] permissions: @@ -98,4 +132,7 @@ jobs: SELF_WORKFLOW: coursia-waiter-starvation-advisory.yml DECLARED_CADENCE_MINUTES: "30" CADENCE_WARN_MINUTES: "360" + # #22937 : borne la MESURE complete -- le run existe toujours et + # son rapport de cadence servie (#15332) est emis a chaque passage. + MEASURE_EVERY_N_RUNS: "4" run: python3 scripts/ci/check_runner_starvation.py diff --git a/.github/workflows/linux-runner-starvation-advisory.yml b/.github/workflows/linux-runner-starvation-advisory.yml index 53f9999b3cf..3bf44998584 100644 --- a/.github/workflows/linux-runner-starvation-advisory.yml +++ b/.github/workflows/linux-runner-starvation-advisory.yml @@ -26,27 +26,57 @@ name: Linux runner starvation advisory # #15332 : le scheduler GitHub a cesse de livrer l'evenement schedule le # 2026-09-09 (4 h sans AUCUN declenchement planifie, depot entier, 159 # workflows) -- un organe porte par schedule seul meurt de la panne qu'il -# observe. Le heartbeat workflow_run s'ancre sur DEUX workflows, par jambe : -# - "Secret Scan" : chaque push et pull_request, mais ses jobs tournent -# sur le parc self-hosted (coursia-ephemeral) -- jambe vivante quand le -# scheduler se tait et que le parc vit. -# - "Notebook outputs required (H.4 schema)" : push/PR touchant un -# notebook (le flux principal du depot) et jobs ubuntu-latest -- jambe -# GitHub-hosted de bout en bout : ce job aussi tourne sur ubuntu-latest, -# donc le heartbeat survit AUSSI au parc eteint tant que le flux -# notebook pousse (correction de la revue ai-01 du 2026-09-16 : Secret -# Scan seul reste en file indeterminee quand le parc est mort -- run -# jamais completed, workflow_run jamais emis ; un ancrage "heberge -# GitHub" doit l'etre de fait, pas de claim). +# observe. Le heartbeat workflow_run s'ancre sur « Secret Scan », qui se +# declenche sur CHAQUE push et CHAQUE pull_request du depot, par jambe : +# - jambe « pull_request » : elle se declenche sur l'activite PR, qui +# continue quand le scheduler se tait -- mesure de la panne du +# 2026-09-09 : 47 fins push/main ET ~53 fins pull_request de l'ancre +# entre 00:00Z et 06:00Z, dont la fenetre de panne (01:13Z -> 05:22Z). +# C'est cette jambe qui compte le plus : un pool eteint bloque les jobs +# de PR, donc les merges s'arretent -- une jambe « push » seule ne +# declencherait plus rien, et l'observateur mourrait de la panne qu'il +# mesure ; +# - jambe « push » : sur un push vers main, les jobs de Secret Scan +# tournent sur ubuntu-latest (mesure : 20/20 jobs lus) -- elle survit +# donc a un parc self-hosted eteint. +# L'ancre « Notebook outputs required (H.4 schema) » est RETIREE de la liste : +# ses jobs sont sur le parc self-hosted depuis #21112 (« carrier routing », +# forme hybride Linux) -- mesure : 20/20 jobs de ses runs push/main sur +# [self-hosted, coursia-ephemeral, coursia-linux]. La « jambe GitHub-hosted +# de bout en bout » que ce header annoncait n'existe donc plus (le routage +# l'a annulee sans que le header soit corrige) : le heartbeat ne survit +# aujourd'hui a la panne du SCHEDULER, pas a celle du parc. Et l'ensemble +# de declenchements de cette ancre est un SOUS-ENSEMBLE de celui de +# « Secret Scan » -- elle ne se declenche que sur les evenements qui +# touchent un *.ipynb, que Secret Scan voit aussi -- la garder ne doublait +# que le volume. # La cadence SERVIE est mesuree par le script lui-meme (runs event=schedule # uniquement -- les runs workflow_run ne faussent pas la mesure). +# +# PORTE DE MESURE (#22937) : le heartbeat se declenche sur chaque fin +# d'ancre -- mesure sur 24 h (fenetre `created=` cote serveur, paginee : +# le listing tronque a 100 runs, et une fenetre non paginee donne un +# FAUX 4,2/h la ou l'incident en montre 48/h) : 418 fins « Secret Scan » +# (309 pull_request + 109 push), soit 17,4 runs/h par advisory, 35 pour la +# paire. Chaque +# passage complet paie ~9 appels API (compteur `gh_calls` du script ; +# l'inventaire des runners en ajoute 1, volontairement non compte) sur le +# jeton de depot (budget 1000 req/h) : la paire consommait ~480 appels/h, +# et le 2026-10-28 22:04Z le jeton a ete sature (« API rate limit +# exceeded for installation ») -> 18 rouges sur main en 10 min. La porte +# (MEASURE_EVERY_N_RUNS) borne la MESURE : un passage complet sur 4, les +# autres ne paient que la lecture de la cadence servie (1 appel). Le RUN +# existe toujours et son rapport de cadence servie (#15332) est emis a +# chaque passage -- c'est la SURVIVANCE de l'observateur qui est la +# garantie, pas la frequence de la lecture complete. La jambe `schedule` +# n'est jamais gatee : elle est la cadence de reference de l'advisory. on: schedule: - cron: '19,49 * * * *' workflow_dispatch: workflow_run: - workflows: ["Secret Scan", "Notebook outputs required (H.4 schema)"] + workflows: ["Secret Scan"] types: [completed] permissions: @@ -121,4 +151,7 @@ jobs: SELF_WORKFLOW: linux-runner-starvation-advisory.yml DECLARED_CADENCE_MINUTES: "30" CADENCE_WARN_MINUTES: "360" + # #22937 : borne la MESURE complete -- le run existe toujours et + # son rapport de cadence servie (#15332) est emis a chaque passage. + MEASURE_EVERY_N_RUNS: "4" run: python3 scripts/ci/check_runner_starvation.py diff --git a/scripts/ci/check_runner_starvation.py b/scripts/ci/check_runner_starvation.py index 42454a7987a..95e65e04d8f 100644 --- a/scripts/ci/check_runner_starvation.py +++ b/scripts/ci/check_runner_starvation.py @@ -576,17 +576,22 @@ def absorb(run: dict, status: str, created: datetime) -> None: return result -def fetch_scheduled_runs(repo: str, self_workflow: str, limit: int = 3) -> list[datetime]: +def fetch_scheduled_runs(repo: str, self_workflow: str, limit: int = 3, + stats: CallStats | None = None) -> list[datetime]: """Dates de creation (UTC, ordre descroissant) des derniers runs ``event=schedule`` du workflow lui-meme. #15332 : la cadence SERVIE se mesure sur les seuls runs planifies -- les runs workflow_run/workflow_dispatch (heartbeats) ne prouvent rien du scheduler et ne doivent pas fausser la mesure. + + `stats` est optionnel : fourni, l'appel de ce listing entre dans le + compteur `gh_calls` du passage (honnetete du cout annonce -- #22937). """ data = _gh_json( [f"repos/{repo}/actions/workflows/{self_workflow}/runs" - f"?event=schedule&per_page={limit}"] + f"?event=schedule&per_page={limit}"], + stats=stats, ) if not isinstance(data, dict): return [] @@ -598,6 +603,72 @@ def fetch_scheduled_runs(repo: str, self_workflow: str, limit: int = 3) -> list[ return out +def _env_int(name: str) -> int | None: + """Entier d'environnement, ou None si absent/illisible. + + `GITHUB_RUN_NUMBER` est fourni par le runner Actions ; hors Actions il + est absent, et la porte de mesure doit alors laisser passer (une + lecture qui ne sait pas ou elle est ne doit pas s'arreter). + """ + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return None + try: + return int(raw.strip()) + except ValueError: + return None + + +def gate_measurement( + *, + every_n: int, + run_number: int | None, + event_name: str, +) -> tuple[bool, str]: + """Decide si CE passage paie la mesure complete (#22937). + + Le heartbeat `workflow_run` d'une advisory se declenche sur CHAQUE fin + d'ancre. Mesure sur 24 h (fenetre `created=` cote serveur, sans + troncature de pagination) : 418 fins « Secret Scan » (309 pull_request + + 109 push) et 300 fins « Notebook outputs required » (238 + 62), soit + 718 fins d'ancre -- 29,92/h, 60,62 runs/h pour la paire d'advisories, ~8 + appels API par passage (compteur du script), donc ~479 appels/h sur le + jeton de depot (budget 1000 req/h). Le 2026-10-28 22:04Z : « API rate + limit exceeded for installation », puis 18 rouges sur `main` en 10 min. + + La porte borne la MESURE, pas le RUN : le run existe toujours, son + rapport de cadence servie -- le devoir #15332 -- est emis a chaque + passage, et c'est cette SURVIE de l'observateur qui est la garantie. Un passage sur + `every_n` paie la lecture complete (inventaire + jobs des runs) ; les + autres ne paient que la lecture de la cadence servie (1 appel). + + La jambe `schedule` n'est PAS concernee : elle est la cadence de + REFERENCE de l'advisory, elle mesure toujours. Le gate ne s'applique + qu'aux runs `workflow_run` -- ce qui laisse intact l'advisory + `runner-starvation-advisory.yml` (coursia-lean), qui n'a pas de + heartbeat workflow_run et n'est donc jamais gate. + + `every_n <= 1` desactive la porte : le defaut est volontairement + inerte, pour qu'un appelant tiers ne change pas de comportement sans + l'avoir demande. + """ + if every_n <= 1: + return True, "porte inactive (every_n<=1) : mesure complete" + if event_name != "workflow_run": + return True, ( + f"jambe {event_name or 'inconnue'} : mesure complete " + "(cadence de reference, jamais gatee)" + ) + if run_number is None: + return True, "numero de run inconnu : mesure complete" + if run_number % every_n == 0: + return True, f"run {run_number} : 1 sur {every_n} -> mesure complete" + return False, ( + f"run {run_number} : mesure CADENCE SEULE (1 sur {every_n}) -- la " + "lecture complete (inventaire + jobs des runs) n'est pas payee" + ) + + def evaluate_cadence( scheduled: list[datetime], now: datetime, @@ -884,22 +955,24 @@ def main() -> int: default=int(os.environ.get("IN_PROGRESS_SAMPLE", DEFAULT_IN_PROGRESS_SAMPLE)), help="nombre de runs in_progress inspectes (preuve qu'un " "slot sert) -- les plus recents d'abord") + ap.add_argument("--measure-every-n-runs", type=int, + default=int(os.environ.get("MEASURE_EVERY_N_RUNS", "0")), + help="borne la mesure complete (#22937) : sur les runs " + "workflow_run, une mesure complete sur N passages ; " + "les autres ne paient que la lecture de la cadence " + "servie (1 appel). 0 ou 1 = porte inactive") ap.add_argument("--json", action="store_true", help="sortie JSON du verdict") args = ap.parse_args() - token = os.environ.get("RUNNERS_READ_PAT") or None - inv = fetch_inventory(args.repo, args.label, token) - st = fetch_starvation( - args.repo, args.label, args.starve_minutes, - window_max_minutes=args.window_max_minutes, - workflow=args.workflow or None, - job_read_budget=args.job_read_budget, - in_progress_sample=args.in_progress_sample, - ) - v = evaluate(inv, st, args.warn_floor, label=args.label) + # La cadence servie est lue AVANT la porte de mesure : c'est elle qui + # porte le rapport #15332, et elle ne doit jamais dependre du passage + # que la porte economise. Le compteur `calls` est partage pour que le + # `gh_calls` annonce soit le cout REEL du passage (#22937). + calls = CallStats() + v = Verdict() if args.self_workflow: cad = evaluate_cadence( - fetch_scheduled_runs(args.repo, args.self_workflow), + fetch_scheduled_runs(args.repo, args.self_workflow, stats=calls), datetime.now(timezone.utc), args.declared_cadence_minutes, args.cadence_warn_minutes, @@ -907,28 +980,64 @@ def main() -> int: v.notes.extend(cad.notes) v.warnings.extend(cad.warnings) v.errors.extend(cad.errors) - v.status = "ERROR" if v.errors else "OK" + + run_number = _env_int("GITHUB_RUN_NUMBER") + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + measure, gate_reason = gate_measurement( + every_n=args.measure_every_n_runs, + run_number=run_number, + event_name=event_name, + ) + v.notes.append(f"porte de mesure: {gate_reason}") + + inv: RunnerInventory | None = None + st: Starvation | None = None + if measure: + token = os.environ.get("RUNNERS_READ_PAT") or None + inv = fetch_inventory(args.repo, args.label, token) + st = fetch_starvation( + args.repo, args.label, args.starve_minutes, + window_max_minutes=args.window_max_minutes, + workflow=args.workflow or None, + job_read_budget=args.job_read_budget, + in_progress_sample=args.in_progress_sample, + ) + full = evaluate(inv, st, args.warn_floor, label=args.label) + v.notes = full.notes + v.notes + v.warnings = full.warnings + v.warnings + v.errors = full.errors + v.errors + v.status = "ERROR" if v.errors else "OK" payload = { "status": v.status, "errors": v.errors, "warnings": v.warnings, "notes": v.notes, - "online_runners": inv.online if inv.available else None, - "offline_runners": inv.offline if inv.available else None, - "starved_jobs": [r.__dict__ for r in st.starved], - "in_progress_jobs": len(st.in_progress), - "unexamined_runs": st.unexamined, - "gh_calls": st.stats.calls, + "measurement": "full" if measure else "cadence-only", + "gate": { + "every_n": args.measure_every_n_runs, + "run_number": run_number, + "event_name": event_name, + "reason": gate_reason, + }, + "online_runners": inv.online if (inv is not None and inv.available) else None, + "offline_runners": inv.offline if (inv is not None and inv.available) else None, + "starved_jobs": [r.__dict__ for r in st.starved] if st is not None else [], + "in_progress_jobs": len(st.in_progress) if st is not None else None, + "unexamined_runs": st.unexamined if st is not None else None, + "gh_calls": calls.calls + (st.stats.calls if st is not None else 0), "gh_failures": [ + {"endpoint": ep, "returncode": code, "message": msg} + for ep, code, msg in calls.failures + ] + ([ {"endpoint": ep, "returncode": code, "message": msg} for ep, code, msg in st.stats.failures - ], - "job_reads": st.job_reads, - "skipped_young_runs": st.skipped_young, - "budget_unread_runs": len(st.budget_unread), - "in_progress_unread_runs": st.in_progress_unread, - "listing_failed": sorted(st.listing_failed), + ] if st is not None else []), + "job_reads": st.job_reads if st is not None else None, + "skipped_young_runs": st.skipped_young if st is not None else None, + "budget_unread_runs": len(st.budget_unread) if st is not None else None, + "in_progress_unread_runs": st.in_progress_unread if st is not None else None, + "listing_failed": sorted(st.listing_failed) if st is not None else [], } if args.json: print(json.dumps(payload, ensure_ascii=False, indent=2)) diff --git a/scripts/tests/test_check_runner_starvation.py b/scripts/tests/test_check_runner_starvation.py index ea91690aa78..e0ff997e1ce 100644 --- a/scripts/tests/test_check_runner_starvation.py +++ b/scripts/tests/test_check_runner_starvation.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import sys from datetime import datetime, timedelta, timezone from pathlib import Path @@ -688,3 +689,113 @@ def test_evaluate_wires_host_loss_warnings(): v = rs.evaluate(inv, rs.Starvation(), warn_floor=2) assert v.status == "OK" assert any("POOL LOSS" in w and "ai-01-wsl" in w for w in v.warnings) + + +# --- PORTE DE MESURE (#22937) ---------------------------------------------- +# Le heartbeat workflow_run declenche sur CHAQUE fin d'ancre (721 fins/24 h +# mesurees -> 60 runs/h pour la paire d'advisories -> ~480 appels API/h sur +# le jeton de depot, sature le 2026-10-28 22:04Z). La porte borne la +# MESURE, pas le RUN : l'observateur survit et son rapport de cadence servie +# (#15332) est emis a chaque passage. + +def test_gate_is_inactive_by_default(): + measure, reason = rs.gate_measurement(every_n=0, run_number=7, + event_name="workflow_run") + assert measure is True + assert "inactive" in reason + + +def test_gate_every_n_one_is_inactive(): + measure, _ = rs.gate_measurement(every_n=1, run_number=7, + event_name="workflow_run") + assert measure is True + + +def test_gate_never_gates_the_schedule_leg(): + # La jambe schedule est la cadence de REFERENCE de l'advisory : la + # gater, ce serait supprimer la reference qu'on veut conserver. + measure, reason = rs.gate_measurement(every_n=4, run_number=3, + event_name="schedule") + assert measure is True + assert "schedule" in reason + + +def test_gate_never_gates_a_manual_dispatch(): + measure, _ = rs.gate_measurement(every_n=4, run_number=3, + event_name="workflow_dispatch") + assert measure is True + + +def test_gate_measures_one_workflow_run_in_n(): + measured, _ = rs.gate_measurement(every_n=4, run_number=8, + event_name="workflow_run") + assert measured is True + for run_number in (9, 10, 11): + measured, _ = rs.gate_measurement(every_n=4, run_number=run_number, + event_name="workflow_run") + assert measured is False + + +def test_gate_measures_when_the_run_number_is_unknown(): + # Hors Actions (GITHUB_RUN_NUMBER absent) : une lecture qui ne sait pas + # ou elle est ne doit pas s'arreter. + measure, reason = rs.gate_measurement(every_n=4, run_number=None, + event_name="workflow_run") + assert measure is True + assert "inconnu" in reason + + +def test_gate_reason_names_the_run_it_refuses_to_measure(): + _, reason = rs.gate_measurement(every_n=4, run_number=17, + event_name="workflow_run") + assert "17" in reason + assert "CADENCE SEULE" in reason + + +def test_env_int_reads_and_refuses_garbage(monkeypatch): + monkeypatch.setenv("RS_TEST_INT", " 42 ") + assert rs._env_int("RS_TEST_INT") == 42 + monkeypatch.setenv("RS_TEST_INT", "pas-un-entier") + assert rs._env_int("RS_TEST_INT") is None + monkeypatch.delenv("RS_TEST_INT", raising=False) + assert rs._env_int("RS_TEST_INT") is None + + +def test_main_cadence_only_pass_still_reports_the_scheduler_silence( + monkeypatch, capsys): + # Le devoir #15332 ne doit SURVIVRE a la porte : un passage econome + # reste un passage, et il dit toujours si le scheduler se tait. + seen = {"inventory": 0, "starvation": 0} + + def fake_scheduled(repo, workflow, limit=3, stats=None): + # La vraie lecture compte 1 appel dans le compteur du passage. + if stats is not None: + stats.calls += 1 + return [] + + def fake_inventory(repo, label, token): + seen["inventory"] += 1 + return rs.RunnerInventory() + + def fake_starvation(*args, **kwargs): + seen["starvation"] += 1 + return rs.Starvation() + + monkeypatch.setattr(rs, "fetch_scheduled_runs", fake_scheduled) + monkeypatch.setattr(rs, "fetch_inventory", fake_inventory) + monkeypatch.setattr(rs, "fetch_starvation", fake_starvation) + monkeypatch.setattr(sys, "argv", [ + "check_runner_starvation.py", "--json", + "--self-workflow", "advisory.yml", + "--measure-every-n-runs", "4", + ]) + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_run") + monkeypatch.setenv("GITHUB_RUN_NUMBER", "17") + + assert rs.main() == 0 + out = capsys.readouterr().out + payload = json.loads(out) + assert payload["measurement"] == "cadence-only" + assert payload["gh_calls"] == 1 + assert seen == {"inventory": 0, "starvation": 0} + assert any("SCHEDULER MUET" in w for w in payload["warnings"]) diff --git a/tests/test_check_runner_starvation_cadence.py b/tests/test_check_runner_starvation_cadence.py index c481b05c4d5..e71c5ab6e0b 100644 --- a/tests/test_check_runner_starvation_cadence.py +++ b/tests/test_check_runner_starvation_cadence.py @@ -77,7 +77,7 @@ def test_un_seul_run_visible_note_sans_intervalle() -> None: def test_fetch_scheduled_runs_filtre_et_degrade(monkeypatch) -> None: # Deux runs rendus par l'API (un schedule, un workflow_run heartbeat) : # seules les dates des runs planifies sont retenues, ordre conserve. - def fake_gh_json(args, token=None): + def fake_gh_json(args, token=None, stats=None): if "event=schedule" not in args[0]: return {"workflow_runs": []} return { @@ -93,5 +93,5 @@ def fake_gh_json(args, token=None): assert runs[0] == datetime(2026, 9, 16, 6, 51, 15, tzinfo=timezone.utc) # Echec API -> liste vide (l'evaluateur le traduit en warning, pas en crash). - monkeypatch.setattr(crs, "_gh_json", lambda args, token=None: None) + monkeypatch.setattr(crs, "_gh_json", lambda args, token=None, stats=None: None) assert crs.fetch_scheduled_runs("jsboige/CoursIA", "linux-runner-starvation-advisory.yml") == []