diff --git a/scripts/check_lane_claim.py b/scripts/check_lane_claim.py index e73a71a32fd..5291e58baaf 100644 --- a/scripts/check_lane_claim.py +++ b/scripts/check_lane_claim.py @@ -1962,6 +1962,129 @@ def _find_open_prs_referencing_issue( return out +# --- #22408 -- un claim age n'est pas un claim mort --------------------------- +# +# Le filtre de peremption ne regardait que l'horloge du `[CLAIMED]`. Instance +# fondatrice #21881 (2026-10-24) : po-2023 avait claim le 2026-10-21T18:39Z, +# ouvert #21884 (`closes #21881`) a 18:59Z et pousse jusqu'au 2026-10-23T06:29Z ; +# a 73,8 h l'organe a rendu STALE, et po-2024 a repris le grain (#22251). Deux +# carnets DEEP livres pour un seul grain, une PR fermee. +# +# Ce que l'age du claim ne dit pas, c'est si la lane LIVRE. Une PR OUVERTE de +# la meme lane, qui reference l'issue et qui a recu un commit ou un commentaire +# porte par cette lane dans la fenetre du seuil, prouve que le claim est vivant : +# l'age apparent vient du fait qu'un `[CLAIMED]` n'est poste qu'une fois, alors +# qu'une livraison dure des jours. +# +# Posture de mesure : quand l'activite ne peut pas etre lue (gh indisponible, +# payload illisible), on garde le verdict d'avant #22408 (STALE) et on le dit -- +# une lecture qui echoue ne devient jamais une preuve d'inactivite. + +_GH_PR_ACTIVITY_TIMEOUT_S = int( + os.environ.get("CHECK_LANE_CLAIM_PR_ACTIVITY_TIMEOUT", "60") +) + + +def _iso_utc(value): + """Parse a `gh` ISO-8601 UTC timestamp; None when unreadable.""" + if not isinstance(value, str) or not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _lane_open_prs_referencing( + issue_number: int, + lane: str, + prs: list[dict] | None = None, +) -> list[int]: + """OPEN PR numbers of `lane` whose body references `issue_number`. + + Same body reader as `_find_open_prs_referencing_issue` / `_find_open_pr_ + for_issue_by_lane` (unwrap + shared `extract_lane`), but filtered on the + CLAIMANT lane instead of excluding it -- here the question is "does the + holder still work?", not "does someone else collide?". + """ + if prs is None: + prs = _gh_open_prs_with_files() + out: list[int] = [] + for pr in prs: + body = _unwrap_trapped_body(pr.get("body") or "") + if extract_lane(body) != lane: + continue + if not _pr_body_references_issue(body, issue_number): + continue + try: + out.append(int(pr["number"])) + except (KeyError, ValueError, TypeError): + continue + return sorted(out) + + +def _pr_last_lane_activity(pr_number: int, lane: str): + """Latest commit / lane-tagged comment of an OPEN PR, or None. + + None means NOT MEASURED (gh failure, timeout, unreadable payload) -- the + caller keeps the pre-#22408 verdict. A commit counts whoever pushed it (the + PR is the lane's); a comment counts only when its body carries the lane tag, + because every agent posts under the shared `jsboige` login and the login + cannot tell lanes apart. + """ + try: + proc = subprocess.run( + ["gh", "pr", "view", str(pr_number), "--json", "commits,comments"], + capture_output=True, text=True, shell=False, + encoding="utf-8", errors="replace", + timeout=_GH_PR_ACTIVITY_TIMEOUT_S, + ) + except subprocess.TimeoutExpired: + return None + if proc.returncode != 0: + return None + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError: + return None + stamps = [] + for commit in data.get("commits") or []: + ts = _iso_utc(commit.get("committedDate")) + if ts is not None: + stamps.append(ts) + for comment in data.get("comments") or []: + body = _unwrap_trapped_body(comment.get("body") or "") + if extract_lane(body) != lane: + continue + ts = _iso_utc(comment.get("createdAt")) + if ts is not None: + stamps.append(ts) + return max(stamps) if stamps else None + + +def _claim_still_being_delivered( + issue_number: int, + lane: str, + threshold_h: float, + now, + prs: list[dict] | None = None, +): + """`(pr_number, activity_age_h)` proving `lane` still works the grain. + + None when no OPEN PR of `lane` references the issue, or when the last + measurable activity is older than `threshold_h`, or when activity could not + be measured at all. + """ + for pr_number in _lane_open_prs_referencing(issue_number, lane, prs=prs): + last = _pr_last_lane_activity(pr_number, lane) + if last is None: + continue + age_h = (now - last).total_seconds() / 3600.0 + if age_h < threshold_h: + return pr_number, age_h + return None + + # #12386 v2 -- `_find_open_pr_for_issue_by_lane` returns the unique OPEN PR # in `lane` whose body references `issue_number` (case insensitive # `Closes #N` / `Fixes #N` / `Refs #N` / `See #N` / plain `#N`), OR `None` @@ -3198,12 +3321,60 @@ def _run_check(payload: dict, my_lane: str, stale_threshold=None, # their own [CLAIMED] (this is not a silent bypass). Without the flag # (default None), behaviour is unchanged: every active claim blocks. stale_others: dict[str, ClaimEvent] = {} + revived_claims: list[dict] = [] if stale_threshold is not None: now = now or datetime.now(timezone.utc) for ln, ev in others.items(): age = _claim_age_hours(ev.created_at, now) if age is not None and age >= stale_threshold: stale_others[ln] = ev + # #22408 -- l'age du claim ne suffit pas : la lane titulaire peut etre + # en train de livrer. Une PR ouverte de cette lane, qui reference + # l'issue et qui a bouge dans la fenetre du seuil, ranime le claim (il + # continue de bloquer) et la mesure est rendue dans `revived_claims`. + # La lecture des PRs ouvertes n'est payee que s'il y a un candidat. + if stale_others and payload.get("number") is not None: + for ln, ev in list(stale_others.items()): + # #22408 / review Hermes 5641325382 -- la lecture de l'activite + # peut LEVER, pas seulement rendre None : `_lane_open_prs_ + # referencing` passe par `_gh_open_prs_with_files()`, qui leve + # `RuntimeError` sur un `gh` en echec et `OpenPrsIncompleteError` + # (sous-classe de RuntimeError) sur une enumeration partielle. + # Sans ce filet, une enumeration morte faisait sortir l'exception + # de `_run_check` -- traceback au lieu d'un verdict, et quatre + # tests verts sur `main` (gh indisponible) rougissaient ici. + # Une lecture impossible n'est pas une preuve de livraison : le + # claim garde son verdict d'avant (STALE), et on le dit. + try: + live = _claim_still_being_delivered( + int(payload["number"]), ln, stale_threshold, now) + except (RuntimeError, ValueError, TypeError) as exc: + print( + f"WARN: activite de {ln} illisible " + f"({type(exc).__name__}: {exc}) -- le claim garde son " + f"verdict d'avant (perime) (#22408)", + file=sys.stderr, + ) + continue + if live is None: + continue + pr_number, activity_age_h = live + revived_claims.append({ + "lane": ln, + "claim_age_h": round( + _claim_age_hours(ev.created_at, now) or 0.0, 1), + "pr": pr_number, + "last_activity_age_h": round(activity_age_h, 1), + }) + print( + f"INFO: claim de {ln} age de " + f"{_claim_age_hours(ev.created_at, now):.1f}h, MAIS sa PR " + f"ouverte #{pr_number} a bouge il y a " + f"{activity_age_h:.1f}h (< {stale_threshold:g}h) -- le " + f"claim N'EST PAS perime (#22408).", + file=sys.stderr, + ) + del stale_others[ln] others = {ln: ev for ln, ev in others.items() if ln not in stale_others} else: # #12751 -- a zero of absence-of-measurement must not re-read as a @@ -3230,12 +3401,21 @@ def _run_check(payload: dict, my_lane: str, stale_threshold=None, # gh failure, same posture as the #16570 paths leg: a leg that cannot # measure must not fabricate a verdict. implicit_occupation: list[dict] = [] + # #22408 (item 3) -- `[]` disait deux choses incompatibles : « mesure, et + # personne n'occupe » et « jambe non demandee ». Instance #21881 : le + # rapport de l'issue lisait `implicit_occupation: []` et en concluait que + # l'organe n'avait pas vu la PR tierce, alors que la jambe est *lazy by + # outcome* -- a cet instant le registre portait un claim actif, donc la + # jambe n'a pas tourne. Le booleen separe la mesure de son absence. + implicit_occupation_measured = False if (check_open_pr_paths and not others and mine is None and payload.get("number") is not None): + implicit_occupation_measured = True try: implicit_occupation = _find_open_prs_referencing_issue( int(payload["number"]), my_lane) except (RuntimeError, ValueError, TypeError) as exc: + implicit_occupation_measured = False print( f"WARN: la jambe IMPLICIT (#14300) n'a pas pu tourner " f"({exc}) : le verdict ci-dessous ne dit rien des PRs " @@ -3351,6 +3531,11 @@ def _run_check(payload: dict, my_lane: str, stale_threshold=None, "my_active_claim": bool(mine), "blocking_lanes": sorted(others), "stale_claims": sorted(stale_others) if stale_threshold is not None else None, + # #22408 -- les claims dont l'age depassait le seuil mais dont la lane + # livrait encore : ils BLOQUENT (ils sont dans `others`, donc dans + # `blocking_lanes`) et la mesure qui les a ranimes est citee ici, pour + # qu'un lecteur ne voie pas un BLOCKED inexplique. + "revived_claims": revived_claims, # #12751 -- l'etat de la detection est nomme explicitement. "active" : # un seuil est pose, les claims plus vieux sont age-filtered (le # comportement par defaut, 48h). "disabled" : --no-stale / threshold @@ -3572,10 +3757,13 @@ def _run_check(payload: dict, my_lane: str, stale_threshold=None, # as open_pr_collisions above. Non-empty routes the verdict below # to `IMPLICIT` at exit 3 -- distinct from claim-BLOCKED (exit 1) # and from the io/gh-error exit 2 that check_grain_free.py - # documents for this mode. Empty list = no OPEN PR of another lane - # references the issue (or the leg was skipped: BLOCKED verdict, - # posting path, or a gh failure WARNed above). + # documents for this mode. "implicit_occupation": implicit_occupation, + # #22408 -- `[]` seul ne distinguait pas « mesure : personne » de + # « jambe non demandee » (lazy by outcome) ni de « lecture echouee ». + # Ce booleen porte la difference ; les lecteurs qui font + # `data.get("implicit_occupation") or []` gardent leur comportement. + "implicit_occupation_measured": implicit_occupation_measured, } print(json.dumps(summary, ensure_ascii=False, indent=2)) diff --git a/scripts/tests/test_check_lane_claim.py b/scripts/tests/test_check_lane_claim.py index cfd299c9f93..e5ccd0d88cc 100644 --- a/scripts/tests/test_check_lane_claim.py +++ b/scripts/tests/test_check_lane_claim.py @@ -1134,6 +1134,238 @@ def test_stale_threshold_unblocks_old_claim(capsys): assert '"blocked": false' in captured.out +# --- #22408 : l'age ne suffit pas, la lane peut livrer ------------------------ +# Instance fondatrice #21881 : claim po-2023 du 2026-10-21T18:39Z, PR #21884 +# ouverte a 18:59Z, dernier commit le 2026-10-23T06:29Z. A 73,8 h l'organe a +# rendu STALE et po-2024 a repris le grain (#22251) : deux carnets DEEP pour un +# seul grain. Le claim n'est perime que si la lane ne donne plus signe de vie. + +CSP10_PATH = "MyIA.AI.Notebooks/Search/Part2-CSP/CSP-10-*.ipynb" + + +class _ActivityProc: + def __init__(self, stdout: str, returncode: int = 0): + self.stdout = stdout + self.returncode = returncode + self.stderr = "" + + +def _lane_pr(number: int, lane: str, issue: int) -> dict: + return { + "number": number, + "title": "CSP-10", + "headRefName": "feature/csp10", + "body": (f"Grain: DEEP/notebook-python -- lane {lane} " + f"-- prev: DEEP/docs #1\n\nCloses #{issue}\n"), + "files": [{"path": "MyIA.AI.Notebooks/Search/Part2-CSP/CSP-10-Optimisation.ipynb"}], + } + + +def _fake_gh_pr_view(payload_json: str, returncode: int = 0): + """`subprocess.run` stub for the ONE call `_pr_last_lane_activity` makes. + + Scoped to that helper by construction: every wiring test below patches + `_pr_last_lane_activity` itself instead, so a stub never has to share the + process with `git ls-files` or any other caller. + """ + def fake_run(argv, *args, **kwargs): + assert list(argv[:3]) == ["gh", "pr", "view"], argv + return _ActivityProc(payload_json, returncode) + return fake_run + + +def _patch_last_activity(monkeypatch, value): + """Inject the measured activity instant (None = NOT MEASURED).""" + monkeypatch.setattr(clc, "_pr_last_lane_activity", + lambda pr_number, lane: value) + + +def test_pr_last_lane_activity_takes_the_latest_lane_signal(monkeypatch): + """Parsing : le commit le plus recent OU un commentaire porte par la lane.""" + fixture = json.dumps({ + "commits": [{"committedDate": "2026-08-01T00:00:00Z"}, + {"committedDate": "2026-08-06T23:00:00Z"}], + "comments": [ + # une AUTRE lane commente : ne compte pas (login partage partout) + {"body": "[INFO] lane myia-po-2024:CoursIA-2 -- relecture", + "createdAt": "2026-08-07T11:00:00Z"}, + ], + }) + monkeypatch.setattr(clc.subprocess, "run", _fake_gh_pr_view(fixture)) + assert clc._pr_last_lane_activity( + 21884, "myia-po-2023:CoursIA-2") == datetime( + 2026, 8, 6, 23, 0, tzinfo=timezone.utc) + + +def test_pr_last_lane_activity_counts_a_lane_tagged_comment(monkeypatch): + """Un commentaire de la lane titulaire compte comme une activite.""" + fixture = json.dumps({ + "commits": [{"committedDate": "2026-08-01T00:00:00Z"}], + "comments": [ + {"body": "[INFO] lane myia-po-2023:CoursIA-2 -- 12/18 cellules", + "createdAt": "2026-08-07T09:00:00Z"}, + ], + }) + monkeypatch.setattr(clc.subprocess, "run", _fake_gh_pr_view(fixture)) + assert clc._pr_last_lane_activity( + 21884, "myia-po-2023:CoursIA-2") == datetime( + 2026, 8, 7, 9, 0, tzinfo=timezone.utc) + + +def test_pr_last_lane_activity_none_on_gh_failure(monkeypatch): + """Une lecture morte n'est pas une mesure d'inactivite.""" + monkeypatch.setattr(clc.subprocess, "run", + _fake_gh_pr_view("", returncode=1)) + assert clc._pr_last_lane_activity(21884, "myia-po-2023:CoursIA-2") is None + + +def test_aged_claim_with_live_lane_pr_blocks(monkeypatch, capsys): + """73 h de claim + PR ouverte de la meme lane + commit de 13 h => BLOCKED. + + C'est le test cible de #22408 : sans lui, l'organe autorisait une reprise + sur un grain en cours de livraison. + """ + p = payload(comment("[CLAIMED] lane myia-po-2023:CoursIA-2 -- CSP-10", + "2026-08-04T11:00:00Z"), number=21881) + monkeypatch.setattr(clc, "_gh_open_prs_with_files", + lambda: [_lane_pr(21884, "myia-po-2023:CoursIA-2", 21881)]) + _patch_last_activity(monkeypatch, + datetime(2026, 8, 6, 23, 0, tzinfo=timezone.utc)) + + rc = clc._run_check(p, "myia-po-2024:CoursIA-2", stale_threshold=48.0, + now=NOW, my_paths=[CSP10_PATH]) + captured = capsys.readouterr() + assert rc == 1, captured.out + captured.err + assert '"myia-po-2023:CoursIA-2"' in captured.out + assert '"stale_claims": []' in captured.out + assert '"pr": 21884' in captured.out + assert "N'EST PAS perime (#22408)" in captured.err + + +def test_aged_claim_without_open_pr_stays_stale(monkeypatch, capsys): + """Controle negatif : le meme claim, sans PR ouverte, reste STALE.""" + p = payload(comment("[CLAIMED] lane myia-po-2023:CoursIA-2 -- CSP-10", + "2026-08-04T11:00:00Z"), number=21881) + monkeypatch.setattr(clc, "_gh_open_prs_with_files", lambda: []) + + rc = clc._run_check(p, "myia-po-2024:CoursIA-2", stale_threshold=48.0, + now=NOW, my_paths=[CSP10_PATH]) + captured = capsys.readouterr() + assert rc == 0, captured.out + captured.err + assert '"stale_claims": [\n "myia-po-2023:CoursIA-2"\n ]' in captured.out + assert '"revived_claims": []' in captured.out + assert "STALE_CLAIM myia-po-2023:CoursIA-2" in captured.err + + +def test_aged_claim_with_silent_pr_is_still_stale(monkeypatch, capsys): + """PR ouverte mais derniere touche a 61 h (> seuil 48 h) : STALE.""" + p = payload(comment("[CLAIMED] lane myia-po-2023:CoursIA-2 -- CSP-10", + "2026-08-04T11:00:00Z"), number=21881) + monkeypatch.setattr(clc, "_gh_open_prs_with_files", + lambda: [_lane_pr(21884, "myia-po-2023:CoursIA-2", 21881)]) + _patch_last_activity(monkeypatch, + datetime(2026, 8, 4, 23, 0, tzinfo=timezone.utc)) + + rc = clc._run_check(p, "myia-po-2024:CoursIA-2", stale_threshold=48.0, + now=NOW, my_paths=[CSP10_PATH]) + captured = capsys.readouterr() + assert rc == 0, captured.out + captured.err + assert '"stale_claims": [\n "myia-po-2023:CoursIA-2"\n ]' in captured.out + + +def test_unreadable_activity_keeps_the_old_verdict(monkeypatch, capsys): + """gh indisponible : on garde STALE (une lecture morte n'est pas une preuve).""" + p = payload(comment("[CLAIMED] lane myia-po-2023:CoursIA-2 -- CSP-10", + "2026-08-04T11:00:00Z"), number=21881) + monkeypatch.setattr(clc, "_gh_open_prs_with_files", + lambda: [_lane_pr(21884, "myia-po-2023:CoursIA-2", 21881)]) + _patch_last_activity(monkeypatch, None) + + rc = clc._run_check(p, "myia-po-2024:CoursIA-2", stale_threshold=48.0, + now=NOW, my_paths=[CSP10_PATH]) + captured = capsys.readouterr() + assert rc == 0, captured.out + captured.err + assert '"stale_claims": [\n "myia-po-2023:CoursIA-2"\n ]' in captured.out + + +def test_open_pr_enumeration_failure_keeps_the_old_verdict(monkeypatch, capsys): + """#22408 / review Hermes 5641325382 -- une enumeration qui LEVE ne sort pas. + + `_gh_open_prs_with_files()` leve `RuntimeError` sur un `gh` en echec et + `OpenPrsIncompleteError` (sa sous-classe) sur une enumeration partielle. + Ce mode n'etait couvert par aucun test : l'exception traversait + `_run_check` jusqu'a la CLI, qui sortait en traceback sans verdict -- et + quatre tests verts sur `main` (pas de `gh` dans l'environnement) + rougissaient au head. Une lecture impossible ne ranime pas un claim : il + garde son verdict d'avant, et l'organe le dit sur stderr. + """ + p = payload(comment("[CLAIMED] lane myia-po-2023:CoursIA-2 -- CSP-10", + "2026-08-04T11:00:00Z"), number=21881) + + def boom(): + raise clc.OpenPrsIncompleteError("enumeration incomplete: REST timeout") + + monkeypatch.setattr(clc, "_gh_open_prs_with_files", boom) + + rc = clc._run_check(p, "myia-po-2024:CoursIA-2", stale_threshold=48.0, + now=NOW, my_paths=[CSP10_PATH]) + captured = capsys.readouterr() + assert rc == 0, captured.out + captured.err + assert '"stale_claims": [\n "myia-po-2023:CoursIA-2"\n ]' in captured.out + assert '"revived_claims": []' in captured.out + assert "activite de myia-po-2023:CoursIA-2 illisible" in captured.err + + +def test_open_pr_enumeration_runtime_error_keeps_the_old_verdict( + monkeypatch, capsys): + """Meme filet pour un `gh` simplement en echec (RuntimeError nu). + + C'est la forme exacte du log CI du job `Scripts Tests (CPU)` : + `RuntimeError: gh repo view failed (exit 4)`. + """ + p = payload(comment("[CLAIMED] lane myia-po-2023:CoursIA-2 -- CSP-10", + "2026-08-04T11:00:00Z"), number=21881) + + def boom(): + raise RuntimeError("gh repo view failed (exit 4)") + + monkeypatch.setattr(clc, "_gh_open_prs_with_files", boom) + + rc = clc._run_check(p, "myia-po-2024:CoursIA-2", stale_threshold=48.0, + now=NOW, my_paths=[CSP10_PATH]) + captured = capsys.readouterr() + assert rc == 0, captured.out + captured.err + assert '"stale_claims": [\n "myia-po-2023:CoursIA-2"\n ]' in captured.out + assert "illisible" in captured.err + + +def test_implicit_occupation_is_not_measured_when_a_claim_blocks(capsys): + """#22408 item 3 : `[]` ne doit plus se lire « mesure, personne ». + + A l'instant du rapport #21881, le registre portait un claim actif : la + jambe IMPLICIT est lazy by outcome, elle n'a pas tourne, et `[]` se + lisait comme un resultat. Le booleen porte la difference. + """ + p = payload(comment("[CLAIMED] lane myia-po-2025:CoursIA -- working here", + "2026-08-07T10:00:00Z"), number=21881) + rc = clc._run_check(p, "myia-po-2024:CoursIA", + stale_threshold=48.0, now=NOW, check_open_pr_paths=True) + captured = capsys.readouterr() + assert rc in (1, 2), captured.out + assert '"implicit_occupation": []' in captured.out + assert '"implicit_occupation_measured": false' in captured.out + + +def test_implicit_occupation_is_measured_on_a_free_grain(monkeypatch, capsys): + p = payload(comment("rien", "2026-08-07T10:00:00Z"), number=21881) + monkeypatch.setattr(clc, "_gh_open_prs_with_files", lambda: []) + rc = clc._run_check(p, "myia-po-2024:CoursIA", + stale_threshold=48.0, now=NOW, check_open_pr_paths=True) + captured = capsys.readouterr() + assert rc == 0, captured.out + assert '"implicit_occupation_measured": true' in captured.out + + def test_stale_threshold_fresh_claim_returns_exit_2_not_scoped(capsys): # Other lane claimed 2h ago; threshold 24h -> NOT_SCOPED at exit 2 (#12322). # Pre-#12322 this returned `exit 1` + `BLOCKED`, conflating a non-scoped