From f87e07d868d8c05490e5a3943f5d258f472525e6 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 12 Sep 2026 20:13:35 +0200 Subject: [PATCH 1/2] fix(picker): ecarter des urnes de production les issues deja livrees Le picker servait des grains dont le travail etait DEJA livre. La lane myia-po-2023:CoursIA-2 en a recu 18 en 2 cycles (escalade DM `msg-20260912T174921-p9mfe0`), a du les refuter au lieu de produire, et restait a `R1 NOT HELD` pour la 9e fois : le defaut lui coute ses cycles a chaque tirage. Cause : `fetch_pool` classait le LABEL `candidate-delivered` (klass `delivered`, urne reservee #15069) mais etait AVEUGLE au COMMENTAIRE `[INFO] candidate-delivered` -- le marqueur que le protocole demande nommement a une lane worker de poster en rendant la main. Mesure sur le pool ouvert du 2026-09-12 (339 issues) : 59 portent le label, 60 portent le marqueur en commentaire, et 54 ne portent QUE le commentaire. Le corpus reellement expose est donc majoritairement celui que le label ne voit pas. Le filtre s'insere dans la boucle de remplacement existante de `draw_unclaimed` (le meme point que le garde de claim) : un candidat signale est ecarte ET remplace dans sa propre urne, jamais retire sec. Deux surfaces, deux couts : le label est deja dans le payload du pool (zero appel reseau) et est teste en premier ; le commentaire coute une requete par issue et n'est sonde que sur les candidats TIRES (jamais un balayage du pool), sous un plafond dur de 16 sondes par tirage. Fail-OPEN et DIT : une lecture en echec (reseau, 403, payload illisible) conserve le candidat et l'annonce explicitement -- une lecture qui n'a pas abouti n'est jamais lue comme une absence de signal. Le plafond de sondes a son propre message, distinct de l'echec de lecture (sentinelle `DELIVERED_SIGNAL_UNPROBED`). L'urne `delivered` n'est PAS touchee : elle sert precisement a remettre ces issues aux lanes habilitees (#15069). `--include-delivered` est l'echappatoire nommee, et un tirage vide se dit au lieu de rendre un vide muet. See #13420 (EPIC du picker, provenance doctrinale). Tests : 31 dans test_pick_delivered_gate.py (label, commentaire, controle positif du non-signale, remplacement, urne `delivered` sous lane habilitee, `--include-delivered`, echec de lecture + avertissement, plafond + sentinelle, tri-etat de la sonde). Co-Authored-By: Claude Sonnet 5 --- scripts/pick_idle_grain.py | 262 ++++++++++++++++++- scripts/tests/test_pick_delivered_gate.py | 296 ++++++++++++++++++++++ 2 files changed, 553 insertions(+), 5 deletions(-) diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index de79b5f683..db40cdda6a 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -636,6 +636,191 @@ 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 des urnes de PRODUCTION, ou ``None``. + + L'urne `delivered` (#15069) n'appelle JAMAIS cette fonction : elle sert + precisement a remettre ces issues aux lanes habilitees, pour fermeture. + Ce qui est protege ici, ce sont les urnes `grain` et `umbrella`. + + 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) des urnes de production : {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 +1152,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 +1173,27 @@ 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 = [], {}, [] + # Le filtre de livraison ne touche QUE les urnes de production : l'urne + # `delivered` est ce qui remet ces issues aux lanes habilitees (#15069), + # la traverser la viderait de son sens. `--include-delivered` est + # l'echappatoire nommee de la lane habilitee qui veut malgre tout tirer + # ces issues du vivier ordinaire. + 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 +1219,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 != "delivered" 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 +2823,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 des urnes de production 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 +2884,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 +3160,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 +3194,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 +3304,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 +3314,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 +3323,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 +3376,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..e04dd5883f 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,292 @@ 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_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 From d1e5738d37c4567f8f79eea8f5be4a1b71eff52f Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 12 Sep 2026 20:22:10 +0200 Subject: [PATCH 2/2] fix(picker,#15809 review): le filtre de livraison ne porte que l'urne grain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review coordinateur (#15809, issuecomment-5647793615) : appliquer `delivered_signal_reason` a l'urne `umbrella` etait un comportement neuf sans precedent dans l'organe -- le canal label n'y marque JAMAIS un EPIC, par decision mesuree et ecrite (.github/workflows/candidate-delivered-advisory.yml : « EPICs are excluded ... the checkbox heuristic suggested in #10466 was measured firsthand and is UNRELIABLE »). L'exhibit est le candidat de preuve lui-meme : le marqueur de #12208 dit « candidate-delivered PARTIEL » et conclut « L'EPIC reste vivante comme parapluie de tracking », en demandant l'arbitrage a ai-01. Le filtre convertisait ce verdict nuance en exclusion binaire. Doctrinalement, une lane ne claime jamais un 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. L'ecarter retirerait une source de grains de CONTENU a une flotte mesuree a 15 % DEEP -- l'inverse de l'effet recherche. Geste minimal : la condition passe de `cls != "delivered"` a `cls == "grain"`, la portee est documentee dans le docstring et le commentaire de boucle, et le vocabulaire « urnes de production » devient « urne grain » (messages et aide CLI). Rien d'autre ne bouge. Test ajoute, adjacent au cas grain pour que la paire se lise : l'umbrella portant le marqueur EST TOUJOURS tiree (controle positif -- sans lui, un vert ne distingue pas « les umbrellas sont protegees » de « le filtre ne mord nulle part »). 32 passed dans test_pick_delivered_gate.py. Co-Authored-By: Claude Sonnet 5 --- scripts/pick_idle_grain.py | 40 +++++++++++++++-------- scripts/tests/test_pick_delivered_gate.py | 25 ++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index db40cdda6a..a516f6c5f5 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -715,11 +715,21 @@ def delivered_signal_reason( probe=None, failures: list[int] | None = None, ) -> str | None: - """Pourquoi ecarter ce candidat des urnes de PRODUCTION, ou ``None``. - - L'urne `delivered` (#15069) n'appelle JAMAIS cette fonction : elle sert - precisement a remettre ces issues aux lanes habilitees, pour fermeture. - Ce qui est protege ici, ce sont les urnes `grain` et `umbrella`. + """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 @@ -776,7 +786,7 @@ def print_delivered_signal_report( if dropped: numbers = ", ".join(f"#{it['number']}" for it in dropped) print(f"Signal de livraison : {len(dropped)} candidat(s) " - f"ECARTE(S) des urnes de production : {numbers}.") + 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 " @@ -1173,11 +1183,15 @@ 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 = [], {}, [] - # Le filtre de livraison ne touche QUE les urnes de production : l'urne - # `delivered` est ce qui remet ces issues aux lanes habilitees (#15069), - # la traverser la viderait de son sens. `--include-delivered` est - # l'echappatoire nommee de la lane habilitee qui veut malgre tout tirer - # ces issues du vivier ordinaire. + # 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}) @@ -1220,7 +1234,7 @@ def _counted_probe(number, lane_name): "produirait la collision, pas le livrable. Candidat " "remplace dans la meme urne."))) continue - if cls != "delivered" and not include_delivered: + 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 @@ -2825,7 +2839,7 @@ def main(argv: list[str] | None = None) -> int: help="urnes admises : grain,umbrella,delivered") ap.add_argument("--include-delivered", dest="include_delivered", action="store_true", - help="ne PAS ecarter des urnes de production les issues " + 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, " diff --git a/scripts/tests/test_pick_delivered_gate.py b/scripts/tests/test_pick_delivered_gate.py index e04dd5883f..91d897aeff 100644 --- a/scripts/tests/test_pick_delivered_gate.py +++ b/scripts/tests/test_pick_delivered_gate.py @@ -245,6 +245,31 @@ def test_label_ecarte_le_grain_meme_si_klass_ne_l_a_pas_classe(monkeypatch): # --- 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)])