diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index edbe503c38..968c1a12c5 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -1257,11 +1257,110 @@ def delivered_probe_inert(issue_number: int, lane: str | None = None) -> bool: return False +# --- Troisieme surface de livraison : PR MERGEE citant l'issue (#19907) ------ +# +# Mesure du 2026-10-08 (cycles c.1450..c.1453, Tell c.1392 picker-delivered +# gap confirme 9x) : 24-28 cycles successifs sans grain actionnable. Le label +# `candidate-delivered` est pose par un workflow quotidien 05:49Z et le +# marqueur `[INFO] candidate-delivered` est poste par les lanes worker quand +# elles refutent un candidat. Mais l'urne `grain` reapparait regulierement des +# LIVREURS dont le label a ete retracte par le sweep post-merge, et dont +# aucune lane n'a encore refute (donc pas de marqueur). Cas fondateur : #16031 +# (perf life_compose, PR #17347 MERGED 09/22, label retracte 09/24 par +# l'advisory post-merge, tapis narrow-cache l'a servi 28 cycles de suite). +# +# Le sweep post-merge ne s'arrete pas a 7 jours : il retracte le label sur +# TOUTE activite de commentaire post-merge, sans limite de temps (#15744). Et +# l'urne `delivered` (#15069) ne s'applique qu'aux LIVREURS portes par un +# label ou un marqueur -- les LIVREURS anciens dont le label a ete retracte +# retombent dans `grain` sans aucun signal. La PR mergee est la SEULE +# surface de verification qui survit au sweep. +# +# Cout : 1 requete `gh pr list --state merged --search "N in:title,body"` +# par candidat tire, partagee avec les autres sondes du meme plafond +# (`DELIVERED_SIGNAL_MAX_PROBES`). Borne de 90 jours : au-dela, un merge +# de cette envergure a deja ete documente ailleurs (release notes, ledger, +# ou ferme par `git log --grep=#N`). 90 = compromis entre couverture des +# livraisons recentes et exclusion des PRs historiques (avant #19907, la +# mesure c.1450 a releve 4 LIVREURS dans les 90 derniers jours, 0 au-dela +# sur le meme echantillon). +MERGED_PR_WINDOW_DAYS = 90 + + +def merged_pr_probe_inert(issue_number: int, lane: str | None = None): + """Sonde inerte : aucun signal, aucun appel reseau. + + Meme doctrine que ``delivered_probe_inert`` : un test unitaire ne doit + pas emettre de requete par candidat tire. La signature retourne + ``None`` (TRI-ETAT, voie fail-OPEN : pas de signal mesurable) plutot + que ``False`` parce qu'un verdict True/False signifierait « il y a un + signal, il vaut X » -- ce qu'un test inerte ne peut pas affirmer sans + avoir sonde. Le retour ``None`` laisse la voie du tapis intacte. + """ + return None + + +def merged_pr_signal(issue_number: int, lane: str | None = None): + """Une PR MERGEE citant `#N` est-elle dans la fenetre de 90 jours ? + + TRI-ETAT, meme doctrine que ``has_delivered_signal`` et + ``open_cover_signal`` : + - ``True`` : au moins une PR MERGED cite `#N` et `mergedAt` est dans + les 90 derniers jours (jour UTC) ; + - ``False`` : aucune PR MERGED ne cite `#N`, OU toutes les PRs + MERGEes qui la citent datent de plus de 90 jours ; + - ``None`` : la requete a echoue (reseau, 403, payload illisible) -- + l'appelant doit tirer quand meme EN LE DISANT. + + Le `--search "N in:title,body"` est un filtre serveur (GitHub ne peut + pas tenir la borne de 90 jours cote serveur) ; le post-filtre temporel + se fait ici, sur le payload limite a 20 resultats. Meme doctrine + d'ancre `#N\\b` que ``open_cover_signal`` (post-filtre, #17760) pour + eviter les collisions de sous-chaine (ex. #11703 vs #1170391). + """ + try: + out = subprocess.run( + ["gh", "pr", "list", "--repo", REPO, "--state", "merged", + "--limit", "20", "--search", f"{issue_number} in:title,body", + "--json", "number,state,mergedAt,title,body"], + capture_output=True, text=True, encoding="utf-8", check=True, + timeout=30, + ).stdout + prs = json.loads(out) + except Exception: # noqa: BLE001 - sonde best-effort ; l'echec est DIT + return None + if not isinstance(prs, list): + return None + anchor = re.compile(rf"#{issue_number}\b") + now = dt.datetime.now(dt.timezone.utc) + window = dt.timedelta(days=MERGED_PR_WINDOW_DAYS) + for pr in prs: + if pr.get("state") != "MERGED": + continue + if not anchor.search((pr.get("title") or "") + "\n" + + (pr.get("body") or "")): + continue + merged_at = pr.get("mergedAt") + if not merged_at: + continue + try: + # GitHub renvoie des timestamps ISO-8601 en Z ; fromisoformat + # les accepte depuis Python 3.11 (la toolchain workers est 3.11+ + # par regle F, et 3.13 sur po-2026 ML-Training-Pipeline). + merged_dt = dt.datetime.fromisoformat(merged_at.replace("Z", "+00:00")) + except ValueError: + continue + if (now - merged_dt) <= window: + return True + return False + + def delivered_signal_reason( item: dict, lane: str | None = None, probe=None, failures: list[int] | None = None, + merged_pr_probe=None, ) -> str | None: """Pourquoi ecarter ce candidat de l'urne `grain`, ou ``None``. @@ -1280,12 +1379,17 @@ def delivered_signal_reason( 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. + commentaire n'est atteinte que s'il est absent ; la sonde PR-mergee + (#19907) n'est atteinte qu'en dernier, parce qu'elle est la plus + couteuse (meme `gh pr list` que ``open_cover_signal`` mais avec un + post-filtre temporel). 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 + if merged_pr_probe is None: + merged_pr_probe = merged_pr_probe_inert labels = {str(label).casefold() for label in item.get("labels") or []} if DELIVERED_LABEL in labels: return ( @@ -1311,6 +1415,29 @@ def delivered_signal_reason( "refutant. La re-servir comme grain de production fait bruler un " "cycle a la lane qui la recoit." ) + # Troisieme surface : PR MERGEE <90j citant `#N` (#19907). Couvre les + # LIVREURS dont le label a ete retracte par le sweep post-merge et dont + # aucune lane n'a encore refute (donc pas de marqueur). Cas fondateur + # c.1450 : #16031 (perf life_compose), label retracte 09/24, servi 28 + # cycles de suite par le tapis narrow-cache. La sonde est partagee + # avec `open_cover_reason` via le meme `_counted_probe` parent (meme + # plafond `DELIVERED_SIGNAL_MAX_PROBES`). + merged = merged_pr_probe(item["number"], lane) + if merged == DELIVERED_SIGNAL_UNPROBED: + return None + if merged is None: + if failures is not None: + failures.append(item["number"]) + return None + if merged: + return ( + "SIGNAL LIVRAISON (PR MERGEE <" + str(MERGED_PR_WINDOW_DAYS) + + "j) : une PR MERGEE recente cite cette issue, la livraison est " + "faite par cette PR. Le sweep post-merge a retracte le label " + "`candidate-delivered` (#15744) et aucune lane n'a encore " + "refute (donc pas de marqueur en commentaire), mais la " + "livraison est reelle. Cf #19907, cas fondateur c.1450 #16031." + ) return None @@ -2018,7 +2145,8 @@ def check_claims(numbers: list[int], lane: str) -> dict[int, tuple[str, str]]: def draw_unclaimed(by_class, args, rng, visits, series, issue_to_family, delivery=None, delivered_probe=None, delivered_state=None, fallback_by_class=None, continuity_state=None, - cover_probe=None, long_visits=None): + cover_probe=None, long_visits=None, + merged_pr_probe=None): """Tire, puis REMPLACE tout candidat qu une autre lane tient deja. Trois raisons de remplacer plutot que d annoter : @@ -2081,6 +2209,22 @@ def _counted_cover_probe(number): budget[0] -= 1 return (cover_probe or open_cover_inert)(number) + def _counted_merged_pr_probe(number, lane_name): + # Troisieme surface de livraison (#19907) : PR MERGEE <90j citant + # l'issue. Partage le MEME budget que les deux autres sondes + # (label/marqueur + PR couvrante) : un candidat peut consommer + # jusqu'a 3 unites de budget (commentaire + PR couvrante + PR + # mergée recente). C'est le prix de la couverture des LIVREURS + # dont le label a ete retracte par le sweep post-merge (#15744) + # et dont aucune lane n'a refute (donc pas de marqueur en + # commentaire) -- le cas fondateur c.1450 #16031 servi 28 + # cycles de suite par le tapis narrow-cache faute de cette sonde. + if budget[0] <= 0: + state["budget_hit"] = True + return DELIVERED_SIGNAL_UNPROBED + budget[0] -= 1 + return (merged_pr_probe or merged_pr_probe_inert)(number, lane_name) + for cls, want, prev in urnes: primary = list(by_class[cls]) primary_numbers = {item["number"] for item in primary} @@ -2163,7 +2307,8 @@ def _counted_cover_probe(number): # commentaire est plafonnee, et son epuisement est # fail-OPEN (le candidat est conserve). reason = delivered_signal_reason( - c, args.lane, _counted_probe, failures) + c, args.lane, _counted_probe, failures, + _counted_merged_pr_probe) if reason is not None: conflicts.append((c, "LIVRAISON : " + reason + ( " Candidat remplace dans la meme urne."))) @@ -5358,7 +5503,8 @@ def belt_probe_budget(belt_check_window: int) -> int: def belt_pick_with_replacements(belt_pool, belt_claims, args, probe_budget, - delivered_probe=None, claims_probe=None): + delivered_probe=None, claims_probe=None, + merged_pr_probe=None): """La boucle de service du tapis : claims, livraison, remplacement. Extraite de ``main`` (#19390) pour etre testable sans harnais complet : @@ -5373,9 +5519,16 @@ def belt_pick_with_replacements(belt_pool, belt_claims, args, probe_budget, ``belt_probe_budget(belt_check_window)`` -- la valeur doit couvrir la tete que la boucle peut servir, sinon les candidats au-dela sont servis sans lecture (#19969). Epuisement = fail-OPEN (``DELIVERED_SIGNAL_UNPROBED``), - rapporte dans l'etat de retour. ``delivered_probe`` (tests) remplace la - sonde reseau ; ``claims_probe`` (tests) remplace la verification au fil - de l'eau des items hors fenetre. + rapporte dans l'etat de retour. ``delivered_probe`` et ``merged_pr_probe`` + (tests) remplacent les sondes reseau ; ``claims_probe`` (tests) remplace + la verification au fil de l'eau des items hors fenetre. + + 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 + explicitement. Le defaut precedent (sonde reseau) etait un piege : un + test neuf qui oubliait l'injection pendait sur un vrai ``gh`` (mesure + 2026-10-09 : 99 s pour un seul test, cf. coordinateur #19913). Rend ``(picks, withheld, etat)`` avec ``etat = {"failures": [numeros illisibles], "budget_hit": bool}``. @@ -5386,7 +5539,8 @@ def belt_pick_with_replacements(belt_pool, belt_claims, args, probe_budget, # Le budget enrobe TOUTE sonde, injectee ou reelle : un test qui fournit # sa sonde doit voir le plafond s'appliquer aussi -- sinon la borne de # cout ne serait testable qu'avec le reseau reel. - user_probe = delivered_probe or has_delivered_signal + user_probe = delivered_probe or delivered_probe_inert + user_merged_pr_probe = merged_pr_probe or merged_pr_probe_inert def counted_probe(number, lane_name): if budget[0] <= 0: @@ -5395,6 +5549,13 @@ def counted_probe(number, lane_name): budget[0] -= 1 return user_probe(number, lane_name) + def counted_merged_pr_probe(number, lane_name): + if budget[0] <= 0: + state["budget_hit"] = True + return DELIVERED_SIGNAL_UNPROBED + budget[0] -= 1 + return user_merged_pr_probe(number, lane_name) + if claims_probe is None: def claims_probe(numbers): return check_claims(numbers, args.lane) @@ -5438,7 +5599,8 @@ def claims_probe(numbers): # mesuree du canal label). if it.get("klass") == "grain" and not args.include_delivered: reason = delivered_signal_reason( - it, args.lane, counted_probe, state["failures"]) + it, args.lane, counted_probe, state["failures"], + counted_merged_pr_probe) if reason is not None: belt_withheld.append( (it, "LIVRAISON : " + reason @@ -6038,10 +6200,15 @@ def main(argv: list[str] | None = None) -> int: # Boucle de service extraite (#19390) : claims + sonde de livraison # bornee a la fenetre de tete, remplacement dans la meme urne. # Epuisement du budget de sondes = fail-OPEN, rapporte en banniere - # et en JSON (cf belt_pick_with_replacements). + # et en JSON (cf belt_pick_with_replacements). Les sondes reelles + # sont injectees ICI explicitement : les defauts de la boucle sont + # inertes (#19913) pour que les tests ne sortent jamais sur le + # reseau par oubli. belt_picks, belt_withheld, belt_pick_state = ( belt_pick_with_replacements(belt_pool, belt_claims, args, - belt_probe_budget(belt_check_window))) + belt_probe_budget(belt_check_window), + delivered_probe=has_delivered_signal, + merged_pr_probe=merged_pr_signal)) belt_delivered_failures = belt_pick_state["failures"] belt_probe_budget_hit = [belt_pick_state["budget_hit"]] # Banniere legere : le tapis ne refuse jamais, mais rappelle diff --git a/scripts/tests/test_pick_delivered_gate.py b/scripts/tests/test_pick_delivered_gate.py index 6286d1a14c..76cf169e60 100644 --- a/scripts/tests/test_pick_delivered_gate.py +++ b/scripts/tests/test_pick_delivered_gate.py @@ -542,20 +542,26 @@ def failing(number): def test_plafond_partage_entre_sondes_livraison_et_couverture(monkeypatch): - """« Au meme rang » (#16589) = AUSSI au meme cout : les deux sondes + """« Au meme rang » (#16589) = AUSSI au meme cout : les 3 sondes parent le MEME budget de DELIVERED_SIGNAL_MAX_PROBES, pas un plafond - double. Avec un plafond de 2 et deux candidats, exactement deux sondes - reelles partent (livraison du 1er, couverture du 1er) -- le 2e candidat - n'en a plus aucune.""" - monkeypatch.setattr(pig, "DELIVERED_SIGNAL_MAX_PROBES", 2) + double. Avec un plafond de 4 et deux candidats, exactement quatre sondes + reelles partent (livraison 1er, merged_pr 1er, couverture 1er, + livraison 2e) -- le 2e candidat n'a plus que la livraison, et la + couverture n'est pas appelee une 2e fois.""" + monkeypatch.setattr(pig, "DELIVERED_SIGNAL_MAX_PROBES", 4) _patch_draw(monkeypatch, [1, 2]) delivered_calls = [] + merged_pr_calls = [] cover_calls = [] def counting_delivered(number, lane=None): delivered_calls.append(number) return False + def counting_merged_pr(number, lane=None): + merged_pr_calls.append(number) + return False + def counting_cover(number): cover_calls.append(number) return "" @@ -565,11 +571,15 @@ def counting_cover(number): picks, _, _ = draw_unclaimed( by, _args(grains=2), random.Random(7), None, None, None, delivered_probe=counting_delivered, - cover_probe=counting_cover, delivered_state=state) - assert delivered_calls == [1], ( - f"plafond partage : une seule sonde livraison, or {delivered_calls}") + cover_probe=counting_cover, + merged_pr_probe=counting_merged_pr, + delivered_state=state) + assert delivered_calls == [1, 2], ( + f"plafond partage : une sonde livraison par candidat survivant, or {delivered_calls}") + assert merged_pr_calls == [1], ( + f"plafond partage : une seule sonde merged_pr (1er candidat), or {merged_pr_calls}") assert cover_calls == [1], ( - f"plafond partage : une seule sonde couverture, or {cover_calls}") + f"plafond partage : une seule sonde couverture (1er candidat), or {cover_calls}") assert state["budget_hit"] is True assert len(picks) == 2, "les non sondes sont CONSERVES (fail-open)" diff --git a/scripts/tests/test_pick_idle_grain.py b/scripts/tests/test_pick_idle_grain.py index 7300ec6c76..f5a26c3cee 100644 --- a/scripts/tests/test_pick_idle_grain.py +++ b/scripts/tests/test_pick_idle_grain.py @@ -3862,6 +3862,7 @@ def _measure(items, *args, **kwargs): + # #19768 : le tapis a servi 4 candidats sur 4 non prenables (#7742, #16643, # #16372, #14549) le 2026-10-07, parce que le plafond de la sonde pleine # (16 unites, partage avec la sonde de PR couvrante) etait deja epuise. @@ -4078,3 +4079,177 @@ class _A: assert picks == [] assert len(conflicts) == 1 assert state["recent_filtered"] == 0 + +# --- Geste 4 #19907 : 3e surface de livraison (PR MERGEE <90j citant #N) ----- +# +# Cas fondateur (2026-10-08, cycles c.1450..c.1453, Tell c.1392 picker-delivered +# gap confirme 9x) : 24-28 cycles successifs sans grain actionnable. Le label +# `candidate-delivered` est retracte par le sweep post-merge au-dela de +# l'activite recente (#15744), et le marqueur `[INFO] candidate-delivered` +# n'est poste que par les lanes qui refutent. Restait la PR MERGEE comme +# seule surface de verification qui survit au sweep, mais le tapis ne la +# sondait pas -> LIVREURS anciens servis 28 cycles de suite (cas fondateur +# #16031 perf life_compose, label retracte 09/24, servi c.1450..c.1453 sans +# qu'aucune lane ne refute pour poser un marqueur). +# +# Les 4 tests ci-dessous couvrent l'acceptance de #19907 : +# 1. PR MERGEE <90j -> ecarte du grain (controle positif) +# 2. PR MERGEE >90j -> conserve (la fenetre de 90j borne la portee) +# 3. Pas de PR MERGEE -> conserve (regression preservee) +# 4. PR MERGEE recente isolee (sans label ni marqueur) -> ecarte +# (couvre la nouvelle surface en isolation des 2 premieres) + + +def _merged_pr_payload(prs): + """Helper : forge le payload gh pr list --state merged --json.""" + return prs + + +def _iso_days_ago(days, hours=0): + """Timestamp ISO-8601 Z, aujourd'hui - `days` jours UTC.""" + import datetime as _dt + moment = _dt.datetime.now(_dt.timezone.utc) - _dt.timedelta( + days=days, hours=hours) + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def test_merged_pr_recent_90d_excludes_from_grain(monkeypatch): + """#19907 controle positif : PR MERGEE il y a 30j citant #N -> ecarte. + + Le candidat n'a ni label `candidate-delivered` ni marqueur en + commentaire : seule la PR MERGEE le designe comme livre. Le sweep + post-merge a retracte le label (cf #15744) il y a 30 jours, et + aucune lane n'a encore refute (donc pas de marqueur en commentaire). + Le tapis narrow-cache l'aurait servi 28 cycles de suite -- le 3e + predicat corrige cet angle mort. + + Le test injecte un `merged_pr_probe` qui reproduit le verdict de + `merged_pr_signal` pour le candidat teste : PR #17347 MERGED il y a + 30 jours cite #16031. La sonde reelle (`merged_pr_signal`) n'est PAS + appelee -- on ne teste que le contrat de `delivered_signal_reason`. + """ + def fake_merged_pr_probe(number, lane): + if number == 16031: + return True # PR MERGEE <90j trouvee + return False + monkeypatch.setattr(pig.subprocess, "run", lambda *a, **kw: _FakeCompleted("[]")) + + item = {"number": 16031, "klass": "grain", "labels": []} + reason = pig.delivered_signal_reason( + item, "myia-po-2026:CoursIA-2", + merged_pr_probe=fake_merged_pr_probe) + assert reason is not None, "PR MERGEE <90j doit produire un signal" + assert "PR MERGEE" in reason + assert "90" in reason # borne visible dans le diagnostic + + +def test_merged_pr_over_90d_keeps_in_grain(monkeypatch): + """#19907 controle de fenetre : PR MERGEE il y a 100j -> conserve. + + La borne de 90 jours borne la portee du 3e predicat. Au-dela, la + livraison est documentee ailleurs (release notes, ledger, ou + trouvee par `git log --grep=#N` en GREP direct). C'est un compromis + entre couverture des livraisons recentes et exclusion des PRs + historiques (avant #19907, la mesure c.1450 a releve 4 LIVREURS + dans les 90 derniers jours, 0 au-dela sur le meme echantillon). + + Verifie la sonde reelle `merged_pr_signal` (pas un mock) : forge + un payload avec une PR MERGEE il y a 100 jours, verifie que la + sonde rend `False` (pas de signal). + """ + monkeypatch.setattr(pig.subprocess, "run", lambda *a, **kw: _FakeCompleted( + json.dumps([ + {"number": 9999, "state": "MERGED", + "mergedAt": _iso_days_ago(100), + "title": "old fix for #16031", + "body": "historical merge"}, + ]))) + + signal = pig.merged_pr_signal(16031) + assert signal is False, ( + f"PR MERGEE >90j doit rendre False, recu : {signal!r}" + ) + + +def test_no_merged_pr_keeps_in_grain(monkeypatch): + """#19907 regression preservee : pas de PR MERGEE -> conserve. + + Le candidat n'a ni label, ni marqueur, ni PR MERGEE recente -> c'est + un grain standard, le tapis le sert normalement. La 3e surface de + livraison ne change rien au cas standard. + + Verifie la sonde reelle `merged_pr_signal` : payload vide -> False. + """ + monkeypatch.setattr(pig.subprocess, "run", + lambda *a, **kw: _FakeCompleted(json.dumps([]))) + + signal = pig.merged_pr_signal(12345) + assert signal is False, ( + f"pas de PR MERGEE doit rendre False, recu : {signal!r}" + ) + + +def test_merged_pr_isolated_no_label_no_marker(monkeypatch): + """#19907 isolation : PR MERGEE <90j isolee (sans label/marqueur) -> ecarte. + + Couvre la nouvelle surface en isolation des 2 premieres. Le candidat + n'a ni le label `candidate-delivered` (retracte par sweep post-merge + #15744) ni de marqueur `[INFO] candidate-delivered` en commentaire + (aucune lane n'a refute). Seule la PR MERGEE le designe comme + livre -- c'est exactement le cas fondateur c.1450 #16031. + """ + def fake_merged_pr_probe(number, lane): + if number == 16031: + return True # PR MERGEE <90j isolee + return False + monkeypatch.setattr(pig.subprocess, "run", lambda *a, **kw: _FakeCompleted( + json.dumps({"comments": [ + {"body": "Discussion ordinaire sur le scope, pas un marqueur."}, + {"body": "Reponse d'une lane sur un point technique."}, + ]}))) + + item = {"number": 16031, "klass": "grain", "labels": []} + reason = pig.delivered_signal_reason( + item, "myia-po-2026:CoursIA-2", + merged_pr_probe=fake_merged_pr_probe) + assert reason is not None, ( + "PR MERGEE isolee <90j doit produire un signal meme sans " + "label/marqueur (c'est precisement le cas fondateur c.1450)" + ) + # Le diagnostic mentionne `candidate-delivered` dans son explication + # du sweep post-merge, mais le PREFIXE du verdict doit etre specifique + # a la 3e surface (PR MERGEE) -- c'est ce qui distingue le verdict + # d'un signal label/marqueur homonyme. + assert reason.startswith("SIGNAL LIVRAISON (PR MERGEE <90j)") + # Et la JUXTAPOSITION du prefixe de verdict (label/marqueur) doit etre + # ABSENTE : pas de "SIGNAL LIVRAISON (label `candidate-delivered`)" + # ni de "SIGNAL LIVRAISON (commentaire `[INFO] candidate-delivered`)". + assert "SIGNAL LIVRAISON (label" not in reason + assert "SIGNAL LIVRAISON (commentaire" not in reason + + +def test_merged_pr_signal_anchor_substring_no_false_positive(monkeypatch): + """#19907 post-filtre d'ancre `#N\\b` : pas de collision de sous-chaine. + + Verifie la sonde reelle `merged_pr_signal` : PR #1170391 (sous-chaine + de #11703) ne doit PAS etre consideree comme couvrant #11703. + Cf #17760, arbitrage ai-01 2026-09-25 : la recherche GitHub matche un + NOMBRE NU en sous-chaine, et le post-filtre `#N\\b` est necessaire. + """ + monkeypatch.setattr(pig.subprocess, "run", lambda *a, **kw: _FakeCompleted( + json.dumps([ + # PR #1170391 -> body contient UNIQUEMENT "#1170391" (pas de + # "#11703" separe), donc l'ancre #11703\b n'est pas presente. + {"number": 1170391, "state": "MERGED", + "mergedAt": _iso_days_ago(10), + "title": "unrelated", + "body": "fixes #1170391 only"}, + ]))) + + signal = pig.merged_pr_signal(11703) + assert signal is False, ( + f"PR #1170391 ne doit PAS couvrir #11703 (collision sous-chaine), " + f"recu : {signal!r}" + ) + + diff --git a/scripts/tests/test_pick_idle_grain_belt.py b/scripts/tests/test_pick_idle_grain_belt.py index abeb19bcaf..c3689b8360 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -14,16 +14,66 @@ """ import json +import re import sys from datetime import datetime, timezone, timedelta 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`, pas d'`Exception` : le picker attrape + large (`except Exception` / `OSError`) sur ses chemins de fetch pour + rendre un fail-OPEN. Un garde qui deriverait d'`Exception` serait + avale par ces `except` -- exactement le silence qu'il doit casser. + """ + + +@pytest.fixture(autouse=True) +def _no_real_gh(monkeypatch, request): + """#19913 : aucun test de ce fichier n'emet un appel `gh`. + + Le piege mesure (coordinateur, 2026-10-09) : sur une fusion avec main, + la suite de ce fichier pendait par tests de 30 s a plusieurs minutes. + Cause : `main --belt` touche des surfaces que la fixture + `_patch_belt_network` n'enumerait pas -- `fetch_series_visits` -> + `gh_payload_cache` -> `run_gh` -> `subprocess.run`, bloque sur un + `communicate` reel. Enumerer les surfaces une par une est une course + perdue : chaque fonction reseau ajoutee rouvre le trou. + + Ce garde coupe au SEUL point de passage commun : `subprocess.run` + avec `gh` dans l'argv. Il leve en millisecondes, bruyamment, avec la + ligne fautive -- la ou un oubli de fixture produisait un hang muet. + Les autres subprocess (git...) passent inchangees. Un test qui veut + vraiment `gh` se marque `@pytest.mark.real_gh` et pose son propre + desarmement ; aucun test de ce fichier n'en a besoin. + """ + 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] + + " -- stuber la surface (voir _patch_belt_network) ou " + "marquer le test @pytest.mark.real_gh") + return real_run(argv, *args, **kwargs) + + monkeypatch.setattr(pig.subprocess, "run", guarded_run) + + def _make_item(number, age_days, idle, klass="grain", last=None, created=None, genre="docs"): return { @@ -544,6 +594,33 @@ def _patch_belt_network(monkeypatch, prs, red_state): for n in nums}) # Le tapis lit aussi les claims comme visites : jamais de reseau en test. monkeypatch.setattr(pig, "latest_claim_stamp", lambda n: None) + # `fetch_visits` (visits + long_visits, l.6024/6029 de main) etait le + # trou de la fixture : non patchee, `main --belt --json` sortait sur un + # vrai `gh` et le test pendait 99 s (mesure 2026-10-09, coordinateur + # #19913 : la suite bloquait sur un subprocess.communicate reel). + # Un dictionnaire VIDE n'est pas une visite manquante : le tapis traite + # l'absence comme "jamais servie" (la plus ancienne en tete), ce qui + # est exactement l'etat que le pool vide implique deja. + monkeypatch.setattr(pig, "fetch_visits", lambda *a, **k: ({}, None)) + # `fetch_series_visits` (l.6044 de main) : 2e trou de la meme famille, + # trouve par le garde `_no_real_gh` (stack : series_saturation -> + # gh_payload_cache -> run_gh -> subprocess.run bloque en communicate). + # Rend `(zones, issue_to_family, erreur)` ; vide + erreur None = mesure + # faite, aucune zone -- un test de saturation injecte la sienne. + monkeypatch.setattr(pig, "fetch_series_visits", + lambda **k: ({}, {}, None)) + # `fetch_merged` (l.6066 de main, importe de series_saturation) : le + # corpus de livraisons reelles. Meme famille que fetch_series_visits -- + # passe par gh_payload_cache -> run_gh. `measure_delivery`, en aval, + # est pure : elle ne fait que lire ce corpus. Forme `(prs, err)`. + monkeypatch.setattr(pig, "fetch_merged", lambda *a, **k: ([], None)) + # `pin_gh_token()` (gh_identity, appele au demarrage de main) : le + # VRAI hang des tests `main --belt` (mesure 2026-10-09 : ~300 s par + # test, 5 tests). Le garde `_no_real_gh` l'a nomme en 0,8 s -- un + # `gh auth token --user myia-po-2026` sans jeton machine repond apres + # un aller-retour d'auth lent. C'est de l'installation d'environnement, + # pas le comportement sous test : on le neutralise. + monkeypatch.setattr(pig.gh_identity, "pin_gh_token", lambda *a, **k: None) def test_belt_json_emits_single_document_when_red_present(monkeypatch, capsys): @@ -605,6 +682,87 @@ def test_belt_json_repair_key_absent_when_no_red(monkeypatch, capsys): assert payload["last_delivery_window_days"] == 90 +class _RealProbeReached(AssertionError): + """Un test a atteint une sonde reseau par defaut -- le piege #19913.""" + + +def test_belt_loop_default_probes_are_inert(monkeypatch): + """#19913 : sans sonde injectee, la boucle n'atteint JAMAIS le reseau. + + Les defauts de ``belt_pick_with_replacements`` sont les sondes inertes + (doctrine ``delivered_probe_inert`` / ``merged_pr_probe_inert``). Avant + le fix, le defaut etait ``has_delivered_signal`` / ``merged_pr_signal`` + -- un test qui oubliait d'injecter sa sonde sortait sur un vrai ``gh`` + et pendait (mesure 2026-10-09 : 99 s pour un seul test). Ce garde + casse au premier retour d'un defaut reseau : les vraies sondes sont + remplacees par des leveuses d'exception, la boucle doit completer. + """ + def raise_delivered(n, lane=None): + raise _RealProbeReached(f"has_delivered_signal atteinte pour #{n}") + + def raise_merged(n, lane=None): + raise _RealProbeReached(f"merged_pr_signal atteinte pour #{n}") + + monkeypatch.setattr(pig, "has_delivered_signal", raise_delivered) + monkeypatch.setattr(pig, "merged_pr_signal", raise_merged) + + pool = [_make_item(19001, age_days=50, idle=5, last=None), + _make_item(19002, age_days=49, idle=5, last=None)] + picks, withheld, state = pig.belt_pick_with_replacements( + pool, _free_claims(pool), _belt_args(), probe_budget=8) + + # Fail-OPEN : la sonde inerte rend None = « non sondable », le candidat + # est SERVI (jamais refuse faute de signal) et RAPPORTE dans failures + # -- doctrine TRI-ETAT de merged_pr_probe_inert. Les deux numeros dans + # failures sont le comportement attendu du defaut, pas un defaut. + assert [p["number"] for p in picks] == [19001, 19002] + assert state["failures"] == [19001, 19002] + assert withheld == [] + + +def test_belt_main_json_path_never_reaches_real_probes(monkeypatch, capsys): + """#19913 : le chemin complet `main --belt --json` est inerte par defaut. + + Controle negatif du piege mesure par le coordinateur : sur une fusion + avec main, ``test_belt_json_emits_single_document_when_red_present`` + depassait 30 s dans un vrai ``fetch_visits``, et restait bloque sur un + ``subprocess.communicate`` reel. Apres le fix (fixture + defauts + inertes), ``fetch_visits`` est couvert par la fixture, et les sondes + de livraison ``has_delivered_signal`` / ``merged_pr_signal`` sont + remplacees par des leveuses : le meme scenario main doit completer + vite et sans exception. Toute regression (sonde reseau non couverte + par la fixture, defaut reseau reintroduit dans la boucle) fait lever + ce test. + """ + def raise_real(name): + def raiser(*a, **k): + raise _RealProbeReached(f"{name} atteinte hors injection") + return raiser + + red = _state_red() + prs = [{"number": 18844, + "title": "PR rouge de la lane", + "body": "Grain: MED/guard -- lane myia-po-2024:CoursIA-2", + "createdAt": "2026-09-30T12:00:00Z", + "isDraft": False}] + _patch_belt_network(monkeypatch, prs, red) + # La fixture couvre fetch_visits (main l'appelle legitiment, sans + # reseau en test). Les sondes de livraison, elles, ne DOIVENT etre + # atteintes nulle part sur ce chemin -- defauts inertes de la boucle : + # si un defaut reseau revient, la leveuse fait echouer le test. + monkeypatch.setattr(pig, "has_delivered_signal", + raise_real("has_delivered_signal")) + monkeypatch.setattr(pig, "merged_pr_signal", + raise_real("merged_pr_signal")) + + rc = pig.main(["--lane", "myia-po-2024:CoursIA-2", "--belt", "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["mode"] == "belt" + assert payload["repair"]["grain"]["number"] == 18844 + + def test_non_belt_json_red_still_emits_standalone_repair(monkeypatch, capsys): """Regression check : hors `--belt`, le mode repair reste standalone. @@ -951,7 +1109,12 @@ def test_belt_withdraws_delivered_candidates_and_replaces(): pool = [marked_label, marked_comment, clean_a, clean_b] picks, withheld, state = pig.belt_pick_with_replacements( pool, _free_claims(pool), _belt_args(), probe_budget=8, - delivered_probe=lambda n, lane=None: n == 16048) + delivered_probe=lambda n, lane=None: n == 16048, + # 3e surface de livraison #19907 : probe injecte explicitement + # False, le test ne couvre que le label et le marqueur. Depuis le + # defaut inerte (#19913), omettre le param serait aussi sur ; on + # l'injecte pour garder le scope du test lisible. + merged_pr_probe=lambda n, lane=None: False) assert [p["number"] for p in picks] == [19001, 19002], ( "les candidats livres doivent etre remplaces, pas servis") causes = {w[0]["number"]: w[1] for w in withheld} @@ -966,7 +1129,11 @@ def test_belt_keeps_unmarked_candidate_pickable(): clean = _make_item(19003, age_days=40, idle=4, last=None) picks, withheld, _ = pig.belt_pick_with_replacements( [clean], _free_claims([clean]), _belt_args(), probe_budget=8, - delivered_probe=lambda n, lane=None: False) + delivered_probe=lambda n, lane=None: False, + # 3e surface #19907 : probe inert ici (le test verifie que sans + # marqueur, le candidat reste tirable -- on ne veut pas qu'une + # PR reelle mergee le fausse). + merged_pr_probe=lambda n, lane=None: False) assert [p["number"] for p in picks] == [19003] assert withheld == [] @@ -978,7 +1145,10 @@ def test_belt_unread_probe_is_fail_open_and_reported(): unread = _make_item(19004, age_days=40, idle=4, last=None) picks, withheld, state = pig.belt_pick_with_replacements( [unread], _free_claims([unread]), _belt_args(), probe_budget=8, - delivered_probe=lambda n, lane=None: None) + delivered_probe=lambda n, lane=None: None, + # 3e surface #19907 : probe inert (le test verifie le tri-etat + # du probe commentaire ; la 3e surface n'est pas sous test ici). + merged_pr_probe=lambda n, lane=None: False) assert [p["number"] for p in picks] == [19004] assert withheld == [] assert state["failures"] == [19004] @@ -1053,7 +1223,13 @@ def probe(n, lane=None): # l'epuisement est rapporte -- le temoin du defaut. old_picks, _, old_state = pig.belt_pick_with_replacements( list(pool), _free_claims(pool), _belt_args(grains=20), - probe_budget=window, delivered_probe=probe) + probe_budget=window, delivered_probe=probe, + # 3e surface #19913 : explicitement False. Sous l'ancien defaut + # (sonde reseau) ce test laissait `main` sonder le depot ; depuis + # les defauts inertes, l'omission se lit `failures` (None = + # non sondable) et brouille l'assertion -- l'injection rend la + # dependance visible. + merged_pr_probe=lambda n, lane=None: False) leaked = [p["number"] for p in old_picks if p["number"] in delivered] assert leaked, "temoin : l'ancien budget doit laisser fuiter des livres" assert old_state["budget_hit"] is True @@ -1061,7 +1237,8 @@ def probe(n, lane=None): # Budget aligne : plus aucun livre servi, et plus d'epuisement. new_picks, new_withheld, new_state = pig.belt_pick_with_replacements( list(pool), _free_claims(pool), _belt_args(grains=20), - probe_budget=pig.belt_probe_budget(window), delivered_probe=probe) + probe_budget=pig.belt_probe_budget(window), delivered_probe=probe, + merged_pr_probe=lambda n, lane=None: False) assert [p["number"] for p in new_picks] == [19300] assert {w[0]["number"] for w in new_withheld} == delivered assert new_state["budget_hit"] is False