From 13edb0daadbe527a1745df808dbaf362d0a57d9a Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 3 Nov 2026 21:16:36 +0100 Subject: [PATCH 1/2] Fix(belt,#21436): le mur de temps n'eteint plus un filtre de marqueur deja en memoire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La boucle du tapis sautait les deux filtres de marqueur (recent et conteneur) des que l'echeance etait atteinte, alors que `_last_comment` sert depuis l'instantane de la passe quand il le peut (#22630) : la lecture etait locale et gratuite, le mur n'avait rien a borner. Mesure du 2026-11-03 (draw 32e6c570269a, lane myia-po-2027:CoursIA) : tirage au mur (240 s, time_budget_hit vrai), instantane ayant servi 36 stamps sur 36 sans une seule requete, et 4 candidats sur 4 servis alors que leur dernier commentaire portait un marqueur de livraison. La lane recevait « ACTION REQUISE : poser [CLAIMED] sur #19694 » pour une issue livree depuis le 20/10. Le predicat est lu sur la MEME surface que la sonde (`_snapshot_comments`) : il ne peut donc pas promettre une lecture locale que la sonde ne ferait pas. Hors instantane, la garde reste entiere et le fail-OPEN est celui d'avant. La ligne ACTION REQUISE dit desormais que son objet n'a pas ete verifie quand la tete a ete servie au-dela du mur. Tests : 174 passed (belt + 21436 + 22500 + claim_filter), dont deux nouveaux -- positif (lecture locale, candidat ecarte malgre l'echeance) et negatif (sans instantane, aucune sonde ne part apres le mur). See #21436 Co-Authored-By: Claude Sonnet 5.5 --- scripts/pick_idle_grain.py | 45 +++++++++++++- scripts/tests/test_pick_idle_grain_belt.py | 70 ++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 1b5f606d6f0..bf9a85725bf 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -10066,6 +10066,30 @@ def expired(): user_delivery_event_probe = (delivery_event_probe or delivery_event_probe_inert) + def marker_read_is_local(number): + """Le DERNIER commentaire de `number` est-il deja en memoire ? + + 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 instantane, la garde + `expired()` reste entiere et le fail-OPEN reste celui d'avant. + """ + return _snapshot_comments(number) is not None + 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 @@ -10260,8 +10284,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 @@ -10324,7 +10349,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 @@ -11585,6 +11611,19 @@ 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(" La lecture locale du marqueur (instantane) reste") + print(" faite au-dela du mur : seules les sondes RESEAU") + print(" 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..ebe9592509b 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -3133,3 +3133,73 @@ 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 == [] From 898096be0a2ff1b47a9174dd4f27fb7b9baac60c Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 3 Nov 2026 22:56:18 +0100 Subject: [PATCH 2/2] Fix(belt,#24505): un dossier de cloture ne rallume pas le reseau apres le mur Reserve Hermes (review du 2026-11-03, reproduite par execution) : le predicat marker_read_is_local de #21436 repondait True des que l'instantane existait, or un corps de dossier de cloture (verdict CLOSE ou annonce en prose) declenche dans has_recent_delivery_marker la regle de reouverture #24250 -> _reopen_events -> une requete REST du timeline, tiree APRES le mur. Le predicat rend desormais False pour cette classe : le fail-OPEN historique reprend (filtre saute, silence rapporte) au lieu d'une requete non budgetee. Les marqueurs ordinaires restent filtrees apres le mur comme le couvrait la mesure fondatrice. Banniere du mur alignee sur la meme frontiere. Tests : +2 (dossier CLOSE valide, annonce en prose), 200 passed sur les suites affectees. Co-Authored-By: Claude Sonnet 5.5 --- scripts/pick_idle_grain.py | 38 +++++++-- scripts/tests/test_pick_idle_grain_belt.py | 98 ++++++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index bf9a85725bf..088b69b616c 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -10067,7 +10067,7 @@ def expired(): or delivery_event_probe_inert) def marker_read_is_local(number): - """Le DERNIER commentaire de `number` est-il deja en memoire ? + """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) @@ -10085,10 +10085,34 @@ def marker_read_is_local(number): 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 instantane, la garde + 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. """ - return _snapshot_comments(number) is not None + 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 @@ -11621,9 +11645,11 @@ def _probe(n: int) -> str | None: 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(" La lecture locale du marqueur (instantane) reste") - print(" faite au-dela du mur : seules les sondes RESEAU") - print(" s'arretent.") + 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 ebe9592509b..f99b0c84236 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -3203,3 +3203,101 @@ def test_belt_deadline_still_skips_the_marker_filter_without_local_read(monkeypa 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