diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 0b55d35bb9b..182d71ee1a2 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -10178,6 +10178,54 @@ def expired(): user_delivery_event_probe = (delivery_event_probe or delivery_event_probe_inert) + def marker_read_is_local(number): + """La lecture du marqueur de `number` resterait-elle LOCALE ? + + Le mur de temps (`expired`) borne le RESEAU ; il ne borne pas une + lecture locale. Les deux filtres de marqueur (recent, conteneur) + lisent `_last_comment`, qui sert depuis l'instantane de la passe + quand il le peut (#22630). Les sauter au motif de l'echeance sert + donc un candidat dont la cloture etait CONNUE. + + Mesure du 2026-11-03 (draw `32e6c570269a`, lane + `myia-po-2027:CoursIA`) : le tapis a servi 4 candidats sur 4 + portant chacun un marqueur de livraison dans leur dernier + commentaire, avec un `ACTION REQUISE` « poser [CLAIMED] sur + #19694 » -- alors que l'instantane avait servi 36 stamps sur 36 + SANS une seule requete et que `time_budget_hit` etait vrai. Le + cout du filtre n'etait pas le reseau : c'etait le mur. + + Le predicat est lu sur la MEME surface que la sonde + (`_snapshot_comments`), donc il ne peut pas promettre une lecture + locale que la sonde ne ferait pas : hors instantané, la garde + `expired()` reste entiere et le fail-OPEN reste celui d'avant. + + RESERVE #24505 (review Hermes, reproduite par execution) : + « instantané présent » ne suffit pas. Un corps de DOSSIER DE + CLOTURE (verdict CLOSE, ou annonce en prose) déclenche dans + `has_recent_delivery_marker` l'appel `_delivery_voided_by_reopen` + -> `_reopen_events` -> une requete REST du TIMELINE, tiree + APRES le mur. Le predicat rend donc faux pour cette classe de + corps : le fail-OPEN historique (sauter le filtre, le dire) + reprend, au lieu d'une requete reseau non budgetee. Les corps + ordinaires (`[DELIVERED]`, `[FROZEN]`, `[INFO] + candidate-delivered`, `[RELEASED]`) restent 100 % locaux -- + c'est exactement la classe que la mesure fondatrice couvrait. + """ + comments = _snapshot_comments(number) + if comments is None: + return False + if not isinstance(comments, list) or not comments: + return True + last = comments[-1] + body = (last or {}).get("body") if isinstance(last, dict) else None + if not isinstance(body, str): + return True + # Un dossier de cloture rallume le reseau (regle de reouverture + # #24250) : il ne compte pas comme une lecture locale. + return not (_closure_dossier_verdict(body) == "CLOSE" + or _closure_announced_close(body)) + def counted_probe(number, lane_name): # L'echeance passe AVANT le plafond de sondes : les deux produisent le # meme fail-OPEN, mais elles ne disent pas la meme chose a la lane @@ -10382,8 +10430,9 @@ def claims_probe(numbers): and age_h is not None and age_h >= CONSOLIDATION_LAG_H) else: - recent_flag = (False if expired() - else user_recent_marker_probe(n)) + recent_flag = ( + False if (expired() and not marker_read_is_local(n)) + else user_recent_marker_probe(n)) if recent_flag is None: # Meme traitement que ci-dessus : le fail-OPEN reste # assume, le SILENCE ne l'est pas. La sonde du marqueur @@ -10447,7 +10496,8 @@ def claims_probe(numbers): # s'appliquait qu'a l'urne `grain`. La lecture est HORS budget de # sonde (1 requete, comme le filtre recent) et reste fail-OPEN : # une lecture en echec laisse l'umbrella servable. - recent = False if expired() else user_container_marker_probe(n) + recent = (False if (expired() and not marker_read_is_local(n)) + else user_container_marker_probe(n)) if recent is None: # La lecture a ECHOUE (reseau, 403, payload illisible) : l'etat # de livraison du CONTENEUR est INCONNU, pas « libre ». Le @@ -11710,6 +11760,21 @@ def _probe(n: int) -> str | None: top = belt_picks[0] print(f" ACTION REQUISE : poser [CLAIMED] lane {args.lane} sur " f"#{top['number']} avant edition -- cf lane-claim-protocol.") + if belt_time_budget_hit[0]: + # Une tete servie au-dela du mur n'est pas verifiee : la + # ligne ci-dessus est une INJONCTION, elle doit donc dire + # a cote d'elle que son objet n'a pas ete lu. Mesure du + # 2026-11-03 : le tirage au mur a servi 4 candidats sur 4 + # portant un marqueur de livraison, et la lane recevait + # « poser [CLAIMED] sur #19694 » sans reserve. + print(" CANDIDAT NON VERIFIE (#21436) : le mur de temps a") + print(" ete atteint avant ses lectures de livraison et de") + print(" claim. Lire le dernier commentaire AVANT de claimer.") + print(" Les lectures restees LOCALES apres le mur : le") + print(" dernier commentaire en instantane, sauf dossier de") + print(" cloture (qui rallume la regle de reouverture, donc") + print(" du reseau) -- pour lui, le filtre est saute et le") + print(" silence est rapporte. Les sondes RESEAU s'arretent.") # #17038 (residu du 2026-10-09) -- contrat de verdict : un pick # dont le claim est ERROR n'est PAS un candidat libre, c'est un # candidat NON MESURE. Le servir reste le comportement (le tapis diff --git a/scripts/tests/test_pick_idle_grain_belt.py b/scripts/tests/test_pick_idle_grain_belt.py index b05919be14e..f99b0c84236 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -3133,3 +3133,171 @@ def fake_metrics(pool, **kw): "l'echeance fuit apres la sortie --report (#23340 point 1)") finally: pig._BELT_DEADLINE[0] = None + + +def test_belt_deadline_keeps_the_marker_filter_when_the_read_is_local(monkeypatch): + """Le mur de temps arrete le RESEAU ; il n'a aucune raison d'arreter une + lecture deja en memoire. + + Defaut mesure le 2026-11-03 (draw `32e6c570269a`, lane + `myia-po-2027:CoursIA`) : les deux filtres de marqueur (recent, conteneur) + etaient sautes des que `expired()` rendait vrai, alors que l'instantane + de la passe avait deja lu les commentaires de la fenetre (36 stamps sur + 36 servis sans une seule requete). Le tapis a servi 4 candidats sur 4 + portant chacun un marqueur de livraison dans leur dernier commentaire, et + la lane recevait « ACTION REQUISE : poser [CLAIMED] sur #19694 » pour une + issue livree depuis le 20/10. + + Ici `30201` a ses commentaires en memoire et porte un marqueur : il doit + etre ECARTE malgre l'echeance. `30202` n'a pas d'entree locale : sa sonde + reste sautee (comportement d'avant, fail-OPEN rapporte). + """ + _patched_clock(monkeypatch) + local = _make_item(30201, age_days=100, idle=9, last=None) + remote = _make_item(30202, age_days=99, idle=9, last=None) + monkeypatch.setattr( + pig, "_snapshot_comments", + lambda n: ([{"body": "[INFO] candidate-delivered -- lane x", + "createdAt": "2026-11-03T10:00:00Z"}] + if n == 30201 else None)) + calls = [] + + def marker(n): + calls.append(n) + return n == 30201 + + picks, withheld, state = pig.belt_pick_with_replacements( + [local, remote], {}, _belt_args(grains=2), probe_budget=8, + delivered_probe=lambda n, lane=None: False, + merged_pr_probe=lambda n, lane=None: False, + claims_probe=lambda nums: {}, + recent_marker_probe=marker, + time_budget_s=0.0) + + assert calls == [30201], ( + "seul un candidat dont la lecture est LOCALE est filtre apres le mur") + assert [p["number"] for p in picks] == [30202] + assert [w[0]["number"] for w in withheld] == [30201] + assert "LIVRAISON (recent)" in withheld[0][1] + assert state["time_budget_hit"] is True + + +def test_belt_deadline_still_skips_the_marker_filter_without_local_read(monkeypatch): + """Controle negatif : sans instantane, le filtre coute une requete et + reste soumis au mur. Le correctif ne rallume pas le reseau apres + l'echeance.""" + _patched_clock(monkeypatch) + a = _make_item(30211, age_days=100, idle=9, last=None) + b = _make_item(30212, age_days=99, idle=9, last=None) + monkeypatch.setattr(pig, "_snapshot_comments", lambda n: None) + calls = [] + + picks, withheld, state = pig.belt_pick_with_replacements( + [a, b], {}, _belt_args(grains=2), probe_budget=8, + delivered_probe=lambda n, lane=None: False, + merged_pr_probe=lambda n, lane=None: False, + claims_probe=lambda nums: {}, + recent_marker_probe=lambda n: calls.append(n) or True, + time_budget_s=0.0) + + assert calls == [], "aucune sonde de marqueur ne part apres le mur" + assert [p["number"] for p in picks] == [30211, 30212] + assert withheld == [] + + +# Corps reel d'un dossier de cloture CLOSE (grammaire canonique de +# `check_closure_dossier.py` -- meme fixture que test_pick_closure_reopened). +DOSSIER_CLOSE_24505 = """[CLOSURE PREFLIGHT] +schema: 1 +lane: myia-po-2025:CoursIA +issue: 40003 +verdict: CLOSE +acceptance: + - "criteres confrontes a main avec preuve citee" +residue: none +open-prs: 0 +comments-reviewed: 3 +[/CLOSURE PREFLIGHT]""" + + +def test_belt_deadline_skips_the_marker_filter_on_closure_dossier(monkeypatch): + """Reserve review #24505, reproduite par execution : un dossier de + cloture en dernier commentaire rallume le RESEAU meme apres le mur. + + Le corps CLOSE declenche dans `has_recent_delivery_marker` la regle de + reouverture (#24250) -> `_reopen_events` -> une requete REST du timeline. + Le predicat `marker_read_is_local` d'avant la correction repondait + « True » des que l'instantane existait : le filtre de marqueur passait, + et la requete timeline partait au-dela de l'echeance (reproduit par + Hermes sur un dossier CLOSE valide ET sur l'annonce en prose : appels + `gh api --paginate ... issues//timeline` malgre le mur). + + Apres correction, les deux classes dans le meme tirage : le porteur du + dossier n'est PAS sonde (lecture non locale -> filtre saute, fail-OPEN + historique, silence rapporte) ; le porteur d'un marqueur ordinaire reste + filtre comme avant le mur -- le correctif ne dessert pas la classe que + la mesure fondatrice (#21436) couvrait.""" + _patched_clock(monkeypatch) + dossier = _make_item(30301, age_days=100, idle=9, last=None) + ordinary = _make_item(30302, age_days=99, idle=9, last=None) + bodies = { + 30301: [{"body": DOSSIER_CLOSE_24505, + "createdAt": "2026-11-03T10:00:00Z"}], + 30302: [{"body": "[INFO] candidate-delivered -- lane x", + "createdAt": "2026-11-03T10:00:00Z"}], + } + monkeypatch.setattr(pig, "_snapshot_comments", lambda n: bodies.get(n)) + calls = [] + + def marker(n): + calls.append(n) + return True + + picks, withheld, state = pig.belt_pick_with_replacements( + [dossier, ordinary], {}, _belt_args(grains=2), probe_budget=8, + delivered_probe=lambda n, lane=None: False, + merged_pr_probe=lambda n, lane=None: False, + claims_probe=lambda nums: {}, + recent_marker_probe=marker, + time_budget_s=0.0) + + assert calls == [30302], ( + "seul le marqueur ordinaire (lecture locale) est sonde apres le mur ; " + "le dossier CLOSE ne doit pas y rattacher une requete timeline") + assert [p["number"] for p in picks] == [30301], ( + "le porteur du dossier reste servi : filtre saute (fail-OPEN)") + assert [w[0]["number"] for w in withheld] == [30302] + assert "LIVRAISON (recent)" in withheld[0][1] + assert state["time_budget_hit"] is True + + +def test_belt_deadline_skips_marker_filter_on_prose_close_announcement( + monkeypatch): + """Meme reserve #24505, seconde classe reproduite par Hermes : l'annonce + de fermeture EN PROSE (« Fermeture sur dossier ... verdict CLOSE »)- + declenche la meme regle de reouverture qu'un dossier qui parse. Le + predicat doit la traiter pareil : lecture non locale -> filtre saute + apres le mur, aucune sonde.""" + _patched_clock(monkeypatch) + annonce = _make_item(30311, age_days=100, idle=9, last=None) + body = ("Fermeture sur dossier `[CLOSURE PREFLIGHT]` (verdict CLOSE, " + "gate `check_closure_dossier.py` rc=0, commentaire ci-dessus).") + monkeypatch.setattr( + pig, "_snapshot_comments", + lambda n: ([{"body": body, "createdAt": "2026-11-03T10:00:00Z"}] + if n == 30311 else None)) + calls = [] + + picks, withheld, state = pig.belt_pick_with_replacements( + [annonce], {}, _belt_args(grains=1), probe_budget=4, + delivered_probe=lambda n, lane=None: False, + merged_pr_probe=lambda n, lane=None: False, + claims_probe=lambda nums: {}, + recent_marker_probe=lambda n: calls.append(n) or True, + time_budget_s=0.0) + + assert calls == [], ( + "l'annonce en prose ne doit pas rattacher une sonde apres le mur") + assert [p["number"] for p in picks] == [30311] + assert withheld == [] + assert state["time_budget_hit"] is True