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