From f1e60aafb5449d98c4bad56f66a3e2c173daa3ed Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 28 Sep 2026 17:09:46 +0200 Subject: [PATCH] fix(coordination,Q67): merge_ready exige une approbation myia-ai-01 qui couvre le contenu de la tete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'organe ne merge plus sur le seul dossier tiers READY : la derniere voix de myia-ai-01 doit etre un APPROVED reel, pose sur la tete ou sur une tete dont celle-ci ne differe que par des rafraichissements de base prouves content-free (remontee du plancher DWELL, exposee par merge_dwell.last_authoritative_sha). Absente, perimee ou illisible : skip avant le gate. L'organe evite la peremption du dossier entre la lecture du coordinateur et le merge ; il ne remplace plus cette lecture. CLAUDE.md §A, README de l'organe, skill coordinate et doc agent cloud alignes. Co-Authored-By: Claude-Code --- .claude/skills/coordinate/SKILL.md | 1 + CLAUDE.md | 2 +- docs/reference/agent-cloud-agnosticisme.md | 2 +- scripts/ci/merge_dwell.py | 29 ++- scripts/coordination/README.md | 18 +- scripts/coordination/merge_ready.py | 144 +++++++++++-- scripts/tests/test_merge_dwell.py | 38 ++++ scripts/tests/test_merge_ready.py | 222 ++++++++++++++++++++- 8 files changed, 423 insertions(+), 33 deletions(-) diff --git a/.claude/skills/coordinate/SKILL.md b/.claude/skills/coordinate/SKILL.md index 4f7a5d6dce..a9a7458d29 100644 --- a/.claude/skills/coordinate/SKILL.md +++ b/.claude/skills/coordinate/SKILL.md @@ -92,6 +92,7 @@ Les phases ci-dessous s'executent sous le budget defini par la section `## Budge 4. **Lecture B.0 personnelle minimale avant chaque merge -- non delegable, seulement APRES gate vert** : body + comments + reviews + diff ("Read Body Before Any Action") ; etat A L'INSTANT-T via `gh pr view N --json state,mergedAt,mergeStateStatus,reviews` (jamais depuis le dashboard ni le cycle N-1) ; organe `python scripts/check_unaddressed_nits.py ` (exit 1 = ne pas merger ; son vert ne dispense pas de la lecture). Verifier seulement le dossier, le delta et la preuve decisive ; ne pas rejouer l'audit complet. Une levee porte un auteur et une heure. 5. **Gates de merge** : un preflight READY n'autorise jamais le merge. Appliquer encore B.0, latest-wins CI, H.4 (notebooks : checkout + Papermill local OU log dans le body), catalogue byte-identique a main (`gh pr view N --json files`), scope reel = titre, ordre de stack, variation et relecture de la queue de commentaires. 6. **Merge** : sous `myia-ai-01` (droit `MergePullRequest` verifie firsthand 2026-08-08), avec `gh pr merge --repo jsboige/CoursIA --squash --match-head-commit ` (`--merge` preserve-SHA pour la base d'un stack), **JAMAIS `--delete-branch`**. +7. **Approuver ce qui est lu mais pas encore mergeable** (arbitrage user 2026-09-28, Q67) : une PR hors harnais et hors DEEP dont la lecture du point 4 est faite, mais dont le dossier n'est pas encore READY (minuteur DWELL, jambe a rejouer, dossier a re-tamponner), recoit une review `APPROVED` sous `myia-ai-01` a la tete lue. `merge_ready` la merge des que le dossier est pret ; un rafraichissement de base sans conflit ne perime pas cette approbation, un commit de contenu ou une resolution de conflit la perime. Sans cette review, l'organe ne merge rien : il n'est plus une voie de merge sans lecteur. ### Phase 4bis - Passe issues, sur dossiers de fermeture (mandat user 2026-09-26) diff --git a/CLAUDE.md b/CLAUDE.md index e51b2b6d63..42bca7ed38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ Notation étudiants : moteur générique = [GradeBookApp/configs/README.md](Grad **Reporting dashboard** : poster au minimum début/livraison/fin de session. > 30 min sans post = signe d'isolement. Posts `[INFO]` courts > silence. -**Git** : pas de push direct sur `main`. **Force push** : interdit sur `main` (porté par `allow_force_pushes: false`), autorisé sur une branche de PR qu'une **seule** lane manipule (`--force-with-lease`, l'alternative merge d'abord) — décision user 2026-08-08. Pas de `reset --hard` sur `main` ni sur une branche partagée. Branches `feature/` ou `fix/`, un sujet par PR. Le coordinateur (ai-01) review et merge ; les agents ne mergent pas eux-mêmes. **Exception outillée** (mandat user 2026-09-22) : l'organe `scripts/coordination/merge_ready.py`, exécuté sous l'identité ai-01, merge sans attendre le cycle coordinateur une PR **hors harnais** (ni `.claude/`, ni `CLAUDE.md`, ni `.github/`) et **hors `DEEP`** qui passe gate rc=0 + B.0 rc=0 + `b0: clear` au dossier + `mergeable_state: clean` à la tête exacte ; dans ce périmètre, la lecture manuelle de B.0 (qui, quand, substance) est portée par le dossier de l'adjoint. Tout le reste reste au coordinateur, qui audite l'organe a posteriori. Cf [git-workflow.md](.claude/rules/git-workflow.md). +**Git** : pas de push direct sur `main`. **Force push** : interdit sur `main` (porté par `allow_force_pushes: false`), autorisé sur une branche de PR qu'une **seule** lane manipule (`--force-with-lease`, l'alternative merge d'abord) — décision user 2026-08-08. Pas de `reset --hard` sur `main` ni sur une branche partagée. Branches `feature/` ou `fix/`, un sujet par PR. Le coordinateur (ai-01) review et merge ; les agents ne mergent pas eux-mêmes. **Exception outillée** (mandat user 2026-09-22, resserré le 2026-09-28) : l'organe `scripts/coordination/merge_ready.py`, exécuté sous l'identité ai-01, merge sans attendre le cycle coordinateur une PR **hors harnais** (ni `.claude/`, ni `CLAUDE.md`, ni `.github/`) et **hors `DEEP`** **que le coordinateur a approuvée** — review `APPROVED` de `myia-ai-01` qui couvre le contenu de la tête (posée sur elle, ou séparée d'elle par des seuls rafraîchissements de base sans conflit) — et qui passe gate rc=0 + B.0 rc=0 + `b0: clear` au dossier + `mergeable_state: clean` à la tête exacte. L'organe ne remplace pas la lecture du coordinateur : il évite seulement qu'un dossier se périme entre cette lecture et le merge. Tout le reste reste au coordinateur. Cf [git-workflow.md](.claude/rules/git-workflow.md). **Gouvernance des règles** : tout changement normatif substantiel du harnais (`CLAUDE.md`, `.claude/rules/**`) exige une PR et un sign-off user avant merge. Est substantiel ce qui ajoute une obligation ou une interdiction, crée une règle HARD, durcit ou élargit une prescription, change une autorité, un droit d'action ou une escalade, ou transforme une recommandation en gate. Un mandat user direct vaut sign-off. Une correction de typo ou de lien, une clarification qui ne change aucune prescription, un déplacement fidèle du détail vers `docs/`, ou un slimming qui préserve exactement la règle n'exige pas de sign-off supplémentaire ; la PR et la review restent obligatoires dans tous les cas. diff --git a/docs/reference/agent-cloud-agnosticisme.md b/docs/reference/agent-cloud-agnosticisme.md index 254fd6d5e4..f06a5e6de1 100644 --- a/docs/reference/agent-cloud-agnosticisme.md +++ b/docs/reference/agent-cloud-agnosticisme.md @@ -31,7 +31,7 @@ Le compte GitHub est partagé avec la flotte : une PR de cette session se reconn La session **produit** les PRs ; **la flotte les porte jusqu'au merge**, comme les siennes (mandat du mainteneur : économie de tokens, charge mentale retirée au mainteneur) : - la session **s'abonne aux événements** de ses PRs (CI, reviews, commentaires) : le suivi ne repose plus sur le mainteneur ; -- l'adjoint prévalide (`[ADJOINT PREFLIGHT]`), ai-01 merge ; `scripts/coordination/merge_ready.py` s'applique aux PRs hors harnais ; +- l'adjoint prévalide (`[ADJOINT PREFLIGHT]`), ai-01 lit et merge ; `scripts/coordination/merge_ready.py` merge les PRs hors harnais qu'ai-01 a approuvées ; - **réparation déléguée** : sur un check rouge ou une réserve, la session poste le diagnostic sur la PR (ce qui échoue, pourquoi, correctif proposé) puis ouvre une **issue de réparation** qui renvoie à la PR — le pool d'issues est le canal où les lanes tirent leur travail. Elle ne corrige elle-même que lorsque le correctif est plus court que l'issue ; - une lane peut rafraîchir la branche (`update-branch`) ou y pousser la correction, en nommant le script et le commit (règle 0 de [proactive-coordination.md](../../.claude/rules/proactive-coordination.md)) ; - à chaque tour, la session intègre ce qui a changé — corrections de la flotte, décisions, remède meilleur que le sien — puis enchaîne sur la PR suivante. diff --git a/scripts/ci/merge_dwell.py b/scripts/ci/merge_dwell.py index 80b07d3b24..c3535c4f36 100644 --- a/scripts/ci/merge_dwell.py +++ b/scripts/ci/merge_dwell.py @@ -481,6 +481,31 @@ def last_authoritative_committed_at( ) -> datetime: """#16149 : date de COMMITTER du dernier commit qui modifie le cote PR. + La remontee est celle de `last_authoritative_sha` ; seule la date du + commit atteint est rendue ici.""" + current, payload = _walk_authoritative(repo, sha, base_sha, fetch, run_git) + return _committer_date(payload, current) + + +def last_authoritative_sha( + repo: str, + sha: str, + base_sha: str, + fetch=_gh_json, + run_git=None, +) -> str: + """SHA du dernier commit qui modifie le cote PR (meme remontee que le + plancher DWELL). Deux tetes qui rendent le meme SHA ne different que par + des rafraichissements de base PROUVES content-free : c'est ce que lit + `merge_ready` pour savoir si l'approbation du coordinateur, posee sur une + tete anterieure, couvre encore le contenu de la tete courante.""" + current, _payload = _walk_authoritative(repo, sha, base_sha, fetch, run_git) + return current + + +def _walk_authoritative(repo, sha, base_sha, fetch, run_git): + """Remontee first-parent commune au plancher DWELL et a `merge_ready`. + Remonte la chaine first-parent au-dela des fusions de rafraichissement de base PROUVEES content-free : deux parents, le SECOND ancetre de la base, ET l'arbre du commit identique a l'auto-merge des parents (CR @@ -515,8 +540,8 @@ def last_authoritative_committed_at( if auto_tree and merge_tree and auto_tree == merge_tree: current = first continue - return _committer_date(payload, current) - return _committer_date(payload, current) + return current, payload + return current, payload raise DwellError( "chaine first-parent de plus de {} fusions de base depuis {} " "-- etat pathologique, refus".format(_MAX_WALK, sha[:12]) diff --git a/scripts/coordination/README.md b/scripts/coordination/README.md index efc4792a3d..754c8237e2 100644 --- a/scripts/coordination/README.md +++ b/scripts/coordination/README.md @@ -255,11 +255,13 @@ d'enveloppe du producteur (`data.intercom.messages`, auteur normalise). ## Organe `merge_ready` (Q40, 2026-09-22) Fusion hors cycle coordinateur : un organe deterministe (identite myia-ai-01, -cadence ~20 min) qui merge UNIQUEMENT ce qui passe exactement les controles du -coordinateur lui-meme, en perimetre (b) uniquement -- hors harnais (`.claude/`, -`CLAUDE.md` a tout niveau, `.github/`) et hors grains `DEEP`. Motivation -mesuree : 97 merges en 24 h sur 4 creneaux, 12 heures vides, lead time median -28,5 h ; un dossier d'adjoint perit en attendant le cycle. +cadence ~20 min) qui merge UNIQUEMENT ce que le coordinateur a lu et approuve, +en perimetre (b) uniquement -- hors harnais (`.claude/`, `CLAUDE.md` a tout +niveau, `.github/`) et hors grains `DEEP`. Motivation mesuree : 97 merges en +24 h sur 4 creneaux, 12 heures vides, lead time median 28,5 h ; un dossier +d'adjoint perit en attendant le cycle. L'organe evite cette peremption, il ne +remplace pas la lecture (arbitrage user 2026-09-28, Q67 : jusque-la 6 des 211 +merges du journal portaient une approbation `myia-ai-01`). Par PR (la plus ancienne d'abord), TOUT doit tenir sinon skip avec raison nommee au journal : pas un brouillon + un commentaire `[ADJOINT PREFLIGHT]` @@ -267,7 +269,11 @@ nommee au journal : pas un brouillon + un commentaire `[ADJOINT PREFLIGHT]` superieur aux fichiers listes = skip -- et tier du tag `Grain:` lu par le parseur partage `scripts/grain_tag.py`), pre-controle bon marche du dernier dossier (tete perimee ou `b0:` non clear = skip sans payer le gate ; illisible = decision -laissee au gate), gate `check_adjoint_prevalidation.py` a +laissee au gate), approbation du coordinateur (derniere voix `myia-ai-01` = +`APPROVED` reel, posee sur la tete ou sur une tete dont celle-ci ne differe que +par des rafraichissements de base prouves content-free -- meme remontee que le +plancher DWELL, `merge_dwell.last_authoritative_sha` ; absente, perimee ou +illisible = skip avant le gate), gate `check_adjoint_prevalidation.py` a `ready: true`, champ `b0:` du dossier accepte relu via la grammaire du gate (`parse_dossier` importe), organe B.0 `check_unaddressed_nits.py` a exit 0, `mergeable_state` REST a `clean` (retry sur `unknown` -- apres un merge les diff --git a/scripts/coordination/merge_ready.py b/scripts/coordination/merge_ready.py index c3391afba9..a8f68b78b4 100644 --- a/scripts/coordination/merge_ready.py +++ b/scripts/coordination/merge_ready.py @@ -9,11 +9,17 @@ discussion ayant bouge APRES le dossier, 0 un refus de fond. Un dossier perit pendant qu'il attend le coordinateur. +Arbitrage user 2026-09-28 (Q67, option a) : l'organe ne merge QUE ce que le +coordinateur a LU et APPROUVE. Il ne remplace pas cette lecture ; il evite +seulement qu'un dossier perisse entre elle et le merge. Jusque-la, la +disposition de review n'etait que relevee : sur les 211 merges du journal +(23/09 -> 28/09), 6 portaient une approbation ``myia-ai-01``, et toute lane +ne portant pas la PR pouvait la faire merger par son seul dossier. + L'organe est deterministe et tourne sous l'identite coordinateur -(myia-ai-01) toutes les ~20 minutes : il ne merge QUE ce qui passe -EXACTEMENT les controles du coordinateur lui-meme, et seulement en -perimetre (b) -- hors harnais et hors grains DEEP. Tour de controle par -PR, TOUT doit tenir sinon skip avec raison nommee : +(myia-ai-01) toutes les ~20 minutes, et seulement en perimetre (b) -- hors +harnais et hors grains DEEP. Tour de controle par PR, TOUT doit tenir sinon +skip avec raison nommee : 1. pas un brouillon, et au moins un commentaire d'issue dont la premiere ligne est ``[ADJOINT PREFLIGHT]`` (prefiltre bon marche avant le gate @@ -31,6 +37,18 @@ 2bis. pre-controle du dernier dossier ``[ADJOINT PREFLIGHT]`` : tete perimee ou ``b0:`` different de ``clear`` -> skip SANS payer le gate (un dossier illisible est laisse au gate, qui tranche) ; +2ter. approbation du coordinateur : la DERNIERE voix de ``myia-ai-01`` + (etat reel ``APPROVED``/``CHANGES_REQUESTED``, ou verdict type en + corps -- latest-wins, ``DISMISSED`` jamais) est un ``APPROVED`` reel, + et elle couvre le CONTENU de la tete : soit elle est posee sur la tete + elle-meme, soit la tete n'en differe que par des rafraichissements de + base PROUVES content-free (``merge_dwell.last_authoritative_sha``, la + remontee du plancher DWELL : un ``update-branch`` sans conflit ne + perime pas la lecture, une resolution de conflit ou un commit de + contenu la perime). Absente -> ``no-coordinator-approval`` ; perimee + -> ``coordinator-approval-stale`` ; preuve illisible -> + ``coordinator-approval-unverifiable`` (fail-closed). Controle place + AVANT le gate : une PR non lue ne paie pas le gate ; 3. gate d'entree ``check_adjoint_prevalidation.py --json`` -> ``"ready": true`` (exit 0). Les rc documents du gate (1 no-dossier, 2 unknown, 3 blocked) sont des SKIPS nommes, pas des erreurs ; @@ -80,13 +98,11 @@ (``hold:``) AVANT tout appel gh. Fichier absent = aucune retenue ; fichier illisible ou ligne malformee -> exit 2 (on ne merge pas sans savoir ce qui est retenu). -- Review : la disposition de review est CLASSEE a la tete que la ligne - declare (``approved-exact-head`` / ``approval-not-on-head`` / - ``no-approval``, point 1 de #17672) depuis les ``reviews`` de la meme - vue -- l'oid de review y figure, aucun appel supplementaire. Le - dossier hache cet oid sans le comparer a la tete ; c'est cette - comparaison qui manquait. Elle informe, elle ne bloque pas : le - dossier READY a la tete exacte reste le contrat d'entree. +- Review : deux lectures distinctes. La disposition journalisee (champ + ``review``) est CLASSEE a la tete que la ligne declare + (``approved-exact-head`` / ``approval-not-on-head`` / ``no-approval``, + point 1 de #17672) sur TOUTES les voix, bots compris ; elle informe. + Ce qui BLOQUE est l'etape 2ter : la seule voix du coordinateur. - Journal : une ligne JSON par PR evaluee (ts UTC en Z, pr, head, verdict, reason, merged, review) dans ``%LOCALAPPDATA%/CoursIA/merge_ready/journal.jsonl`` (surchargeable @@ -145,6 +161,11 @@ sys.path.insert(0, str(CI_DIR)) import pool_review_verdicts as review_canon # noqa: E402 +# La remontee first-parent qui saute les rafraichissements de base PROUVES +# content-free est celle du plancher DWELL (#16149) : importee, pas recopiee, +# pour que « la lecture du coordinateur couvre-t-elle encore cette tete ? » et +# « depuis quand le contenu n'a-t-il pas bouge ? » ne divergent jamais. +import merge_dwell # noqa: E402 REPO = "jsboige/CoursIA" COORDINATOR_USER = "myia-ai-01" @@ -481,8 +502,8 @@ def list_open_prs(runner: Runner, gh_env: dict[str, str]) -> list[int]: #: review se classe sans appel supplementaire (l'oid de review y figure, mesure #: du 2026-09-25 : ``gh pr view --json reviews`` rend ``commit.oid``). PR_VIEW_FIELDS = ( - "number,title,isDraft,body,headRefName,headRefOid,files,changedFiles," - "comments,reviews" + "number,title,isDraft,body,headRefName,headRefOid,baseRefOid,files," + "changedFiles,comments,reviews" ) @@ -557,6 +578,95 @@ def review_disposition(view: dict, head: str) -> str: return NO_APPROVAL +# --- approbation du coordinateur (etape 2ter, Q67 option a) -------------------- + + +def coordinator_approval_oid(view: dict) -> str | None: + """Oid du commit que la DERNIERE voix de ``myia-ai-01`` approuve, ou None. + + Latest-wins sur les seules voix du coordinateur, lues comme le canon (etat + reel, puis verdict type en corps) : un ``CHANGES_REQUESTED`` ou un + ``VERDICT: CONCERNS`` posterieur retire l'approbation, un ``COMMENTED`` + sans verdict (une phrase de levee, par exemple) ne la retire pas. Seul + l'etat REEL ``APPROVED`` vaut approbation : c'est le geste explicite de + lecture, pas un jeton de corps. + """ + voices = [ + row + for row in view.get("reviews") or [] + if isinstance(row, dict) + and ((row.get("author") or {}).get("login")) == COORDINATOR_USER + and _review_voice_state(row) + ] + if not voices: + return None + latest = max(voices, key=lambda row: str(row.get("submittedAt") or "")) + if str(latest.get("state") or "") != "APPROVED": + return None + oid = str(((latest.get("commit") or {}).get("oid")) or "") + return oid or None + + +def _dwell_fetch(runner: Runner, gh_env: dict[str, str]): + """``fetch`` de ``merge_dwell`` passe par le runner, jeton epingle.""" + + def fetch(path: str) -> object: + res = runner.run(["gh", "api", path], env=gh_env) + if res.returncode != 0: + raise merge_dwell.DwellError(f"gh api {path} rc={res.returncode}") + try: + return json.loads(res.stdout) + except json.JSONDecodeError as exc: + raise merge_dwell.DwellError(f"gh api {path} : reponse non-JSON") from exc + + return fetch + + +def _dwell_git(runner: Runner): + """``run_git`` de ``merge_dwell`` passe par le runner, dans ce depot.""" + + def run_git(args: list[str]) -> tuple[int, str]: + res = runner.run(["git", "-C", str(REPO_ROOT), *args]) + return res.returncode, res.stdout + + return run_git + + +def coordinator_approval_reason( + view: dict, head: str, runner: Runner, gh_env: dict[str, str] +) -> str | None: + """Etape 2ter : ``None`` si l'approbation du coordinateur couvre ``head``. + + Une approbation posee sur une tete anterieure couvre encore la tete + courante si les deux ne different que par des rafraichissements de base + prouves content-free : les deux tetes remontent alors au MEME dernier + commit de contenu. Toute autre difference perime la lecture ; une preuve + illisible vaut refus. + """ + approved = coordinator_approval_oid(view) + if approved is None: + return "no-coordinator-approval" + if approved == head: + return None + base = str(view.get("baseRefOid") or "") + if not base: + return "coordinator-approval-unverifiable:no-base" + fetch = _dwell_fetch(runner, gh_env) + run_git = _dwell_git(runner) + try: + current = merge_dwell.last_authoritative_sha( + REPO, head, base, fetch=fetch, run_git=run_git + ) + read = merge_dwell.last_authoritative_sha( + REPO, approved, base, fetch=fetch, run_git=run_git + ) + except merge_dwell.DwellError: + return "coordinator-approval-unverifiable" + if current == read: + return None + return "coordinator-approval-stale" + + def precheck_dossier(view: dict) -> str | None: """Pre-controle bon marche, AVANT le gate : le dernier dossier visible dans la vue est-il a la tete courante, et declare-t-il ``b0: clear`` ? @@ -806,12 +916,20 @@ def skip(reason: str) -> PRVerdict: if reason is not None: return skip(reason) reason = precheck_dossier(view) + if reason is not None: + return skip(reason) + # 2ter. approbation du coordinateur (Q67) : une PR non lue ne paie pas le gate. + reason = coordinator_approval_reason(view, view_head, runner, gh_env) if reason is not None: return skip(reason) # 3. gate d'entree. ready, gate_head, gate_reason = run_gate(runner, pr, gh_env) if not ready: return skip(gate_reason) + # L'approbation a ete jugee a la tete de la vue : une tete differente au + # gate n'a pas ete couverte par ce jugement. + if gate_head != view_head: + return skip("head-moved") # 4. b0 declaratif du dossier accepte. reason = dossier_b0_reason(runner, pr, gh_env) if reason is not None: diff --git a/scripts/tests/test_merge_dwell.py b/scripts/tests/test_merge_dwell.py index 63f256e159..a539cbf836 100644 --- a/scripts/tests/test_merge_dwell.py +++ b/scripts/tests/test_merge_dwell.py @@ -838,3 +838,41 @@ def test_git_helper_immune_to_path_pollution(tmp_path, monkeypatch): assert "git version" in r.stdout, "stdout != vrai git : {!r}".format(r.stdout[:80]) assert not marker.exists(), "le git factice du PATH pollue a ete appele" assert Path(_GIT).name.lower().startswith("git"), _GIT + + +# --- 7. Q67 -- le meme SHA de contenu sert a merge_ready ----------------------- + +def _refresh_fetch(tree): + def fetch(path): + if path == "repos/o/r/commits/m3rg3": + return _commit( + "m3rg3", "2026-09-07T11:59:00Z", ["auc0", "ba5e"], tree=tree + ) + if path == "repos/o/r/commits/auc0": + return _commit("auc0", "2026-09-07T09:00:00Z", ["r00t"]) + raise AssertionError("chemin inattendu: " + path) + return fetch + + +def test_q67_sha_de_contenu_franchit_un_rafraichissement_prouve(): + sha = merge_dwell.last_authoritative_sha( + "o/r", "m3rg3", "ba5e", fetch=_refresh_fetch("7ee0"), + run_git=_git_proving("7ee0"), + ) + assert sha == "auc0" + + +def test_q67_sha_de_contenu_s_arrete_sur_une_resolution_d_auteur(): + sha = merge_dwell.last_authoritative_sha( + "o/r", "m3rg3", "ba5e", fetch=_refresh_fetch("d1ff"), + run_git=_git_proving("7ee0"), + ) + assert sha == "m3rg3" + + +def test_q67_date_et_sha_suivent_la_meme_remontee(): + fetch = _refresh_fetch("7ee0") + when = merge_dwell.last_authoritative_committed_at( + "o/r", "m3rg3", "ba5e", fetch=fetch, run_git=_git_proving("7ee0"), + ) + assert when.isoformat().startswith("2026-09-07T09:00:00") diff --git a/scripts/tests/test_merge_ready.py b/scripts/tests/test_merge_ready.py index 4a7299ba45..8f7e4da8bf 100644 --- a/scripts/tests/test_merge_ready.py +++ b/scripts/tests/test_merge_ready.py @@ -23,6 +23,12 @@ TOKEN = "tok-myia-ai-01-fake" GRAIN_MED = "Grain: MED/guard -- lane myia-po-2026:CoursIA -- prev: MED/guard #1" GRAIN_DEEP = "Grain: DEEP/lean -- lane myia-po-2026:CoursIA -- prev: MED/guard #1" +BASE = "ba5eba5eba5eba5eba5eba5eba5eba5eba5eba5e" +# Sentinelle : « pas precise » n'est pas « aucune approbation ». Par defaut une +# vue porte l'approbation du coordinateur a la tete (le chemin nominal depuis +# Q67) ; une vue dont les reviews sont fournies explicitement n'en recoit que si +# on la demande. +_DEFAULT = object() def dossier_body(b0: str = "clear") -> str: @@ -68,19 +74,27 @@ def default_view( comments: list[dict] | None = None, title: str = "fix(x): une PR ordinaire", reviews: list[dict] | None = None, + coordinator=_DEFAULT, + head: str = HEAD, ) -> dict: + rows = list(reviews) if reviews is not None else [] + if coordinator is _DEFAULT: + coordinator = head if reviews is None else None + if coordinator is not None: + rows.append(coordinator_review(oid=coordinator)) return { "number": pr, "title": title, "isDraft": draft, "body": body if body is not None else GRAIN_MED, - "headRefOid": HEAD, + "headRefOid": head, + "baseRefOid": BASE, "files": [{"path": p} for p in files], "changedFiles": len(files), "comments": comments if comments is not None else [{"body": dossier_body()}], - "reviews": reviews if reviews is not None else [], + "reviews": rows, } @@ -102,6 +116,19 @@ def review_row( } +def coordinator_review( + *, + state: str = "APPROVED", + oid: str = HEAD, + submitted: str = "2026-09-25T01:00:00Z", + body: str = "", +) -> dict: + """Une voix du coordinateur (``myia-ai-01``), anterieure aux voix de bot par defaut.""" + return review_row( + state=state, oid=oid, submitted=submitted, body=body, login="myia-ai-01" + ) + + class ScriptedRunner: """Runner fake : dispatch par contenu de commande. Defauts = chemin nominal (une PR unique prete au merge, gate READY, b0 clear, B.0 clear, clean).""" @@ -121,6 +148,8 @@ def __init__( gate_stderr: str = "", fetch_rc: int = 0, twin_rc: int = 0, + commits: dict[str, dict] | None = None, + auto_tree: str | None = None, ): self.token = token self.token_rc = token_rc @@ -136,6 +165,10 @@ def __init__( self.gate_stderr = gate_stderr self.fetch_rc = fetch_rc self.twin_rc = twin_rc + # Remontee first-parent de merge_dwell (etape 2ter) : payloads de + # ``repos/.../commits/`` et arbre rendu par ``git merge-tree``. + self.commits = commits or {} + self.auto_tree = auto_tree self.calls: list[tuple[list[str], dict | None]] = [] self.sleeps: list[float] = [] @@ -171,6 +204,17 @@ def run(self, cmd: list[str], env: dict | None = None) -> mr.RunResult: if c[:2] == ["gh", "api"] and "/comments" in c[2]: rows = self.comments if self.comments is not None else rest_comments() return mr.RunResult(0, json.dumps(rows), "") + if c[:2] == ["gh", "api"] and "/commits/" in c[2]: + sha = c[2].rsplit("/", 1)[-1] + if sha not in self.commits: + return mr.RunResult(1, "", "HTTP 404") + return mr.RunResult(0, json.dumps(self.commits[sha]), "") + if c[:1] == ["git"] and "merge-tree" in c: + if self.auto_tree is None: + return mr.RunResult(1, "", "conflict") + return mr.RunResult(0, self.auto_tree + "\n", "") + if c[:1] == ["git"] and ("cat-file" in c or "merge-base" in c): + return mr.RunResult(0, "", "") if c[:2] == ["gh", "api"] and "/pulls/" in c[2]: row = self.pulls.pop(0) if len(self.pulls) > 1 else self.pulls[0] return mr.RunResult(0, json.dumps(row), "") @@ -535,7 +579,7 @@ def test_journal_ligne_par_pr(tmp_path): # Une ligne mergee porte la disposition CLASSEE, pas « non evaluee » : # le verdict terminal est reconstruit apres le merge, il doit heriter de # la classification faite avant. - assert row["review"] == mr.NO_APPROVAL + assert row["review"] == mr.APPROVED_EXACT_HEAD assert [row["pr"] for row in lines] == [401, 402] # ancienne d'abord @@ -799,7 +843,7 @@ def test_disposition_review_dismissed_n_est_jamais_approbatrice(): def test_disposition_sans_approbation_lue(): - assert mr.review_disposition(default_view(), HEAD) == mr.NO_APPROVAL + assert mr.review_disposition(default_view(coordinator=None), HEAD) == mr.NO_APPROVAL # Une voix qui ne type pas de verdict n'est pas une approbation. view = default_view(reviews=[review_row(state="COMMENTED")]) assert mr.review_disposition(view, HEAD) == mr.NO_APPROVAL @@ -809,10 +853,15 @@ def test_deux_prs_qui_ne_different_que_par_la_tete_de_l_approbation(tmp_path): """Le defaut vise : a tout le reste egal, l'organe ne distinguait pas une PR approuvee a la tete de la PR approuvee sur un commit anterieur.""" views = { - 201: default_view(pr=201, reviews=[review_row(oid=HEAD)]), - 202: default_view(pr=202, reviews=[review_row(oid=HEAD_MOVED)]), + 201: default_view(pr=201, reviews=[review_row(oid=HEAD)], coordinator=HEAD), + 202: default_view( + pr=202, reviews=[review_row(oid=HEAD_MOVED)], coordinator=HEAD_MOVED + ), } - rc, lines, _ = run_organ(tmp_path, ScriptedRunner(prs=(201, 202), views=views)) + rc, lines, _ = run_organ( + tmp_path, + ScriptedRunner(prs=(201, 202), views=views, **refresh_topology()), + ) assert rc == 0 assert [row["verdict"] for row in lines] == ["would-merge", "would-merge"] reste = [ @@ -833,10 +882,15 @@ def test_un_skip_porte_la_disposition_de_la_tete_evaluee(tmp_path): def test_le_bilan_compte_les_candidates_par_disposition(tmp_path, capsys): views = { - 201: default_view(pr=201, reviews=[review_row(oid=HEAD)]), - 202: default_view(pr=202, reviews=[review_row(oid=HEAD_MOVED)]), + 201: default_view(pr=201, reviews=[review_row(oid=HEAD)], coordinator=HEAD), + 202: default_view( + pr=202, reviews=[review_row(oid=HEAD_MOVED)], coordinator=HEAD_MOVED + ), } - run_organ(tmp_path, ScriptedRunner(prs=(201, 202), views=views)) + run_organ( + tmp_path, + ScriptedRunner(prs=(201, 202), views=views, **refresh_topology()), + ) out = capsys.readouterr().out assert "candidates : 1 approved-exact-head, 1 approval-not-on-head" in out assert "[review: approved-exact-head]" in out @@ -897,3 +951,151 @@ def test_twin_fetch_failure_is_fail_closed(tmp_path): assert lines[0]["verdict"] == "skipped" assert lines[0]["reason"] == "twin-collision-unreadable:fetch" assert not any("check_twin_index_collisions.py" in f for f in runner.flat()) + + +# --- 2ter. approbation du coordinateur (Q67, arbitrage user 2026-09-28) ---------- + + +def refresh_topology(*, tree: str | None = "7ee0", head_parents=None) -> dict: + """HEAD = fusion de rafraichissement de base au-dessus de HEAD_MOVED. + + ``tree`` est l'arbre de la fusion ; ``auto_tree`` rendu par merge-tree est + toujours ``7ee0`` -- les deux egaux = fusion PROUVEE content-free. + """ + parents = head_parents or [HEAD_MOVED, BASE] + head_commit = {"commit": {"committer": {"date": "2026-09-28T10:00:00Z"}}, + "parents": [{"sha": p} for p in parents]} + if tree is not None: + head_commit["commit"]["tree"] = {"sha": tree} + return { + "commits": { + HEAD: head_commit, + HEAD_MOVED: { + "commit": {"committer": {"date": "2026-09-28T08:00:00Z"}}, + "parents": [{"sha": "r00t"}], + }, + }, + "auto_tree": "7ee0", + } + + +def test_sans_approbation_du_coordinateur_skip_avant_le_gate(tmp_path): + runner = ScriptedRunner(views={123: default_view(coordinator=None)}) + rc, lines, _ = run_organ(tmp_path, runner, extra=("--apply",)) + assert rc == 0 + assert lines[-1]["reason"] == "no-coordinator-approval" + assert lines[-1]["merged"] is False + assert not any("check_adjoint_prevalidation.py" in f for f in runner.flat()) + + +def test_approbation_d_un_autre_login_ne_compte_pas(tmp_path): + """Un APPROVED de bot ou du login partage des lanes n'est pas la lecture + du coordinateur, meme a la tete exacte.""" + for login in ("clusterManager-Myia", "jsboige"): + view = default_view(reviews=[review_row(oid=HEAD, login=login)]) + runner = ScriptedRunner(views={123: view}) + _, lines, _ = run_organ(tmp_path, runner) + assert lines[-1]["reason"] == "no-coordinator-approval", login + + +def test_changes_requested_posterieur_retire_l_approbation(tmp_path): + view = default_view( + reviews=[ + coordinator_review(oid=HEAD), + coordinator_review( + state="CHANGES_REQUESTED", oid=HEAD, submitted="2026-09-26T00:00:00Z" + ), + ] + ) + _, lines, _ = run_organ(tmp_path, ScriptedRunner(views={123: view})) + assert lines[-1]["reason"] == "no-coordinator-approval" + + +def test_verdict_concerns_en_corps_posterieur_retire_l_approbation(tmp_path): + view = default_view( + reviews=[ + coordinator_review(oid=HEAD), + coordinator_review( + state="COMMENTED", oid=HEAD, submitted="2026-09-26T00:00:00Z", + body="VERDICT: CONCERNS\npoint a reprendre", + ), + ] + ) + _, lines, _ = run_organ(tmp_path, ScriptedRunner(views={123: view})) + assert lines[-1]["reason"] == "no-coordinator-approval" + + +def test_commented_sans_verdict_posterieur_ne_retire_pas_l_approbation(tmp_path): + """Une phrase de levee postee en review n'est pas une voix : l'approbation tient.""" + view = default_view( + reviews=[ + coordinator_review(oid=HEAD), + coordinator_review( + state="COMMENTED", oid=HEAD, submitted="2026-09-26T00:00:00Z", + body="Point 2 leve : traite par le commit abc.", + ), + ] + ) + _, lines, _ = run_organ(tmp_path, ScriptedRunner(views={123: view})) + assert lines[-1]["verdict"] == "would-merge" + + +def test_approbation_dismissed_ne_compte_pas(tmp_path): + view = default_view(reviews=[coordinator_review(state="DISMISSED", oid=HEAD)]) + _, lines, _ = run_organ(tmp_path, ScriptedRunner(views={123: view})) + assert lines[-1]["reason"] == "no-coordinator-approval" + + +def test_rafraichissement_de_base_prouve_ne_perime_pas_la_lecture(tmp_path): + """Approbation sur HEAD_MOVED, puis update-branch sans conflit -> HEAD : + la lecture couvre encore le contenu, l'organe merge.""" + view = default_view(coordinator=HEAD_MOVED) + runner = ScriptedRunner(views={123: view}, **refresh_topology()) + rc, lines, _ = run_organ(tmp_path, runner, extra=("--apply",)) + assert rc == 0 + assert lines[-1]["merged"] is True + + +def test_resolution_de_conflit_perime_la_lecture(tmp_path): + """Meme topologie, mais l'arbre de la fusion differe de l'auto-merge : + du contenu d'auteur est entre, la lecture est perimee.""" + view = default_view(coordinator=HEAD_MOVED) + runner = ScriptedRunner(views={123: view}, **refresh_topology(tree="d1ff")) + _, lines, _ = run_organ(tmp_path, runner, extra=("--apply",)) + assert lines[-1]["reason"] == "coordinator-approval-stale" + assert lines[-1]["merged"] is False + + +def test_commit_de_contenu_apres_l_approbation_perime_la_lecture(tmp_path): + """HEAD est un commit ordinaire au-dessus de HEAD_MOVED : perime.""" + view = default_view(coordinator=HEAD_MOVED) + runner = ScriptedRunner( + views={123: view}, **refresh_topology(head_parents=[HEAD_MOVED]) + ) + _, lines, _ = run_organ(tmp_path, runner) + assert lines[-1]["reason"] == "coordinator-approval-stale" + + +def test_preuve_illisible_fail_closed(tmp_path): + view = default_view(coordinator=HEAD_MOVED) + runner = ScriptedRunner(views={123: view}, commits={}) + _, lines, _ = run_organ(tmp_path, runner) + assert lines[-1]["reason"] == "coordinator-approval-unverifiable" + + +def test_base_absente_fail_closed(tmp_path): + view = default_view(coordinator=HEAD_MOVED) + view["baseRefOid"] = "" + _, lines, _ = run_organ(tmp_path, ScriptedRunner(views={123: view})) + assert lines[-1]["reason"] == "coordinator-approval-unverifiable:no-base" + + +def test_tete_du_gate_differente_de_la_tete_lue(tmp_path): + """L'approbation est jugee a la tete de la vue ; si le gate evalue une autre + tete, ce jugement ne la couvre pas.""" + body = dossier_body().replace(HEAD, HEAD_MOVED) + view = default_view( + head=HEAD_MOVED, coordinator=HEAD_MOVED, comments=[{"body": body}] + ) + _, lines, _ = run_organ(tmp_path, ScriptedRunner(views={123: view})) + assert lines[-1]["reason"] == "head-moved"