From 2ff175c0ac4a6ada15125ff9c00080ca2a9dd0bd Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 5 Nov 2026 20:04:01 +0100 Subject: [PATCH] Fix(picker,#19309): le gel s'applique a toutes les classes -- une EPIC gelee n'est plus servie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le gel est une decision explicite du coordinateur, portee par deux regles : regle A (ligne `[FROZEN] ...` dans le CORPS de l'issue) et regle B (en-tete `[FROZEN]` dans le FIL). `_issue_is_frozen` les lisait deja, mais elle n'etait cablee que DERRIERE `has_recent_delivery_marker`, lui-meme cable sur l'urne `grain` SEULE (`draw_unclaimed` : `if cls == "grain" and not include_delivered`). Une EPIC, classee `umbrella`, n'etait donc jamais consultee pour son gel. Mesure du 2026-11-05 (#19309) : l'EPIC est gelee par le body (ligne `[FROZEN]` depuis 17:38Z) ET par le fil (depuis le 03/11) ; le tapis l'a servie 7 fois dans la journee -- 7 `[RELEASED]` sans travail. Le correctif : - une sonde de gel A PART ENTIERE (`has_frozen_marker`), qui ne lit QUE le gel -- ni le marqueur de livraison de l'item, ni celui de ses enfants -- et rend la SOURCE (`body` / `comment`) ; - elle est appliquee a TOUTES les classes (`grain`, `umbrella`, `delivered`), avant les filtres de classe et avant le plafond EPIC, pour qu'un candidat gele dise « GEL » et ne revienne pas au tirage suivant ; - l'ecart sort avec sa cause (`GEL (body|comment)`) et son propre volet de sortie (`frozen_signal` en JSON, `ECARTE (gel)` en texte) ; - une lecture ratee reste fail-OPEN, mais DITE (`frozen_unread`) ; - le mode `--admissible` rendait `ADMISSIBLE` sur un item gele : le filtre y est branche, et la sortie nomme la cause. Cout : ZERO requete supplementaire dans le tapis -- la lecture du fil est servie par l'instantane de la passe, qui couvre la fenetre que la boucle parcourt, et le corps vient du cache du pool que le pool paie deja. La voie ponderee lit une fois de plus par candidat tire (une poignee). Temoins : `scripts/tests/test_pick_idle_grain_freeze_scope.py` -- EPIC gelee par le corps, EPIC gelee par le fil, controle negatif, gel sur toutes les classes, `--include-delivered` qui ne contourne pas le gel, lecture ratee fail-OPEN signalee, voie ponderee, `--admissible`. Suite : 1943 passed, 2 skipped, 5 xfailed (selection picker/claim/snapshot/ candidate-delivered/origami/closure) ; 498 passed sur la suite du picker. Co-Authored-By: Claude Haiku 5.5 --- scripts/pick_idle_grain.py | 224 ++++++++- .../test_pick_idle_grain_freeze_scope.py | 441 ++++++++++++++++++ 2 files changed, 660 insertions(+), 5 deletions(-) create mode 100644 scripts/tests/test_pick_idle_grain_freeze_scope.py diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 262a58f9b7..b5f6da8e03 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -2769,6 +2769,66 @@ def _issue_comments(issue_number: int) -> tuple[list[dict] | None, str | None]: return comments, body +def has_frozen_marker(issue_number: int) -> str | None: + """L'item est-il GEL ? Renvoie la SOURCE du gel, ou l'absence de gel. + + TRI-ETAT : + - ``"body"`` -- regle A : le corps porte la ligne `[FROZEN] ...` ; + - ``"comment"`` -- regle B : un en-tete `[FROZEN]` dans le fil ; + - ``False`` -- l'item n'est pas gel ; + - ``None`` -- la LECTURE a echoue (fail-OPEN). + + **Ce que cette fonction ne fait PAS** : lire les marqueurs de + LIVRAISON. Ni ceux de l'item, ni -- pour une umbrella -- ceux de ses + enfants. C'est la separation demandee sur #19309 : le gel est un veto + de la classe, la livraison est un fait propre a l'item. Un parapluie + n'herite pas de la livraison de ses enfants. + + Pourquoi une fonction distincte de `has_recent_delivery_marker` : celle-ci + confond les deux notions (elle rend `True` pour un item gel comme pour un + item livre), et elle n'etait cablee que sur l'urne `grain`. Le gel d'une + umbrella n'etait donc jamais consulte -- #19309, gelee par le body et par + le fil, servie 7 fois par jour comme tete du tapis. + + Cout : la MEME lecture que `has_recent_delivery_marker` (une requete, + ou ZERO quand l'instantane de la passe sert le fil), et le corps vient + du cache du pool (`_POOL_BODIES`), que le pool paie deja. + """ + comments, fetched_body = _issue_comments(issue_number) + if comments is None: + # Lecture en echec : fail-OPEN. L'appelant distingue « pas gele » + # de « non mesure » par ce tri-etat. + return None + body_of_issue = _issue_body(issue_number) + if body_of_issue is None: + body_of_issue = fetched_body + return _issue_is_frozen(body_of_issue, comments) or False + + +def gel_cause_text(gel: str | None) -> str | None: + """Le fragment de cause que le GEL ajoute a un refus (#19309). + + - ``"body"`` / ``"comment"`` : l'item est gele, la cause le dit ; + - ``False`` : rien a ajouter ; + - ``None`` : la lecture a echoue -- fail-OPEN, mais + DIT (un gel non mesure n'est pas un item libre de gel). + + Utilise par le mode `--admissible`, qui rendait `ADMISSIBLE` sur un item + gele : le gel est un veto transversal, l'urne de la question doit donc + le voir aussi. + """ + if gel is False: + return None + if gel is None: + return ("GEL NON MESURE : la lecture du gel a echoue -- fail-OPEN " + "assume, l'item n'est pas ecarte pour autant") + return (f"GEL ({gel}) : l'item est gele. Regle A = ligne `[FROZEN] ...` " + f"dans le corps ; regle B = en-tete `[FROZEN]` dans le fil. Le " + f"degel consiste a retirer la ligne du corps, ou a poster " + f"`[UNFROZEN]` depuis une lane habilitee. Un `[RELEASED]` ne " + f"leve pas un gel.") + + def _last_comment_body(issue_number: int) -> str | None: """Le corps du DERNIER commentaire PORTEUR D'ETAT, en 1 requete. @@ -3881,6 +3941,23 @@ def print_delivered_resolved_lines(withheld: list, indent: str = "") -> None: f"{_delivered_resolved_short_cause(cause)}") +def print_frozen_resolved_lines(withheld: list, indent: str = "") -> None: + """Une ligne `ECARTE (gel): #N -- ` par candidat gele (#19309). + + Distincte de `print_delivered_resolved_lines` : le gel n'est pas une + livraison. Un candidat gele est ecarte par un VETO du coordinateur, et + la sortie doit le dire -- sinon la lane lit une liste de candidats + servis et ne voit pas pourquoi celui-ci manque. + """ + for it, cause in withheld: + if not cause.startswith("GEL ("): + continue + source = cause[len("GEL ("):].split(")")[0] + print(f"{indent}ECARTE (gel): #{it['number']} -- regle {source} " + f"(degel : retirer la ligne `[FROZEN]` du corps, ou poster " + f"`[UNFROZEN]` depuis une lane habilitee)") + + def print_delivered_signal_report( withheld: list, state: dict, @@ -4149,6 +4226,9 @@ def print_empty_draw_notice(withheld: list, picks: list, for _, cause in withheld): print(" Une partie a deja ete livree ou est couverte par une PR") print(" ouverte ; elle ne doit pas etre reproduite.") + if any(cause.startswith("GEL (") for _, cause in withheld): + print(" Un candidat est GEL (veto du coordinateur) : il ne peut pas") + print(" etre servi tant que le gel n'est pas leve.") print(" Claims, livraisons et urnes autorisees restent proteges. Enchainer") print(" immediatement sur la deep-queue ou le fallback global de la lane.") print(" Une poignee locale epuisee ne termine jamais la session.") @@ -5137,7 +5217,7 @@ def draw_unclaimed(by_class, args, rng, visits, series, issue_to_family, fallback_by_class=None, continuity_state=None, cover_probe=None, long_visits=None, merged_pr_probe=None, revocation_probe=None, - gpu_probe=None): + gpu_probe=None, frozen_probe=None): """Tire, puis REMPLACE tout candidat qu une autre lane tient deja. Trois raisons de remplacer plutot que d annoter : @@ -5178,6 +5258,11 @@ def draw_unclaimed(by_class, args, rng, visits, series, issue_to_family, recent_filtered = state.setdefault("recent_filtered", 0) # Les numeros servis sur une lecture de claim EN ECHEC (#20265). claim_unread = state.setdefault("claim_unread", []) + # #19309 -- le GEL est transversal : il s'applique a toutes les classes, + # la ou les filtres de livraison restent classes (une umbrella n'herite + # pas de la livraison de ses enfants). + frozen_unread = state.setdefault("frozen_unread", []) + user_frozen_probe = frozen_probe or frozen_probe_inert # Une lecture de claim n'est SIGNALABLE que si elle a ete TENTEE : le # repli `ERROR` couvre aussi le controle jamais demande. check_claims_ran = bool(args.check_claims and args.lane) @@ -5308,6 +5393,29 @@ def _gate_view(cand_item, _bodies=body_by_number): "collision du #14259. Candidat remplace dans la " "meme urne (#14300)."))) continue + # #19309 -- porte de GEL, avant les filtres de classe. + # Le gel est une decision explicite du coordinateur : il + # vaut pour TOUTES les classes (grain, umbrella, + # delivered) et ne se contourne ni par `--include-delivered` + # ni par le fait que l'item soit un parapluie. Ce qu'il ne + # fait PAS : heriter des marqueurs de livraison des + # enfants d'un parapluie -- c'est le role de la sonde + # de CONTENEUR, pas du gel. + gel = user_frozen_probe(c["number"]) + if gel is None: + frozen_unread.append(c["number"]) + elif gel: + state["frozen_filtered"] = state.get( + "frozen_filtered", 0) + 1 + conflicts.append((c, ( + f"GEL ({gel}) : l'item est gele. Regle A = ligne " + f"`[FROZEN] ...` dans le corps ; regle B = " + f"en-tete `[FROZEN]` dans le fil. Le degel " + f"consiste a retirer la ligne du corps, ou a poster " + f"`[UNFROZEN]` depuis une lane habilitee. Un " + f"`[RELEASED]` ne leve pas un gel. Candidat " + f"remplace dans la meme urne."))) + continue if cls == "grain" and not include_delivered: # #19768 : filtre RECENT d'abord, AVANT la sonde # pleine. Un en-tete `[DELIVERED]`, `[RELEASED]` @@ -10280,6 +10388,21 @@ def container_marker_probe_inert(number): # (Ecrire `return None` ici ajouterait un finding RET501 au fichier.) +def frozen_probe_inert(number): + """Defaut inerte de la sonde de GEL (doctrine #19913). + + Rend ``False`` = « aucun gel releve » : la boucle applique ses filtres + habituels, sans neutraliser aucune surface. Un test qui n'injecte pas + de sonde ne sort jamais sur le reseau. + + Le retour n'est PAS ``None`` : ici ``None`` signifierait « lecture en + echec », et l'appelant le traiterait en fail-OPEN *signale*. Un defaut + inerte n'a pas d'echec a signaler : il n'a rien mesure (meme contrat que + `delivery_event_probe_inert`). + """ + return False + + def delivery_event_probe_inert(number): """Defaut inerte de la lecture d'evenement de livraison (#22500 point 1). @@ -10318,7 +10441,8 @@ def belt_pick_with_replacements(belt_pool, belt_claims, args, probe_budget, container_marker_probe=None, delivery_event_probe=None, time_budget_s=None, - gpu_probe=None): + gpu_probe=None, + frozen_probe=None): """La boucle de service du tapis : claims, livraison, remplacement. Extraite de ``main`` (#19390) pour etre testable sans harnais complet : @@ -10355,6 +10479,16 @@ def belt_pick_with_replacements(belt_pool, belt_claims, args, probe_budget, plus de 48 h, donc DUE a nouveau ». Elle remplace le filtre recent pour les consolidations (meme grammaire, meme lecture, une de moins). + ``frozen_probe`` (tests) remplace la lecture du GEL (#19309). Elle est + **distincte** de `recent_marker_probe` sur deux points qui sont le + fond du defaut corrige : (a) elle ne lit QUE le gel -- ni le marqueur de + livraison de l'item, ni celui de ses enfants -- donc une umbrella n'herite + pas de la livraison de ses enfants ; (b) elle vaut pour TOUTES les + classes (`grain`, `umbrella`, `delivered`), la ou le filtre recent + n'etait cable que sur l'urne `grain`. Elle rend la SOURCE du gel + (``"body"`` / ``"comment"``), ``False`` si l'item n'est pas gele, ``None`` + si la lecture a echoue (fail-OPEN signale, `frozen_unread`). + Defauts INERTES (doctrine ``delivered_probe_inert`` / #19913) : un test qui appelle cette boucle sans injecter ses sondes n'emet AUCUNE requete reseau. Qui veut le vrai signal l'injecte -- ``main`` le fait @@ -10400,7 +10534,8 @@ def belt_pick_with_replacements(belt_pool, belt_claims, args, probe_budget, budget = [int(probe_budget)] state = {"failures": [], "budget_hit": False, "recent_filtered": 0, "claim_unread": [], "container_unread": [], "marker_unread": [], - "cover_failures": [], "time_budget_hit": False} + "cover_failures": [], "time_budget_hit": False, + "frozen_unread": [], "frozen_filtered": 0} # #22630 -- l'instantane local est rafraichi UNE FOIS pour les candidats # de la fenetre : toutes les sondes de commentaires (filtre recent et # sonde pleine de livraison) le lisent ensuite sans requete GitHub par @@ -10518,6 +10653,9 @@ def expired(): or container_marker_probe_inert) user_delivery_event_probe = (delivery_event_probe or delivery_event_probe_inert) + # #19309 -- la sonde de GEL est distincte de la sonde de LIVRAISON : + # elle ne lit que le gel, et elle vaut pour toutes les classes. + user_frozen_probe = frozen_probe or frozen_probe_inert def marker_read_is_local(number): """La lecture du marqueur de `number` resterait-elle LOCALE ? @@ -10649,6 +10787,36 @@ def claims_probe(numbers): if code == CLAIM_CODE_BLOCKED: belt_withheld.append((it, human)) continue + # #19309 -- le GEL est un VETO transversal, pas un veto de classe. + # Il passe AVANT le plafond EPIC et avant les filtres de + # livraison, parce que le gel est une decision explicite du + # coordinateur : un candidat gel doit dire « GEL », jamais + # « PLAFOND EPIC » ni « LIVRAISON », et ne doit surtout pas + # revenir au tirage suivant. + # + # Le gel s'applique a TOUTES les classes (grain, umbrella, + # delivered) : la regle A vit dans le corps, la regle B dans le + # fil, et ni l'une ni l'autre ne depend de la classe. Ce que + # cette porte ne fait PAS -- contrairement au filtre de + # livraison -- c'est heriter des marqueurs de livraison des + # ENFANTS d'un parapluie : un EPIC n'est pas livre parce que ses + # sous-grains le sont. + gel = user_frozen_probe(n) + if gel is None: + # Lecture en echec : fail-OPEN, mais DIT (meme doctrine que + # `claim_unread` / `marker_unread` -- le silence n'est pas + # un fail-OPEN, c'est un trou). + state["frozen_unread"].append(n) + elif gel: + state["frozen_filtered"] += 1 + belt_withheld.append((it, ( + f"GEL ({gel}) : l'item est gele. Regle A = ligne " + f"`[FROZEN] ...` dans le corps ; regle B = en-tete " + f"`[FROZEN]` dans le fil. Le degel consiste a retirer la " + f"ligne du corps, ou a poster `[UNFROZEN]` depuis une lane " + f"habilitee. Un `[RELEASED]` ne leve pas un gel. " + f"Candidat remplace dans la meme urne."))) + continue # #22500 -- plafond hebdomadaire d'un EPIC hors consolidation. Le # tapis ne peut pas empecher une lane de CREER un sous-grain ; il # peut refuser de lui reproposer le meme EPIC tant qu'un sous-grain @@ -11280,6 +11448,15 @@ def main(argv: list[str] | None = None) -> int: ) delivered_state: dict = {"failures": [], "budget_hit": False, "recent_filtered": 0} + # Sonde de GEL (#19309) : meme commutateur d'inertie sous pytest que les + # deux sondes ci-dessus. Le gel est un veto transversal -- toutes + # classes -- et il est distinct de la sonde de livraison : un parapluie + # n'herite pas des marqueurs de livraison de ses enfants. + frozen_probe = ( + frozen_probe_inert + if "PYTEST_CURRENT_TEST" in os.environ and args.cache_dir is None + else has_frozen_marker + ) # Capacite dimensionnee pour le cache PAR TRANCHE (#19236) : la fenetre du # tapis (90 j) couvre ~30 tranches de 3 j, qui s'ajoutent aux entrees @@ -11357,6 +11534,13 @@ def main(argv: list[str] | None = None) -> int: "(fermee, ou au-dela de la limite de la requete).") return 1 cause = admissibility(hit, balance, issue_to_family, args.dwell_hours) + # #19309 -- le mode `--admissible` ne passait par AUCUN des filtres de + # gel : il rendait `ADMISSIBLE` sur une issue gelee. Le gel est un + # veto transversal, donc cette urine le voit aussi -- et la sortie le + # dit, plutot que de laisser lire un `ADMISSIBLE` muet. + gel_text = gel_cause_text(frozen_probe(hit["number"])) + if gel_text: + cause = (cause + " " + gel_text) if cause else gel_text if series_err: if (cache_status.get("series") or {}).get("status") == "stale": print(f"(saturation FRAICHE NON MESUREE : {series_err} -- " @@ -11968,7 +12152,8 @@ def _probe(n: int) -> str | None: has_recent_container_delivery_marker), delivery_event_probe=delivery_event, time_budget_s=belt_time_remaining, - gpu_probe=gpu_gate_probe)) + gpu_probe=gpu_gate_probe, + frozen_probe=frozen_probe)) _belt_phase(f"pick_with_replacements fait: " f"{len(belt_picks)} retenu(s), {len(belt_withheld)} witheld") belt_delivered_failures = belt_pick_state["failures"] @@ -12050,6 +12235,10 @@ def _probe(n: int) -> str | None: # #19907 acceptance 2 : une ligne par candidat ecarte pour # livraison, avant les agregats -- chaque ecart se lit seul. print_delivered_resolved_lines(belt_withheld, indent=" ") + # #19309 : le gel a sa ligne a lui -- c'est un VETO du + # coordinateur, pas une livraison, et la lane doit voir POURQUOI + # le candidat manque. + print_frozen_resolved_lines(belt_withheld, indent=" ") # #19768 : le filtre recent a sa ligne a lui, HORS budget -- ne # pas le fondre dans « Signal de livraison », la distinction est # la mesure (un candidat ecarte hors budget n'a pas consomme de @@ -12276,6 +12465,19 @@ def _probe(n: int) -> str | None: "container_unread": sorted(set(belt_pick_state.get( "container_unread") or [])), }, + # Volet GEL (#19309) : le gel est un veto transversal, et son + # propre volet est separe des marqueurs de livraison -- un + # parapluie n'herite pas de la livraison de ses enfants, mais + # il herite de son PROPRE gel. `withheld` = candidats ecartes + # par la porte de gel, `unread` = lectures ratees (fail-OPEN + # signale). + "frozen_signal": { + "withheld": [it["number"] for it, c in belt_withheld + if c.startswith("GEL (")], + "filtered": belt_pick_state.get("frozen_filtered", 0), + "unread": sorted(set(belt_pick_state.get( + "frozen_unread") or [])), + }, # Volet bulk GraphQL (#19643, ai-01 review 5568273366) : # `fallback_count` est le nombre de numeros absents de # la map bulk et qui ont du recourir a la sonde unitaire @@ -12476,7 +12678,8 @@ def _probe(n: int) -> str | None: fallback_by_class=fallback_by_class, continuity_state=continuity, long_visits=long_visits, - gpu_probe=gpu_gate_probe) + gpu_probe=gpu_gate_probe, + frozen_probe=frozen_probe) withheld.extend(claim_conflicts) delivery = recent_delivery(picks) # Geste 5 #18203 : consigner la volee ponderee. L'urne journalisee est @@ -12572,6 +12775,15 @@ def _probe(n: int) -> str | None: "withheld": [it["number"] for it, c in withheld if c.startswith("GPU : ")], }, + # Volet GEL (#19309) : distinct du volet livraison -- le gel est + # un veto transversal, la livraison reste un fait de classe. + "frozen_signal": { + "withheld": [it["number"] for it, c in withheld + if c.startswith("GEL (")], + "filtered": delivered_state.get("frozen_filtered", 0), + "unread": sorted(set(delivered_state.get("frozen_unread") + or [])), + }, "red_backlog": backlog, "substance_drought": drought, "lane_record": lane_record, @@ -12720,6 +12932,8 @@ def _probe(n: int) -> str | None: # AVANT les picks servis -- le tableau ci-dessous reste la vue de # tirage, les lignes ECARTE sont la vue d'exclusion. print_delivered_resolved_lines(withheld) + # #19309 : le gel a sa ligne a lui (veto du coordinateur, pas livraison). + print_frozen_resolved_lines(withheld) header = (f"{'urne':<10} {'issue':<8} {'age':>5} {'inact':>6} {'vus':>4} " f"{'genre':<16} {'p':>5} titre") print(header) diff --git a/scripts/tests/test_pick_idle_grain_freeze_scope.py b/scripts/tests/test_pick_idle_grain_freeze_scope.py new file mode 100644 index 0000000000..afc18cc5f9 --- /dev/null +++ b/scripts/tests/test_pick_idle_grain_freeze_scope.py @@ -0,0 +1,441 @@ +"""Le GEL est transversal : toutes les classes, pas seulement l'urne grain. + +Contexte (#19309) : le gel est une decision explicite du coordinateur. +Deux regles le portent -- regle A (ligne `[FROZEN] ...` dans le CORPS de +l'issue) et regle B (en-tete `[FROZEN]` dans le FIL). `_issue_is_frozen` +les lisait deja, mais elle n'etait cablee que DERRIERE +`has_recent_delivery_marker`, lui-meme cable sur l'urne `grain` SEULE. + +Mesure du 2026-11-05 (#19309) : l'EPIC etait gelee par le body ET par le +fil, et le tapis l'a servie 7 fois dans la journee -- 7 `[RELEASED]` sans +travail. Le gel est donc became une sonde A PART ENTIERE : distincte de la +sonde de livraison (elle ne lit QUE le gel -- un parapluie n'herite pas de +la livraison de ses enfants) et valable pour TOUTES les classes. + +Les temoins ci-dessous sont les deux formes de gel (body, fil) sur une +EPIC, plus les garde-fous : controle negatif, lecture en echec, et +`--include-delivered` qui ne contourne pas le gel. + +Convention du dossier : `main()` n'est appele qu'a pool vide ; les tests +portent sur les boucles extrait (`belt_pick_with_replacements`, +`draw_unclaimed`), comme les autres tests du picker. +""" + +import random +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ci")) + +import pick_idle_grain as pig # noqa: E402 + + +class _RealGhReached(BaseException): + """Un test a atteint un `gh` reel. + + Derive de `BaseException` : le picker attrape large sur ses chemins de + fetch, un garde derive d'`Exception` serait avale par ces `except` -- + exactement le silence qu'il doit casser. + """ + + +@pytest.fixture(autouse=True) +def _no_real_gh(monkeypatch, request): + """Aucun test de ce fichier n'emet un appel `gh` (doctrine #19913).""" + if request.node.get_closest_marker("real_gh"): + return + real_run = pig.subprocess.run + + def guarded_run(argv, *args, **kwargs): + cmd = argv if isinstance(argv, (list, tuple)) else [argv] + joined = " ".join(str(part) for part in cmd) + if re.search(r"(^|[\\/ ])gh(\.exe)?($|\s)", joined): + raise _RealGhReached("appel gh reel depuis un test : " + joined[:160]) + return real_run(argv, *args, **kwargs) + + monkeypatch.setattr(pig.subprocess, "run", guarded_run) + + +@pytest.fixture(autouse=True) +def _pool_vide(monkeypatch): + """Le cache des corps est vide entre deux tests.""" + monkeypatch.setattr(pig, "_POOL_BODIES", {}) + + +def _make_item(number, age_days=100, idle=10, klass="grain", last=None, + genre="docs"): + return { + "number": number, + "klass": klass, + "age": age_days, + "idle": idle, + "genre": genre, + "labels": [], + "title": f"issue #{number}", + "created_at": "2026-01-01T00:00:00Z", + "last_delivery_stamp": last, + } + + +def _belt_args(grains=2, include_delivered=False): + import types + return types.SimpleNamespace( + grains=grains, include_delivered=include_delivered, + lane="myia-ai-01:CoursIA-2") + + +def _free_claims(items): + return {it["number"]: (pig.CLAIM_CODE_FREE, "libre") for it in items} + + +def _install_issues(monkeypatch, issues): + """Stubbe la lecture de PLUSIEURS issues : corps + fil, sans reseau. + + Le corps passe par le cache du pool (`_POOL_BODIES`) -- c'est le chemin + que le pool paie deja -- et le fil par `_issue_comments`, qui est ensuite + lu par `_issue_is_frozen`. Une issue absente du registre se lit comme une + LECTURE RATEE (`(None, None)`) : c'est le fail-OPEN de la sonde. + """ + registry = {} + for number, spec in issues.items(): + body = spec.get("body", "") + comments = spec.get("comments") or [] + registry[int(number)] = (body, comments) + pig._POOL_BODIES[int(number)] = body + + def _fake_comments(issue_number): + found = registry.get(int(issue_number)) + if found is None: + return None, None + body, comments = found + return list(comments), body + + monkeypatch.setattr(pig, "_issue_comments", _fake_comments) + + +# -------------------------------------------------------------------------- +# La sonde de gel elle-meme : source du gel, et echec distingue. +# -------------------------------------------------------------------------- + + +def test_has_frozen_marker_dit_la_source_du_gel(monkeypatch): + """Regle A (body) et regle B (fil) : la source est nommee, pas devinee.""" + _install_issues(monkeypatch, { + 19309: {"body": "[FROZEN] EPIC gelee sous le veto user " + "-- coordinateur, 2026-11-05"}, + 19310: {"body": "EPIC de suivi", + "comments": [{"body": "[FROZEN] gel pose en fil " + "-- coordinateur"}]}, + }) + assert pig.has_frozen_marker(19309) == "body" + assert pig.has_frozen_marker(19310) == "comment" + + +def test_has_frozen_marker_ne_gele_pas(monkeypatch): + """Controle negatif : ni le corps ni le fil ne portent de gel.""" + _install_issues(monkeypatch, { + 19311: {"body": "EPIC de suivi", + "comments": [{"body": "[RELEASED] lane x -- fin de cycle"}]}, + }) + assert pig.has_frozen_marker(19311) is False + + +def test_has_frozen_marker_lecture_echouee_rend_none(monkeypatch): + """Lecture ratee -> None (fail-OPEN signale), pas False.""" + monkeypatch.setattr(pig, "_issue_comments", lambda n: (None, None)) + assert pig.has_frozen_marker(19312) is None + + +def test_has_frozen_marker_ne_lit_pas_les_marqueurs_de_livraison(monkeypatch): + """Separation des deux notions : une livraison n'est PAS un gel. + + Un item livre (marqueur `[DELIVERED]`) n'est pas gele : la sonde de gel + ne doit pas confondre les deux, sinon un parapluie heriterait de la + livraison de ses enfants. + """ + _install_issues(monkeypatch, { + 19313: {"body": "EPIC de suivi", + "comments": [{"body": "[DELIVERED] lane x -- PR #1"}]}, + }) + assert pig.has_frozen_marker(19313) is False + + +# -------------------------------------------------------------------------- +# Temoin 1 : l'EPIC gelee par le BODY est ecartee du tapis. +# -------------------------------------------------------------------------- + + +def test_belt_epic_gelee_par_le_body_est_ecartee(monkeypatch): + """TEMOIN 1 -- regle A : une EPIC gelee par son corps n'est pas servie. + + C'est le cas fondateur de #19309 : l'EPIC etait gelee depuis le + 2026-11-05 17:38Z (ligne `[FROZEN]` dans le corps) et le tapis l'a + servie 7 fois dans la journee, parce que la sonde de gel n'etait cablee + que sur l'urne `grain`. + """ + epic = _make_item(19309, klass="umbrella") + clean = _make_item(19320, klass="umbrella") + _install_issues(monkeypatch, { + 19309: {"body": "[FROZEN] EPIC gelee sous le veto user " + "« carnets neufs » -- coordinateur " + "myia-ai-01:CoursIA, 2026-11-05"}, + 19320: {"body": "EPIC libre"}, + }) + + pool = [epic, clean] + picks, withheld, state = pig.belt_pick_with_replacements( + pool, _free_claims(pool), _belt_args(grains=2), probe_budget=8, + frozen_probe=pig.has_frozen_marker) + + assert [p["number"] for p in picks] == [19320], ( + "une EPIC gelee par son corps ne doit pas etre servie") + causes = {w[0]["number"]: w[1] for w in withheld} + assert causes[19309].startswith("GEL (body)"), causes[19309] + assert state["frozen_filtered"] == 1 + + +# -------------------------------------------------------------------------- +# Temoin 2 : l'EPIC gelee par le FIL est ecartee du tapis. +# -------------------------------------------------------------------------- + + +def test_belt_epic_gelee_par_le_fil_est_ecartee(monkeypatch): + """TEMOIN 2 -- regle B : le gel pose en fil ecarte aussi l'EPIC. + + Le gel de #19309 existe dans les DEUX formes ; une EPIC peut aussi + n'etre gelee que par le fil (le filet des gels deja poses en + commentaire, en attendant leur migration vers le body). + """ + epic = _make_item(19309, klass="umbrella") + clean = _make_item(19320, klass="umbrella") + _install_issues(monkeypatch, { + 19309: {"body": "EPIC de suivi", + "comments": [ + {"body": "[FROZEN] gel pose en fil " + "-- coordinateur, 2026-11-05"}, + {"body": "[RELEASED] lane autre -- fin de cycle"}, + ]}, + 19320: {"body": "EPIC libre"}, + }) + + pool = [epic, clean] + picks, withheld, state = pig.belt_pick_with_replacements( + pool, _free_claims(pool), _belt_args(grains=2), probe_budget=8, + frozen_probe=pig.has_frozen_marker) + + assert [p["number"] for p in picks] == [19320] + causes = {w[0]["number"]: w[1] for w in withheld} + assert causes[19309].startswith("GEL (comment)"), causes[19309] + assert state["frozen_filtered"] == 1 + + +# -------------------------------------------------------------------------- +# Garde-fous. +# -------------------------------------------------------------------------- + + +def test_belt_epic_non_gelee_est_servie(monkeypatch): + """Controle negatif : la porte de gel n'ecarte pas tout.""" + epic = _make_item(19320, klass="umbrella") + _install_issues(monkeypatch, { + 19320: {"body": "EPIC libre", + "comments": [{"body": "[RELEASED] lane x -- fin de cycle"}]}, + }) + pool = [epic] + picks, withheld, state = pig.belt_pick_with_replacements( + pool, _free_claims(pool), _belt_args(grains=1), probe_budget=8, + frozen_probe=pig.has_frozen_marker) + assert [p["number"] for p in picks] == [19320] + assert state["frozen_filtered"] == 0 + + +def test_belt_gel_s_applique_a_toutes_les_classes(monkeypatch): + """Le gel est transversal : grain, umbrella ET delivered. + + L'urne `delivered` sert precisement les issues livrees aux lanes + habilitees (#15069) ; un gel reste un gel. + """ + grain = _make_item(101, klass="grain") + delivered = _make_item(102, klass="delivered") + clean = _make_item(103, klass="grain") + _install_issues(monkeypatch, { + 101: {"body": "[FROZEN] gele par le coordinateur -- 2026-11-05"}, + 102: {"body": "[FROZEN] gele par le coordinateur -- 2026-11-05"}, + 103: {"body": "grain libre"}, + }) + + pool = [grain, delivered, clean] + picks, withheld, state = pig.belt_pick_with_replacements( + pool, _free_claims(pool), _belt_args(grains=3), probe_budget=8, + frozen_probe=pig.has_frozen_marker) + + assert [p["number"] for p in picks] == [103] + causes = {w[0]["number"]: w[1] for w in withheld} + assert causes[101].startswith("GEL (body)") + assert causes[102].startswith("GEL (body)") + assert state["frozen_filtered"] == 2 + + +def test_belt_include_delivered_ne_contourne_pas_le_gel(monkeypatch): + """`--include-delivered` ne desarme pas le gel. + + L'echappatoire de `--include-delivered` porte sur les MARQUEURS DE + LIVRAISON ; le gel est une decision du coordinateur, pas un fait de + livraison. + """ + epic = _make_item(19309, klass="umbrella") + _install_issues(monkeypatch, { + 19309: {"body": "[FROZEN] gelee par le coordinateur -- 2026-11-05"}, + }) + pool = [epic] + picks, withheld, state = pig.belt_pick_with_replacements( + pool, _free_claims(pool), _belt_args(grains=1, include_delivered=True), + probe_budget=8, frozen_probe=pig.has_frozen_marker) + assert picks == [] + assert withheld[0][1].startswith("GEL (body)") + assert state["frozen_filtered"] == 1 + + +def test_belt_lecture_de_gel_echouee_fail_open_et_signalee(monkeypatch): + """Lecture ratee -> fail-OPEN, mais DIT (`frozen_unread`). + + Meme doctrine que `claim_unread` / `marker_unread` : le silence n'est + pas un fail-OPEN, c'est un trou. + """ + epic = _make_item(19320, klass="umbrella") + pool = [epic] + picks, withheld, state = pig.belt_pick_with_replacements( + pool, _free_claims(pool), _belt_args(grains=1), probe_budget=8, + frozen_probe=lambda n: None) + assert [p["number"] for p in picks] == [19320], ( + "une lecture de gel ratee ne doit pas ecarter le candidat") + assert state["frozen_unread"] == [19320] + assert state["frozen_filtered"] == 0 + + +def test_belt_sans_sonde_injectee_ne_measure_rien(monkeypatch): + """Le defaut inerte ne fait rien de plus que la sonde de gel. + + Sans sonde injectee, la porte de gel est NEUTRE (le defaut inerte rend + `False`) : la boucle ne lit rien de plus, et ne signale aucune lecture + ratee. Un test qui n'injecte pas de sonde ne sort jamais sur le reseau. + """ + epic = _make_item(19320, klass="umbrella") + pool = [epic] + picks, withheld, state = pig.belt_pick_with_replacements( + pool, _free_claims(pool), _belt_args(grains=1), probe_budget=8) + assert [p["number"] for p in picks] == [19320] + assert state["frozen_unread"] == [] + assert state["frozen_filtered"] == 0 + + +# -------------------------------------------------------------------------- +# La voie PONDEREE : meme porte, meme grammaire. +# -------------------------------------------------------------------------- + + +def test_draw_unclaimed_epic_gelee_est_ecartee(monkeypatch): + """La voie ponderee applique la meme porte de gel. + + Sans cela, le tapis ecarterait l'EPIC mais le tirage pondere la + resservirait -- deux urnes, une seule regle. + """ + class _A: + grains = 0 + umbrellas = 1 + delivered = 0 + prev_genre = None + lane = "myia-ai-01:CoursIA-2" + check_claims = False + + candidate = {"number": 19309, "title": "EPIC gelee", + "age": 30, "idle": 1, "idle_since_delivery": 1, + "genre": "guard"} + by_class = {"grain": [], "umbrella": [candidate], "delivered": []} + state = {"failures": [], "budget_hit": False, "recent_filtered": 0} + + picks, claims, conflicts = pig.draw_unclaimed( + by_class, _A(), random.Random(42), {}, {}, {}, + delivered_state=state, frozen_probe=lambda n: "body") + + assert picks == [] + assert len(conflicts) == 1 + assert conflicts[0][1].startswith("GEL (body)"), conflicts[0][1] + assert state["frozen_filtered"] == 1 + + +def test_draw_unclaimed_lecture_de_gel_echouee_fail_open(monkeypatch): + """Lecture ratee sur la voie ponderee : fail-OPEN signale.""" + class _A: + grains = 1 + umbrellas = 0 + delivered = 0 + prev_genre = None + lane = "myia-ai-01:CoursIA-2" + check_claims = False + + candidate = {"number": 101, "title": "candidat", "age": 30, "idle": 1, + "idle_since_delivery": 1, "genre": "guard"} + by_class = {"grain": [candidate], "umbrella": [], "delivered": []} + state = {"failures": [], "budget_hit": False, "recent_filtered": 0} + # Le filtre RECENT reste cable sur la sonde reelle : on le neutralise + # ici, le scope du test etant la porte de GEL. + monkeypatch.setattr(pig, "has_recent_delivery_marker", lambda n: False) + + picks, claims, conflicts = pig.draw_unclaimed( + by_class, _A(), random.Random(42), {}, {}, {}, + delivered_state=state, frozen_probe=lambda n: None) + + assert [p["number"] for p in picks] == [101] + assert state["frozen_unread"] == [101] + + +# -------------------------------------------------------------------------- +# Le mode `--admissible` : il rendait ADMISSIBLE sur un item gele. +# -------------------------------------------------------------------------- + + +def test_gel_cause_text_nomme_le_gel(): + """La cause du gel est dite dans la sortie de `--admissible`.""" + assert pig.gel_cause_text("body").startswith("GEL (body)") + assert pig.gel_cause_text("comment").startswith("GEL (comment)") + assert pig.gel_cause_text(False) is None + assert "NON MESURE" in pig.gel_cause_text(None) + + +def test_admissible_refuse_un_item_gele(monkeypatch, capsys, tmp_path): + """`--admissible` rend REFUS sur un item gele (#19309, point 3). + + Le mode repondait « ce grain-ci est-il consommable maintenant ? » : un + item gele ne l'est pas. Le filtre est branche, et la sortie le dit. + + `--cache-dir` est fourni pour que la sonde de gel soit la VRAIE (sous + pytest, la sonde par defaut est inerte -- cf `main`) ; la sonde elle-meme + est stubbee, donc aucune requete `gh` n'est emise. + """ + pool_item = { + "number": 19309, "title": "EPIC gelee", "genre": "docs", + "polarity": "neutre", "created_at": "2026-01-01T00:00:00Z", + "age": 100, "idle": 10, "idle_since_delivery": 10, + "labels": [], "klass": "umbrella", + } + + monkeypatch.setattr(pig, "fetch_pool", lambda **kwargs: ([pool_item], None)) + monkeypatch.setattr(pig, "fetch_series_visits", + lambda **kwargs: ({}, {}, None)) + monkeypatch.setattr(pig, "zone_balance", + lambda series, issue_to_family, pool: {}) + monkeypatch.setattr(pig, "has_frozen_marker", lambda n: "body") + # `main` epingle le jeton avant tout appel `gh` : un jeton de presence + # court-circuite l'appel, sans quoi le test toucherait le reseau. + monkeypatch.setenv("GH_TOKEN", "jeton-de-test") + + rc = pig.main(["--admissible", "19309", "--cache-dir", str(tmp_path)]) + out = capsys.readouterr().out + assert rc == 1 + assert "REFUS" in out + assert "GEL (body)" in out