diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index de79b5f683..a516f6c5f5 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -636,6 +636,201 @@ def apply_delivered_urn_gate(lane, urns_arg, urns_default, selected_urns): "[INFO] candidate-delivered avec preuve et rend la main)") +# --- Signal de livraison : ne jamais servir un grain deja livre ------------ +# +# Mesure du 2026-09-12 (escalade de la lane myia-po-2023:CoursIA-2, +# `msg-20260912T174921-p9mfe0`) : 18 grains dont le travail etait DEJA livre +# ont ete servis a une seule lane en 2 cycles. La lane a du les refuter au +# lieu de produire, et restait a `R1 NOT HELD` pour la 9e fois. Le picker +# savait classer le LABEL `candidate-delivered` (klass `delivered`, urne +# reservee #15069) mais etait AVEUGLE au COMMENTAIRE `[INFO] +# candidate-delivered` -- le marqueur que le protocole demande pourtant +# nommement a une lane worker de poster en rendant la main. +# +# Les deux surfaces ne se recouvrent PAS. Mesure sur le pool ouvert du +# 2026-09-12 (339 issues) : 59 portent le label, 60 portent le marqueur en +# commentaire, 54 ne portent QUE le commentaire. Le corpus reellement expose +# est donc majoritairement celui que le label ne voit pas. +# +# Deux couts, deux chemins : le label est deja dans le payload du pool (une +# seule requete `gh issue list --json labels` pour tout le pool, zero appel +# supplementaire) ; le commentaire coute une requete par issue, et n'est donc +# sonde que sur les candidats TIRES -- jamais un balayage du pool. La meme +# borne que `recent_delivery`. +DELIVERED_LABEL = "candidate-delivered" +DELIVERED_COMMENT_MARKER = "[INFO] candidate-delivered" +# Plafond dur de sondes par tirage : la boucle de remplacement d'une urne peut +# en theorie la parcourir entiere, et une urne de 167 grains ne doit pas +# produire 167 requetes. +DELIVERED_SIGNAL_MAX_PROBES = 16 +# Sentinelle rendue par une sonde qui n'a PAS ete tentee (plafond atteint). +# Distincte de ``None`` : les deux sont fail-OPEN, mais l'une est un echec de +# lecture et l'autre une economie voulue -- les confondre ferait afficher +# << lecture en echec >> sur des candidats jamais interroges. +DELIVERED_SIGNAL_UNPROBED = object() + + +def has_delivered_signal(issue_number: int, + lane: str | None = None) -> bool | None: + """Le marqueur `[INFO] candidate-delivered` est-il dans les commentaires ? + + TRI-ETAT, et c'est tout l'objet de la fonction : un echec de lecture n'est + pas une absence de signal. ``True`` = marqueur present, ``False`` = aucun + marqueur, ``None`` = la lecture a echoue (reseau, 403, payload illisible) + et l'appelant doit tirer quand meme EN LE DISANT. + + ``lane`` n'entre pas dans le verdict (le signal vaut pour toutes les + lanes) : il est accepte pour que la sonde et son appelant partagent une + signature unique, et pour les sondes de test qui veulent la lire. + """ + try: + out = subprocess.run( + ["gh", "issue", "view", str(issue_number), "--repo", REPO, + "--json", "comments"], + capture_output=True, text=True, encoding="utf-8", check=True, + timeout=30, + ).stdout + comments = (json.loads(out) or {}).get("comments") or [] + except Exception: # noqa: BLE001 - sonde best-effort ; l'echec est DIT + return None + for comment in comments: + if DELIVERED_COMMENT_MARKER in (comment.get("body") or ""): + return True + return False + + +def delivered_probe_inert(issue_number: int, lane: str | None = None) -> bool: + """Sonde inerte : aucun signal, aucun appel reseau. + + Defaut de ``draw_unclaimed`` : un test unitaire qui appelle la fonction + directement ne doit pas emettre une requete `gh` par candidat tire. Qui + veut le vrai signal l'injecte (ou passe par ``main``). + """ + return False + + +def delivered_signal_reason( + item: dict, + lane: str | None = None, + probe=None, + failures: list[int] | None = None, +) -> str | None: + """Pourquoi ecarter ce candidat de l'urne `grain`, ou ``None``. + + Portee : l'urne `grain` SEULE. Deux urnes ne l'appellent jamais -- + + - `delivered` (#15069) : elle sert precisement a remettre ces issues aux + lanes habilitees, pour fermeture ; + - `umbrella` : le canal label ne marque JAMAIS un EPIC, par decision + mesuree et ecrite (`.github/workflows/candidate-delivered-advisory.yml` + L21-24 : « EPICs are excluded ... the checkbox heuristic suggested in + #10466 was measured firsthand and is UNRELIABLE »). Un commentaire + « [INFO] candidate-delivered *partiel* » sur un EPIC (#12208 : « L'EPIC + reste vivante comme parapluie de tracking ») n'est pas un verdict de + fermeture : une lane n'y claime jamais l'EPIC entier, elle y pioche ou + y cree un sous-grain (proactive-coordination R5). Ecarter une umbrella + retirerait une source de grains de CONTENU, l'inverse du but. + + Le label est teste EN PREMIER parce qu'il ne coute rien ; la sonde de + commentaire n'est atteinte que s'il est absent. Ce n'est pas une + micro-optimisation : c'est ce qui fait que 59 des 113 issues signalees + du pool du 2026-09-12 sont ecartees sans un seul appel reseau. + """ + if probe is None: + probe = delivered_probe_inert + labels = {str(label).casefold() for label in item.get("labels") or []} + if DELIVERED_LABEL in labels: + return ( + "SIGNAL LIVRAISON (label `" + DELIVERED_LABEL + "`) : le travail de " + "cette issue est deja livre. Elle reste servie par l'urne " + "`delivered` aux lanes habilitees (#15069), jamais comme grain de " + "production." + ) + verdict = probe(item["number"], lane) + if verdict == DELIVERED_SIGNAL_UNPROBED: + return None + if verdict is None: + # Fail-OPEN, et rapporte. Traduire cet echec en silence reviendrait a + # lire << pas de signal >> dans une page blanche -- exactement le + # defaut que ce filtre corrige, deplace d'un cran. + if failures is not None: + failures.append(item["number"]) + return None + if verdict: + return ( + "SIGNAL LIVRAISON (commentaire `" + DELIVERED_COMMENT_MARKER + + "`) : une lane a deja rendu la main sur cette issue en la " + "refutant. La re-servir comme grain de production fait bruler un " + "cycle a la lane qui la recoit." + ) + return None + + + +def print_delivered_signal_report( + withheld: list, + state: dict, + include_delivered: bool, +) -> None: + """Rend compte du filtre de livraison : ecartes, non lus, non sondes. + + Extrait de ``main`` pour etre testable sans harnais complet : les trois + etats (ecarte / lecture en echec / non sonde) ont trois messages + DIFFERENTS et deux d'entre eux sont des fail-OPEN. Les confondre + reviendrait a lire un silence comme une couverture -- le defaut exact que + ce filtre corrige, deplace d'un cran. + """ + if include_delivered: + return + dropped = [it for it, cause in withheld if cause.startswith("LIVRAISON")] + if dropped: + numbers = ", ".join(f"#{it['number']}" for it in dropped) + print(f"Signal de livraison : {len(dropped)} candidat(s) " + f"ECARTE(S) de l'urne grain : {numbers}.") + print(" Label `" + DELIVERED_LABEL + "` ou commentaire `" + + DELIVERED_COMMENT_MARKER + "` -- le travail est deja livre ;") + print(" les re-servir comme grain ferait bruler un cycle a la lane " + "qui") + print(" les recoit. Urne `delivered` et `--include-delivered` " + "restent") + print(" les deux chemins pour les traiter.") + failed = sorted(set(state.get("failures") or [])) + if failed: + numbers = ", ".join(f"#{num}" for num in failed) + print(f"!! signal de livraison NON LU sur {numbers} " + f"({len(failed)} candidat(s)) : la lecture n'a pas pu etre faite") + print(" (reseau, 403, payload illisible). Le tirage est MAINTENU et") + print(" ces candidats sont CONSERVES -- une lecture qui n'a pas") + print(" ABOUTI n'est PAS une absence de signal. Verifier a la main") + print(" (`gh issue view --comments`) avant de produire dessus.") + if state.get("budget_hit"): + print(f"!! signal de livraison NON SONDE au-dela de " + f"{DELIVERED_SIGNAL_MAX_PROBES} candidats : le plafond de " + "sondes") + print(" est atteint, la fin de l'urne n'a pas ete verifiee. Les " + "candidats") + print(" non sondes sont CONSERVES (fail-open).") + if dropped or failed or state.get("budget_hit"): + print() + + +def print_empty_draw_notice(withheld: list, picks: list, + include_delivered: bool) -> None: + """Un tirage vide se DIT, et nomme le filtre quand il en est la cause.""" + if picks: + return + print("TIRAGE VIDE : aucun candidat retenu apres filtres et gardes.") + if not include_delivered and any( + cause.startswith("LIVRAISON") for _, cause in withheld): + print(" Une partie s'explique par le signal de livraison ci-dessus :") + print(" ces issues sont deja livrees, pas inaccessibles. Le vivier") + print(" de production est donc plus petit que le pool, et ce n'est") + print(" PAS une absence de travail pour la lane.") + print(" Ce n'est pas un refus de la lane : relancer avec --reroll, ou") + print(" elargir les filtres (`--urns`, bornes d'age/inactivite).") + print() + + def _csv_values(groups: list[str] | None) -> list[str]: """Flatten repeatable comma-separated CLI values, ignoring empty fields.""" return [ @@ -967,7 +1162,7 @@ def check_claims(numbers: list[int], lane: str) -> dict[int, str]: def draw_unclaimed(by_class, args, rng, visits, series, issue_to_family, - delivery=None): + delivery=None, delivered_probe=None, delivered_state=None): """Tire, puis REMPLACE tout candidat qu une autre lane tient deja. Deux raisons de remplacer plutot que d annoter : @@ -988,6 +1183,31 @@ def draw_unclaimed(by_class, args, rng, visits, series, issue_to_family, ("umbrella", args.umbrellas, args.prev_genre), ("delivered", args.delivered, None)) picks, claims, conflicts = [], {}, [] + # Portee du filtre de livraison : l'urne `grain` SEULE. L'urne + # `delivered` est ce qui remet ces issues aux lanes habilitees (#15069) + # -- la traverser la viderait de son sens. L'urne `umbrella` non plus : + # le canal label n'y marque jamais un EPIC par decision mesuree + # (candidate-delivered-advisory.yml : EPICs exclus, heuristique checkbox + # UNRELIABLE), et un « candidate-delivered partiel » sur un parapluie de + # tracking n'est pas un verdict de fermeture -- on y pioche ou on y cree + # un sous-grain (R5), on ne l'ecarte pas. `--include-delivered` reste + # l'echappatoire nommee. + include_delivered = bool(getattr(args, "include_delivered", False)) + state = (delivered_state if delivered_state is not None + else {"failures": [], "budget_hit": False}) + failures = state.setdefault("failures", []) + budget = [DELIVERED_SIGNAL_MAX_PROBES] + + def _counted_probe(number, lane_name): + # Decompte la requete REELLE, pas le test de label : le label est + # court-circuite avant d'arriver ici, et reste donc gratuit meme + # apres epuisement du plafond. + if budget[0] <= 0: + state["budget_hit"] = True + return DELIVERED_SIGNAL_UNPROBED + budget[0] -= 1 + return (delivered_probe or delivered_probe_inert)(number, lane_name) + for cls, want, prev in urnes: pool = list(by_class[cls]) got = [] @@ -1013,8 +1233,19 @@ def draw_unclaimed(by_class, args, rng, visits, series, issue_to_family, ". Une autre lane tient ce grain -- ecrire dessus " "produirait la collision, pas le livrable. Candidat " "remplace dans la meme urne."))) - else: - got.append(c) + continue + if cls == "grain" and not include_delivered: + # Le label est teste A COUT NUL et vaut meme quand le + # plafond de sondes est epuise ; seule la sonde de + # commentaire est plafonnee, et son epuisement est + # fail-OPEN (le candidat est conserve). + reason = delivered_signal_reason( + c, args.lane, _counted_probe, failures) + if reason is not None: + conflicts.append((c, "LIVRAISON : " + reason + ( + " Candidat remplace dans la meme urne."))) + continue + got.append(c) picks.extend(got) return picks, claims, conflicts @@ -2606,6 +2837,13 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--max-idle-days", type=int, default=None) ap.add_argument("--urns", default="grain,umbrella,delivered", help="urnes admises : grain,umbrella,delivered") + ap.add_argument("--include-delivered", dest="include_delivered", + action="store_true", + help="ne PAS ecarter de l'urne grain les issues " + "portant un signal de livraison (label " + "candidate-delivered ou commentaire [INFO] " + "candidate-delivered) -- echappatoire nommee, " + "typiquement pour une lane habilitee a fermer") ap.add_argument("--json", action="store_true", help="sortie machine") ap.add_argument("--orphans-report", action="store_true", help="mode rapport : PRs bloquees sans tag Grain lisible, groupees par " @@ -2660,6 +2898,17 @@ def main(argv: list[str] | None = None) -> int: effective_cache_mode = args.cache if "PYTEST_CURRENT_TEST" in os.environ and args.cache_dir is None: effective_cache_mode = "off" + # Sonde de livraison. Sous pytest, la sonde par defaut est INERTE : un + # test unitaire ne doit pas emettre une requete `gh` par candidat tire. + # Meme precedent d'environnement que le mode cache juste au-dessus ; un + # test qui veut le signal l'injecte explicitement. + delivered_probe = ( + delivered_probe_inert + if "PYTEST_CURRENT_TEST" in os.environ and args.cache_dir is None + else has_delivered_signal + ) + delivered_state: dict = {"failures": [], "budget_hit": False} + payload_cache = PayloadCache(args.cache_dir) cache_status: dict[str, dict[str, Any]] = {} @@ -2925,7 +3174,8 @@ def main(argv: list[str] | None = None) -> int: picks, claims, claim_conflicts = draw_unclaimed( by_class, args, rng, visits, series, issue_to_family, - delivery=delivery_weights if args.delivery_boost_max > 0 else None) + delivery=delivery_weights if args.delivery_boost_max > 0 else None, + delivered_probe=delivered_probe, delivered_state=delivered_state) withheld.extend(claim_conflicts) delivery = recent_delivery(picks) @@ -2958,6 +3208,12 @@ def main(argv: list[str] | None = None) -> int: "calibration_max": 0.5, }, "recent_delivery": {str(k): v for k, v in delivery.items()}, + "delivered_signal": { + "include_delivered": bool(args.include_delivered), + "probes_failed": sorted(set(delivered_state["failures"])), + "budget_hit": delivered_state["budget_hit"], + "max_probes": DELIVERED_SIGNAL_MAX_PROBES, + }, "red_backlog": backlog, "substance_drought": drought, "cache": cache_status, @@ -3062,7 +3318,8 @@ def main(argv: list[str] | None = None) -> int: elif withheld: dwell_n = sum(1 for _, c in withheld if c.startswith("DWELL")) claim_n = sum(1 for _, c in withheld if c.startswith("CLAIM")) - zone_n = len(withheld) - dwell_n - claim_n + delivered_n = sum(1 for _, c in withheld if c.startswith("LIVRAISON")) + zone_n = len(withheld) - dwell_n - claim_n - delivered_n # Les trois causes ne se rangent pas ensemble : dwell et zone # reviennent d'elles-memes, un grain tenu par une autre lane revient # quand CETTE lane le relache. Les fondre dans "zone sans remede" @@ -3071,6 +3328,8 @@ def main(argv: list[str] | None = None) -> int: f"{zone_n} en zone sans remede"] if claim_n: parts.append(f"{claim_n} tenue(s) par une autre lane") + if delivered_n: + parts.append(f"{delivered_n} deja LIVRE(s)") print(f"Retenues hors tirage : {len(withheld)} " f"({', '.join(parts)}). " "Dwell et zone reviennent d'elles-memes -- aucune n'est refusee " @@ -3078,6 +3337,10 @@ def main(argv: list[str] | None = None) -> int: if claim_n: print(" Un grain tenu revient quand sa lane pose [RELEASED], ou " "sur arbitrage [OVERRIDE] du coordinateur.") + if delivered_n: + print(" Un grain ecarte comme DEJA LIVRE ne revient pas par le") + print(" tirage : il reste servable par l'urne `delivered` a une") + print(" lane habilitee (#15069), ou par `--include-delivered`.") for it, cause in sorted(withheld, key=lambda kv: -kv[0]["number"])[:3]: print(f" #{it['number']:<7} {cause.split(chr(58))[0]:<18} {it['title'][:46]}") if backlog.get("triggers"): @@ -3127,6 +3390,9 @@ def main(argv: list[str] | None = None) -> int: print(pad + "-> " + quoi + " (renumeroter un numero eleve en " "lettre d'un numero existant, ou fondre plusieurs " "lettres en un petit nombre), pas une instance de plus.") + print_delivered_signal_report(withheld, delivered_state, + args.include_delivered) + print_empty_draw_notice(withheld, picks, args.include_delivered) print() print_unattributed_blocked(backlog) if visits_err: diff --git a/scripts/tests/test_pick_delivered_gate.py b/scripts/tests/test_pick_delivered_gate.py index 73ed114e8e..91d897aeff 100644 --- a/scripts/tests/test_pick_delivered_gate.py +++ b/scripts/tests/test_pick_delivered_gate.py @@ -24,10 +24,17 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from pick_idle_grain import ( # noqa: E402 + DELIVERED_COMMENT_MARKER, + DELIVERED_LABEL, + DELIVERED_SIGNAL_MAX_PROBES, DELIVERED_URN_LANES, URN_NAMES, apply_delivered_urn_gate, + delivered_signal_reason, delivered_urn_allowed, + draw_unclaimed, + has_delivered_signal, + print_delivered_signal_report, ) DEFAULT_URNS = "grain,umbrella,delivered" @@ -118,3 +125,317 @@ def test_filter_candidates_excludes_delivered_for_worker_urns(): kept, funnel = filter_candidates(items, urns=urns) assert [it["number"] for it in kept] == [1] assert funnel["by_urn"]["delivered"] == 0 + + +# --- Signal de livraison : le LABEL *ou* le COMMENTAIRE (#15809) ------------ +# +# Defaut mesure le 2026-09-12 : 18 grains dont le travail etait DEJA livre ont +# ete servis a une seule lane (myia-po-2023:CoursIA-2) en 2 cycles. La lane a +# du les refuter au lieu de produire, et restait a `R1 NOT HELD` pour la 9e +# fois. Le picker classait le LABEL `candidate-delivered` (klass `delivered`, +# urne reservee #15069) mais etait AVEUGLE au COMMENTAIRE +# `[INFO] candidate-delivered` -- le marqueur que le protocole demande +# pourtant nommement a une lane worker de poster en rendant la main. +# +# Les deux tests qui portent l'invariant vont par PAIRE : un controle negatif +# (le signale est ecarte) et un controle positif (le non-signale est +# conserve). Sans le second, un filtre qui exclut TOUT le monde passerait -- +# c'est exactement le piege que le fichier voisin (#15069) documente deja. + +import argparse # noqa: E402 +import json # noqa: E402 +import random # noqa: E402 + +import pick_idle_grain as pig # noqa: E402 + + +def _item(n, *, labels=(), klass="grain"): + """Candidat synthetique, forme minimale attendue par draw_unclaimed.""" + return {"number": n, "title": f"issue {n}", "labels": list(labels), + "klass": klass, "genre": "docs", "age": 30, "idle": 10, + "parent": None, "polarity": "neutral", + "created_at": "2026-07-01T00:00:00Z", "body": "", + "updated_at": "2026-08-01T00:00:00Z"} + + +def _args(**kw): + base = dict(grains=1, umbrellas=0, delivered=0, prev_genre=None, + lane="myia-po-2023:CoursIA-2", check_claims=False, + include_delivered=False) + base.update(kw) + return argparse.Namespace(**base) + + +def _by(grain=(), umbrella=(), delivered=()): + return {"grain": list(grain), "umbrella": list(umbrella), + "delivered": list(delivered)} + + +def _probe(*signalled): + """Sonde synthetique : True pour les numeros listes, False sinon.""" + def probe(number, lane=None): + return number in signalled + return probe + + +def _patch_draw(monkeypatch, order): + """Force l'ORDRE du tirage. + + Les tests portent sur le FILTRE, pas sur la ponderation : laisser le + tirage Efraimidis-Spirakis choisir rendrait chaque assertion dependante du + bruit d'echantillonnage. On fixe donc l'ordre, et le remplacement se + mesure sur la liste restante -- le pool retreci par draw_unclaimed suffit + a faire avancer la sequence. + """ + seq = list(order) + + def fake_draw(items, n, rng, prev_genre=None, visits=None, series=None, + issue_to_family=None, delivery=None): + by_num = {it["number"]: it for it in items} + out = [] + for num in seq: + if num in by_num and len(out) < n: + it = dict(by_num[num]) + it.pop("body", None) + it["weight"] = 1.0 + it.setdefault("visits", 0) + it.setdefault("family", None) + it.setdefault("family_new_notebooks", 0) + it.setdefault("polarity", "neutral") + out.append(it) + return out + + monkeypatch.setattr(pig, "draw", fake_draw) + + +# --- surface 1 : le LABEL, gratuit (deja dans le payload du pool) ---------- + +def test_le_label_ecarte_sans_un_seul_appel_reseau(): + """Le label vient de `gh issue list --json labels`, deja paye pour tout le + pool : l'ecarter doit couter ZERO appel supplementaire. Une sonde appelee + sur un candidat labellise signalerait une regression de cout.""" + calls = [] + + def probe(number, lane=None): + calls.append(number) + return False + + reason = delivered_signal_reason( + _item(1, labels=(DELIVERED_LABEL,)), "myia-po-2023:CoursIA-2", probe) + assert reason is not None + assert "label" in reason + assert calls == [], f"le label ne doit couter aucun appel, or {calls}" + + +def test_label_ecarte_le_grain_meme_si_klass_ne_l_a_pas_classe(monkeypatch): + """Defense en profondeur : le `klass` du pool route deja les labellisees + vers l'urne `delivered`, donc ce candidat ne devrait PAS se trouver dans + l'urne `grain`. Le garde est teste ici sur cet etat impossible-par- + construction, precisement pour qu'il survive a un changement du ternaire + `klass` -- un garde se valide par ce qu'il attrape, pas par ce qui arrive + aujourd'hui.""" + _patch_draw(monkeypatch, [1, 2]) + by = _by(grain=[_item(1, labels=(DELIVERED_LABEL,)), _item(2)]) + picks, _, conflicts = draw_unclaimed( + by, _args(grains=1), random.Random(7), None, None, None) + assert [p["number"] for p in picks] == [2] + assert len(conflicts) == 1 + assert conflicts[0][1].startswith("LIVRAISON") + + +# --- surface 2 : le COMMENTAIRE, une requete par candidat TIRE ------------- + +def test_umbrella_portant_le_marqueur_reste_tiree_controle_positif(monkeypatch): + """Controle POSITIF (review #15809) : l'urne `umbrella` n'est PAS filtree. + + Deux raisons, toutes deux mesurees : + - le canal label ne marque JAMAIS un EPIC -- decision ecrite dans + `.github/workflows/candidate-delivered-advisory.yml` (« EPICs are + excluded ... the checkbox heuristic suggested in #10466 was measured + firsthand and is UNRELIABLE »). Filtrer l'umbrella par la porte du + commentaire serait un comportement neuf sans precedent dans l'organe ; + - le marqueur de #12208, l'exhibit lui-meme, dit « candidate-delivered + PARTIEL » et conclut « L'EPIC reste vivante comme parapluie de + tracking ». Une lane n'y claime jamais l'EPIC entier, elle y pioche ou + y cree un sous-grain (proactive-coordination R5) : servir une umbrella + partiellement livree est le MODE D'EMPLOI de l'urne, pas un cycle + brule. Ce test est ce qui distingue « les umbrellas sont protegees » + de « le filtre ne mord nulle part ».""" + _patch_draw(monkeypatch, [200]) + by = _by(umbrella=[_item(200, klass="umbrella")]) + picks, _, conflicts = draw_unclaimed( + by, _args(grains=0, umbrellas=1), random.Random(7), None, None, + None, delivered_probe=_probe(200)) + assert [p["number"] for p in picks] == [200] + assert conflicts == [] + + +def test_commentaire_marqueur_ecarte_le_grain(monkeypatch): + _patch_draw(monkeypatch, [1, 2]) + by = _by(grain=[_item(1), _item(2)]) + picks, _, conflicts = draw_unclaimed( + by, _args(grains=1), random.Random(7), None, None, None, + delivered_probe=_probe(1)) + assert [p["number"] for p in picks] == [2] + assert len(conflicts) == 1 + assert DELIVERED_COMMENT_MARKER in conflicts[0][1] + + +def test_le_remplacant_est_bien_rendu_le_controle_positif_qui_compte(monkeypatch): + """Le meme point de doctrine que #13310 : exclure SANS remplacer + fabriquerait de l'idle. Le candidat signale est remplace DANS SA PROPRE + urne -- un test qui ne compterait que le retrait ne le verrait pas.""" + _patch_draw(monkeypatch, [1, 2]) + by = _by(grain=[_item(1), _item(2)]) + picks, _, conflicts = draw_unclaimed( + by, _args(grains=1), random.Random(7), None, None, None, + delivered_probe=_probe(1)) + assert len(picks) == 1, "un remplacant doit etre rendu, pas zero grain" + assert [p["number"] for p in picks] == [2] + + +def test_controle_positif_un_grain_sans_signal_est_conserve(monkeypatch): + """Sans ce cas, un filtre qui exclut TOUT serait vert sur le negatif.""" + _patch_draw(monkeypatch, [1]) + by = _by(grain=[_item(1)]) + picks, _, conflicts = draw_unclaimed( + by, _args(grains=1), random.Random(7), None, None, None, + delivered_probe=_probe()) + assert [p["number"] for p in picks] == [1] + assert conflicts == [] + + +# --- l'urne `delivered` ne doit PAS etre cassee (#15069) ------------------- + +@pytest.mark.parametrize("lane", sorted(DELIVERED_URN_LANES)) +def test_urne_delivered_toujours_servie_a_une_lane_habilitee(lane, monkeypatch): + """Controle positif de non-regression : l'urne `delivered` sert + precisement a remettre ces issues aux lanes habilitees. Un filtre qui la + traverserait la viderait de son sens -- et le modele est signale par la + sonde la plus agressive possible (elle dit True partout).""" + _patch_draw(monkeypatch, [300]) + by = _by(delivered=[_item(300, labels=(DELIVERED_LABEL,), + klass="delivered")]) + picks, _, conflicts = draw_unclaimed( + by, _args(lane=lane, grains=0, delivered=1), random.Random(7), + None, None, None, delivered_probe=_probe(300)) + assert [p["number"] for p in picks] == [300] + assert conflicts == [] + + +def test_une_lane_worker_ne_recoit_pas_l_urne_delivered_mais_le_grain_reste(monkeypatch): + """Symetrie : sous une lane worker, l'urne `delivered` est retiree en + amont (#15069) -- le filtre de livraison ne fait que rendre la meme + garantie au niveau du grain, il ne remplace pas le garde de lane.""" + _patch_draw(monkeypatch, [300]) + by = _by(delivered=[_item(300, klass="delivered")]) + picks, _, _ = draw_unclaimed( + by, _args(lane="myia-po-2023:CoursIA-2", grains=0, delivered=0), + random.Random(7), None, None, None, delivered_probe=_probe(300)) + assert picks == [] + + +# --- echappatoire nommee -------------------------------------------------- + +@pytest.mark.parametrize("labels,probe_signals", [ + ((DELIVERED_LABEL,), ()), + ((), (1,)), +]) +def test_include_delivered_rend_le_grain_signale(monkeypatch, labels, + probe_signals): + """--include-delivered : l'echappatoire de la lane habilitee qui veut + malgre tout tirer ces issues du vivier ordinaire. Les DEUX surfaces + doivent se desactiver ensemble.""" + _patch_draw(monkeypatch, [1]) + by = _by(grain=[_item(1, labels=labels)]) + picks, _, conflicts = draw_unclaimed( + by, _args(grains=1, include_delivered=True), random.Random(7), + None, None, None, delivered_probe=_probe(*probe_signals)) + assert [p["number"] for p in picks] == [1] + assert conflicts == [] + + +# --- fail-OPEN, et DIT (jamais un echec lu comme une absence) -------------- + +def test_lecture_en_echec_tirage_maintenu_et_avertissement_emis(monkeypatch, + capsys): + """Un 403 / un reseau mort n'est PAS << pas de signal >>. Le grain est + conserve (fail-open) ET le picker le dit -- le contraire exact du defaut + d'origine, qui confondait silence et couverture.""" + _patch_draw(monkeypatch, [1]) + state = {"failures": [], "budget_hit": False} + by = _by(grain=[_item(1)]) + + def failing_probe(number, lane=None): + return None + + picks, _, conflicts = draw_unclaimed( + by, _args(grains=1), random.Random(7), None, None, None, + delivered_probe=failing_probe, delivered_state=state) + assert [p["number"] for p in picks] == [1], "fail-open : le grain reste" + assert conflicts == [] + assert state["failures"] == [1] + + print_delivered_signal_report([], state, False) + out = capsys.readouterr().out + assert "NON LU" in out + assert "#1" in out + assert "MAINTENU" in out + assert "n'est PAS une absence de signal" in out + + +def test_plafond_de_sondes_fail_open_et_signale(monkeypatch, capsys): + """Le plafond borne le cout ; il ne doit pas se lire comme un echec de + lecture. Les candidats non sondes sont CONSERVES, et le message est + distinct de celui de l'echec de lecture.""" + monkeypatch.setattr(pig, "DELIVERED_SIGNAL_MAX_PROBES", 1) + _patch_draw(monkeypatch, [1, 2, 3]) + calls = [] + + def counting_probe(number, lane=None): + calls.append(number) + return False + + state = {"failures": [], "budget_hit": False} + by = _by(grain=[_item(1), _item(2), _item(3)]) + picks, _, _ = draw_unclaimed( + by, _args(grains=3), random.Random(7), None, None, None, + delivered_probe=counting_probe, delivered_state=state) + assert calls == [1], f"plafond de 1 : une seule sonde, or {calls}" + assert len(picks) == 3, "les non sondes sont CONSERVES (fail-open)" + assert state["budget_hit"] is True + assert state["failures"] == [], ( + "un candidat NON SONDE n'est pas un echec de lecture -- les confondre " + "ferait afficher une panne reseau sur une economie voulue") + + print_delivered_signal_report([], state, False) + out = capsys.readouterr().out + assert "NON SONDE" in out + assert "fail-open" in out + assert "NON LU" not in out + + +def test_has_delivered_signal_est_tri_etat(monkeypatch): + """La sonde elle-meme : True / False / None. C'est le seul endroit ou + l'echec de lecture est distingue du silence, et tout le fail-open en + depend.""" + class _Proc: + def __init__(self, payload): + self.stdout = payload + + marked = json.dumps({"comments": [ + {"body": "rien"}, {"body": DELIVERED_COMMENT_MARKER + " -- preuve"}]}) + monkeypatch.setattr(pig.subprocess, "run", + lambda *a, **kw: _Proc(marked)) + assert has_delivered_signal(1, "myia-po-2023:CoursIA-2") is True + + clean = json.dumps({"comments": [{"body": "aucun marqueur"}]}) + monkeypatch.setattr(pig.subprocess, "run", + lambda *a, **kw: _Proc(clean)) + assert has_delivered_signal(1) is False + + def boom(*a, **kw): + raise OSError("403") + + monkeypatch.setattr(pig.subprocess, "run", boom) + assert has_delivered_signal(1) is None