diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 71bcda6c48..39e2e68a51 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -626,7 +626,8 @@ def drop_superseded(contexts: list[dict]) -> list[dict]: return kept -def blocking_causes(state: dict) -> list[str]: +def blocking_causes(state: dict, *, age_hours: float | None = None, + saturation_hours: float | None = None) -> list[str]: """Causes qui empechent VRAIMENT le merge, formulees en geste de reparation. `mergeStateStatus: BLOCKED` n'est deliberement PAS une cause : il vaut @@ -634,12 +635,29 @@ def blocking_causes(state: dict) -> list[str]: au coordinateur de merger. Verifie firsthand sur #12108 le 2026-08-22 : BLOCKED, MERGEABLE, zero check en echec. L'accuser aurait renvoye la lane reparer une PR qui n'a rien a reparer. + + `file_saturation` (issue #12830, mandant c.508-L2 + ai-01 c.1331p69) : le + 3ᵉ etat distinct de `mergeStateStatus: BLOCKED` est `BLOCKED + MERGEABLE + + statusCheckRollup.state=PENDING > saturation_hours`. La lane pioche sur + un pool virtuellement vide parce que ce 3ᵉ etat n'etait pas declencheur + (mesure ai-01 du 2026-08-26T04:52Z : 1000 runs en file, 14 concurrents, + attente observee 4 h 25 -- c'est le regime nominal, plus un cas limite). + Le critere exige qu'AUCUN check n'ait demarre (PENDING/QUEUED partout) + sur une PR MERGEABLE : c'est exactement la file qui n'a pas bouge, pas + un rouge substance. La cause est formulee comme geste = commentaire + + `--ignore-red` ou `rerun/updater-branch` selon le cas -- la lane peut + poser un acte (commenter) mais ne peut pas derainer la file seule. + + Le critere reste PASSIF si `age_hours` ou `saturation_hours` ne sont pas + fournis (defaut=None), ce qui preserve la signature utilisee par les 12 + tests existants (cf `_state(...)` qui ne porte pas `age_hours`). """ causes: list[str] = [] advisory: list[str] = [] commits = state.get("commits", {}).get("nodes") or [] rollup = (commits[0]["commit"].get("statusCheckRollup") if commits else None) or {} - for ctx in drop_superseded((rollup.get("contexts", {}) or {}).get("nodes") or []): + contexts = drop_superseded((rollup.get("contexts", {}) or {}).get("nodes") or []) + for ctx in contexts: name = ctx.get("name") or ctx.get("context") or "?" verdict = (ctx.get("conclusion") or ctx.get("state") or "").upper() if verdict not in CHECK_FAILED: @@ -659,6 +677,24 @@ def blocking_causes(state: dict) -> list[str]: for login, review in latest.items(): if review["state"] == "CHANGES_REQUESTED": causes.append(f"CHANGES_REQUESTED non leve ({login})") + # 3ᵉ declencheur `file_saturation` (cf issue #12830) : aucun check n'a + # demarre (PENDING/QUEUED partout), pas de conflit, pas de CHANGES_REQUESTED + # non leve, et la PR est ouverte depuis plus de `saturation_hours`. + # On ne l'ajoute que si rien d'autre n'a deja ete trouve : un rouge + # substance prime sur la file-saturation (la lane reparera la substance, + # la file draine naturellement). + if (age_hours is not None and saturation_hours is not None + and age_hours >= saturation_hours + and state.get("mergeable") == "MERGEABLE" + and not causes + and contexts): + statuses = {(c.get("conclusion") or c.get("state") or "").upper() for c in contexts} + if statuses <= {"PENDING", "QUEUED", ""}: + causes.append( + f"file_saturation : {len(contexts)} check(s) tous pending depuis > " + f"{int(saturation_hours)}h (faux-rouge non-reparable par la lane -- " + f"commenter la PR + --ignore-red ou rerun/updater-branch si gel CI)" + ) if causes and advisory: causes.append("(diagnostic, non bloquant : " + ", ".join(advisory[:3]) + ")") return causes @@ -778,6 +814,16 @@ def red_backlog(lane: str, threshold_hours: float, des points a traiter »). Le declencheur `nits` rend cette regle explicite au lieu de la laisser dependre d'un filtre retire ailleurs. + `file_saturation` (issue #12830) : au moins une PR file-saturee (cf + `blocking_causes` pour le critere exact) ouverte depuis plus de + `threshold_hours`. C'est le 3ᵉ etat distinct de `mergeStateStatus: + BLOCKED` (le `BLOCKED + MERGEABLE + checks tous PENDING depuis > N h`) + que le picker ratait jusqu'ici, et la lane po-2027 en etait la premiere + victime : narrow persistant ×27 cycles dont la file CI etait la cause + jamais diagnostiquee. Le declencheur precede `aged` parce qu'il est + l'attribution directe du narrow : la lane doit SAVOIR qu'elle subit la + file avant de tenter d'autres rouges. + Rend aussi `unattributed_blocked` : les PRs bloquees dont le tag `Grain:` est illisible. Elles ne peuvent bloquer AUCUNE lane -- c'est la bonne arithmetique (deviner une lane serait pire) -- mais les taire donnerait a @@ -815,7 +861,12 @@ def red_backlog(lane: str, threshold_hours: float, state = states.get(pr["number"]) if state is None: continue - causes = blocking_causes(state) + # age_hours sert au critere file_saturation (cf blocking_causes). On le + # passe ici plutot que dans la query GraphQL parce que le fragment + # `_PR_STATE_FRAGMENT` ne porte pas `createdAt` et l'ajouter alourdirait + # chaque appel pour 2 octets deconomie ; le PR-listing le fournit deja. + age = _hours_since(pr["createdAt"]) + causes = blocking_causes(state, age_hours=age, saturation_hours=threshold_hours) n_nits = nits_by_pr.get(pr["number"], 0) if n_nits: # Un point de review non leve est une cause A PART ENTIERE : la PR @@ -831,7 +882,7 @@ def red_backlog(lane: str, threshold_hours: float, causes.append(sat_cause) if causes: red.append({"number": pr["number"], "title": pr["title"], - "age_hours": round(_hours_since(pr["createdAt"])), + "age_hours": round(age), "causes": causes}) red.sort(key=lambda r: -r["age_hours"]) @@ -840,6 +891,17 @@ def red_backlog(lane: str, threshold_hours: float, # D'abord dans la liste : c'est l'ordre dans lequel le mandat du # 2026-08-24 veut que la lane les traite. triggers.append("nits") + # `file_saturation` precede `aged` : une PR file-saturee est l'attribution + # directe d'un narrow de file CI (cf issue #12830), et la lane doit + # distinguer ce cas des rouges substance. Le critere dans `blocking_causes` + # exige que rien d'autre ne soit deja en cause -- si la PR a un FAIL + # substance ET est en file-saturation, elle reste categorisee rouge + # substance (cause FAIL gagne). + file_saturated = [r for r in red + if any("file_saturation" in c for c in r["causes"]) + and r["age_hours"] >= threshold_hours] + if file_saturated: + triggers.append("file_saturation") aged = [r for r in red if r["age_hours"] >= threshold_hours] if aged: triggers.append("aged") diff --git a/scripts/tests/test_pick_idle_grain.py b/scripts/tests/test_pick_idle_grain.py index 7012680ec6..c12a6e4199 100644 --- a/scripts/tests/test_pick_idle_grain.py +++ b/scripts/tests/test_pick_idle_grain.py @@ -516,6 +516,172 @@ def test_repeated_identical_failures_are_named_once(): assert pig.blocking_causes(state) == ["check requis en echec : PR gate"] +# --- file_saturation : 4ᵉ declencheur (issue #12830, c.508-L2) ------------- +# +# Le defaut fondateur : la lane po-2023 (puis po-2027) tirait dans un pool +# virtuellement vide parce que `mergeStateStatus: BLOCKED + MERGEABLE + +# checks tous PENDING depuis > N h` n'etait PAS un declencheur de refus. +# Le 3ᵉ etat etait mecaniquement non-different d'un `BLOCKED + MERGEABLE +# + checks SUCCESS` (attente coordinateur), qu'on veut deliberement laisser +# passer. La discrimination tient en 3 proprietes : AUCUN check n'a demarre +# (statut dans {PENDING, QUEUED}), la PR est MERGEABLE, et elle est ouverte +# depuis plus de `saturation_hours`. Mesure ai-01 du 2026-08-26T04:52Z : +# 1000 runs en file, 14 concurrents, attente observee 4 h 25. + + +def _state_with_started_at(*, checks=(), mergeable="MERGEABLE", reviews=()): + """Comme `_state` mais chaque check porte `startedAt` ET `conclusion`/`state`. + + Le format GraphQL reel de `statusCheckRollup.contexts` inclut `startedAt` + pour les `CheckRun` et `state` (PAS `conclusion`) pour les `StatusContext` + encore en attente. Le discriminateur file_saturation lit `conclusion` puis + `state` -- d'ou les helpers ci-dessous qui couvrent les deux formes. + """ + return { + "number": 1, "mergeable": mergeable, + "reviews": {"nodes": [ + {"state": s, "submittedAt": "2026-08-20T00:00:00Z", "author": {"login": a}} + for s, a in reviews + ]}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"contexts": {"nodes": [ + {"name": t[0], "conclusion": t[1], "state": t[1], + "isRequired": t[2], "startedAt": "2026-08-25T00:00:00Z"} + for t in checks + ]}}}}]}, + } + + +def test_file_saturation_detected_when_all_checks_pending(): + """Cas fondateur (c.508-L2 sur #12640) : tous les checks en PENDING depuis + > N h, MERGEABLE, pas de conflit, pas de CHANGES_REQUESTED -- c'est + exactement la file qui n'a pas bouge, pas un rouge substance. + + Discrimination exigee : un seul FAIL vivant masquerait la file-saturation, + parce que la lane peut le reparer et la file draine naturellement. + """ + state = _state_with_started_at(checks=[ + ("PR gate", "PENDING", True), + ("Scripts Tests (CPU)", "PENDING", True), + ("Notebook Validation", "PENDING", False), + ]) + causes = pig.blocking_causes(state, age_hours=28, saturation_hours=24) + assert any("file_saturation" in c for c in causes) + # La cause doit nommer le geste -- la lane peut commenter, pas rerun seule. + assert any("commenter la PR" in c for c in causes) + + +def test_file_saturation_not_detected_when_a_check_is_success(): + """Faux-positif a eviter : un SUCCESS + des PENDING n'est PAS de la + file-saturation. La PR a au moins un verdict defini ; elle est en cours + de merge, pas en attente de derainage de file. + """ + state = _state_with_started_at(checks=[ + ("PR gate", "PENDING", True), + ("Scripts Tests (CPU)", "SUCCESS", True), + ]) + assert pig.blocking_causes(state, age_hours=28, saturation_hours=24) == [] + + +def test_file_saturation_not_detected_when_a_check_fails(): + """Faux-positif a eviter : un FAIL substance prime sur la file-saturation. + + La lane peut reparer la substance ; la file draine naturellement apres + re-push. Le discriminateur `not causes` dans `blocking_causes` protege + exactement ce cas (les causes FAIL sont ajoutees avant la detection + file_saturation). + """ + state = _state_with_started_at(checks=[ + ("PR gate", "FAILURE", True), + ("Scripts Tests (CPU)", "PENDING", True), + ]) + causes = pig.blocking_causes(state, age_hours=28, saturation_hours=24) + # Le FAIL gagne : file_saturation ne s'y ajoute pas, sinon la cause serait + # `conflitante` et le geste suggere (commenter + ignore-red) perdrait + # l'option reparation. + assert any("check requis en echec" in c for c in causes) + assert not any("file_saturation" in c for c in causes) + + +def test_file_saturation_not_detected_below_saturation_threshold(): + """L'age seul ne suffit pas : une PR jeune avec tous checks PENDING est + dans la queue normale du depot, pas en saturation. + + Cf le mandat du 2026-08-24 sur le retrait du filtre d'age amont : c'est + ici un garde-fou qui empeche la sur-accusation (mesure : 52/55 PRs le + 2026-08-22 pour la definition naive). Le seuil minimum est le meme que + pour `aged` (`threshold_hours`), ce qui garde la coherence. + """ + state = _state_with_started_at(checks=[ + ("PR gate", "PENDING", True), + ("Scripts Tests (CPU)", "PENDING", True), + ]) + # age < saturation : rien. + assert pig.blocking_causes(state, age_hours=2, saturation_hours=24) == [] + # age == saturation : strictement au-dessus (>=) declenche. + causes = pig.blocking_causes(state, age_hours=24, saturation_hours=24) + assert any("file_saturation" in c for c in causes) + + +def test_file_saturation_not_detected_when_not_mergeable(): + """CONFLICTING prime sur la file-saturation : la lane peut rebaser, ce + n'est plus un rouge de file. + + Le discriminateur exige `mergeable == MERGEABLE`. Une PR en conflit + attend un rebase, pas un drainage CI. + """ + state = _state_with_started_at( + checks=[("PR gate", "PENDING", True)], + mergeable="CONFLICTING", + ) + causes = pig.blocking_causes(state, age_hours=28, saturation_hours=24) + assert "conflits avec main -> rebaser" in causes + assert not any("file_saturation" in c for c in causes) + + +def test_file_saturation_trigger_in_red_backlog(monkeypatch): + """Le 4ᵉ declencheur remonte dans `triggers` quand au moins une PR de la + lane est file-saturee et a l'age du seuil. + + Ce test est l'integration : `red_backlog` doit appeler `blocking_causes` + avec `age_hours` et `saturation_hours` derives du seuil, et ajouter + `"file_saturation"` aux triggers. C'est ce qui rend le narrow + diagnostique enfin visible a la lane. + """ + red = _state_with_started_at(checks=[ + ("PR gate", "PENDING", True), + ("Scripts Tests (CPU)", "PENDING", True), + ]) + _patch_backlog(monkeypatch, [ + _pr(1, "myia-po-2026:CoursIA", 28), + _pr(2, "myia-po-2026:CoursIA", 2), + ], {1: red, 2: red}) + out = pig.red_backlog("myia-po-2026:CoursIA", 24, count_threshold=10) + # La PR #1 est file-saturee (28 h > 24 h, tous PENDING, MERGEABLE). + # La PR #2 ne l'est pas (2 h < 24 h). + assert "file_saturation" in out["triggers"] + assert any(any("file_saturation" in c for c in r["causes"]) + for r in out["red"] if r["number"] == 1) + # Et l'ordre respecte la docstring : file_saturation precede `aged` + # dans la liste des triggers (les deux sont vrais ici, file_saturation + # doit apparaitre AVANT `aged`). + assert out["triggers"].index("file_saturation") < out["triggers"].index("aged") + + +def test_file_saturation_signature_backward_compatible(): + """Le defaut `age_hours=None, saturation_hours=None` preserve les appels + existants : un PR avec tous checks PENDING et sans ces parametres ne + declenche PAS file_saturation. C'est ce qui permet aux 12 tests + historiques de `blocking_causes` de rester verts sans modification. + """ + state = _state_with_started_at(checks=[ + ("PR gate", "PENDING", True), + ("Scripts Tests (CPU)", "PENDING", True), + ]) + # Meme PR que test_file_saturation_detected_when_all_checks_pending, + # mais sans age_hours : la cause ne doit PAS apparaitre. + assert pig.blocking_causes(state) == [] + + # --- affluence flotte : cited_issues / fetch_visits / amortissement -------- # # Le defaut que ces tests auraient attrape (2026-08-23, avant merge) : la