Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .claude/rules/merge-cycle-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Contrat du cycle de merge — ce qui se rend, dans quel ordre, sous quel nom

S'applique aux **trois roles** qui produisent les merges du depot : le coordinateur `myia-ai-01:CoursIA`, le titulaire `myia-po-2025:CoursIA-2`, le secretaire `myia-po-2026:CoursIA-3`. Source : mandat user 2026-09-22 — « on a souvent des regressions car les bonnes pratiques ne sont pas cristallisees avant d'etre oubliees, c'est peut-etre le bon moment de mettre les choses en dur ».

Les **mecanismes** du gate (codes de sortie, empreinte, `[ADJOINT PREFLIGHT]`) vivent dans les skills `coordinate` / `coordinate-adjoint` et dans [coordinator-discipline.md](coordinator-discipline.md). Cette regle ne les repete pas : elle fixe les **quatre gestes** dont l'oubli a un cout mesure, et que rien n'empechait d'oublier.

## Regle 1 — un lot se rend NOMINATIVEMENT, jamais en compte (HARD)

Un lot de PRs pre-machees se rend en **citant chaque PR**. Un cumul (« 56 OK B.0 frais », « la file est prete ») n'est pas une livraison : c'est une affirmation que le destinataire doit re-etablir a l'aveugle, et cette re-decouverte **perime** les dossiers qu'elle traverse.

Mesure du 2026-09-22, trois lots reels sur le meme cycle :

| Format rendu | Conversion en merge |
|---|---:|
| liste **nominative** (PRs citees une a une) | **10/10 — 100 %** |
| **cumul** non nomme | **15/53 — 28 %** |
| auto-tire par le coordinateur | **2/54 — 4 %** |

Le travail sous-jacent etait **le meme** dans les trois cas. L'ecart vient entierement du format du rendu. **C'est le levier de debit le plus rentable mesure a ce jour**, avant toute optimisation de quota ou de cout d'appel.

Corollaire : un lot se rend **au fil de l'eau**, jamais en barriere. Chaque dossier pret remonte seul ; attendre d'avoir le lot complet ajoute de la peremption sans ajouter d'information.

## Regle 2 — le dossier se pose EN DERNIER (HARD)

Toute ecriture tierce sur les surfaces de discussion **posterieure** au dossier le perime. Auditer une PR, y repondre, y poster un constat : chacun de ces gestes invalide une attestation deja ecrite — **y compris la sienne propre**.

L'ordre est donc : lire, ecrire tout ce qu'on a a ecrire, **puis** attester. Mesure du 2026-09-22 : **12 des 32** refus `rc=1` du cycle portaient `discussion changed after dossier` — un dossier juste, tue par une ecriture qui a suivi.

La piece jumelle vit dans [git-workflow.md](git-workflow.md) : **la branche est gelee entre le dossier et le merge**. Un dossier a besoin d'une branche silencieuse, sinon le travail de prevalidation est detruit par le travail de reparation, indefiniment.

## Regle 3 — `BLOCKED` vaut autant que `READY`, et ne se maquille jamais (HARD)

Un dossier `verdict: BLOCKED` est une **livraison complete**, pas un echec : il laisse le coordinateur dispatcher depuis un motif atteste **sans ouvrir les surfaces**, donc sans les perimer.

**N'ecrire jamais `READY` pour rendre son travail visible.** Un `b0: clear` faux a deja ete mesure sur une PR portant 3 findings HIGH ouverts. Le verdict decrit l'etat de la PR, jamais l'effort fourni.

Symetrique cote coordinateur : un `BLOCKED` se dispatche **depuis son motif**, il ne se re-audite pas. Et quand la cause est d'**infrastructure** (parc de runners, `main` rouge, minuteur `DWELL`), l'attester en la **nommant** — renvoyer la PR a son auteur pour reparation lui demande de reparer ce qui n'est pas chez lui.

## Regle 4 — un levier se rend AVEC sa portee (HARD)

Toute amelioration proposee ou livree nomme, dans la **meme phrase**, ce qu'elle **ne** traite **pas**. Un levier rendu sans sa portee laisse croire que le probleme est traite, et ferme l'enquete sur la cause dominante.

Cas fondateur : le double fetch des check-runs du gate (#17390) reduit le cout **par appel**. La cause dominante mesuree de la consommation est la **peremption** — des dossiers produits puis jamais consommes. Rendre le premier sans nommer la seconde aurait fait passer un gain marginal pour une solution.

Corollaire, porte par [[a-correct-fix-can-perime-the-whole-fleet-at-once]] : un changement **juste** de l'ensemble des champs qui composent une **empreinte** perime **toutes** les attestations en vol d'un coup. Son livrable est une **issue** avec sa fenetre et son controle positif, pas un commit de passage.

## Partition des emetteurs — pas d'auto-attestation

Le gate compare la lane du dossier au tag `Grain:` de la PR : une lane ne peut pas attester ce qu'elle porte. La partition d'un gisement se fait donc **par lane porteuse**, et se declare au moment du dispatch.

Le login GitHub etant partage, le champ `lane` est une **declaration fail-closed**, pas une preuve d'identite. Ce que le gate exige est que la prevalidation soit **tierce**, pas qu'elle vienne d'une lane nommee.

## Mesures et incidents

Chiffres, distribution des refus par motif, et le detail du cycle qui fonde ces quatre regles : [docs/reference/merge-cycle-measures.md](../../docs/reference/merge-cycle-measures.md).

## Voir aussi

- [coordinator-discipline.md](coordinator-discipline.md) — mecanique du gate et des roles
- [git-workflow.md](git-workflow.md) — gel de branche, `update-branch` et peremption du dossier
- [pr-review-discipline.md](pr-review-discipline.md) — §B.0, emission des marqueurs de reserve
- [harness-hygiene.md](harness-hygiene.md) — les 3 tiers : pourquoi le detail est en `docs/`
90 changes: 90 additions & 0 deletions docs/reference/merge-cycle-measures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Cycle de merge — mesures qui fondent le contrat

Detail de [`.claude/rules/merge-cycle-contract.md`](../../.claude/rules/merge-cycle-contract.md). Le harnais porte les quatre regles ; ce fichier porte les chiffres, pour que le harnais reste succinct ([harness-hygiene](../../.claude/rules/harness-hygiene.md)).

## Le cycle du 2026-09-22 — 30 merges, et ou sont partis les refus

30 PRs mergees sur la fenetre, mesure firsthand (`search/issues is:pr is:merged merged:>=<debut>` = 30), corroborant exactement la somme des trois passes du cycle (2 + 10 + 18). Le mandat user de ~20 merges par cycle est tenu.

Sur **80 candidates** passees au gate d'entree :

| Issue | Compte | Ce que ca dit |
|---|---:|---|
| mergees | 18 | — |
| **pas de dossier digne de confiance** (`rc=1`/`rc=2`) | **32** | la production de dossiers plafonne |
| **rouge CI** | **27** | cause d'infrastructure, pas un defaut de PR |
| `BLOCKED` atteste (`rc=3`) | 3 | dispatchable depuis le motif, sans ouvrir les surfaces |

**Aucune** des 59 refusees ne l'a ete pour un defaut d'elle-meme. C'est le constat qui fonde la regle 3 : renvoyer ces PRs a leurs auteurs leur demande de reparer ce qui n'est pas chez eux.

### Les 32 sans dossier, par motif

| Motif rendu par le gate | Compte |
|---|---:|
| surfaces changees ou non entierement attestees, dont **12** explicitement « changed after dossier » | ~20 |
| `no [ADJOINT PREFLIGHT] dossier comment found` | 11 |
| `rc=2` — snapshot bouge pendant la lecture | 1 |

Le motif dominant n'est pas l'absence de travail : c'est du travail **fait puis perime**. D'ou la regle 2.

## La mesure de conversion — pourquoi le format du rendu est le levier

Trois lots, meme cycle, meme gate, meme coordinateur :

| Lot | Format | Converti | Taux |
|---|---|---:|---:|
| titulaire | liste **nominative** | 10 / 10 | **100 %** |
| secretaire | **cumul** (« 56 OK B.0 frais ») | 15 / 53 | **28 %** |
| coordinateur | auto-tire sur les plus vieilles | 2 / 54 | **4 %** |

Le travail sous-jacent du deuxieme lot etait reel et de qualite : 96 PRs attestees, 56 fraiches. Il a converti a 28 % parce qu'il est arrive en **compte**, forcant une re-decouverte a l'aveugle sur 80 PRs — re-decouverte qui a elle-meme perime une partie des attestations qu'elle traversait.

**Le format de rendu pese davantage que le cout unitaire d'un appel API.** C'est la reponse structurelle a la question du plafond de quota, et elle est gratuite.

## Le cas fondateur de la regle 4 — #17390

Le gate (`scripts/check_adjoint_prevalidation.py`) recupere l'etat des checks **deux fois** par PR :

| Source | Ligne | Transport |
|---|---|---|
| `statusCheckRollup` dans `_pr_metadata` | `:732` | **GraphQL**, le champ le plus cher : il deplie chaque check-run |
| `_head_check_runs(headRefOid)` | `:763` via `:706` | **REST**, et deja la source de verite declaree du projet (`latest_wins_check_runs`, `:396`) |

Le retrait du champ est **juste** et n'a pas ete livre en code : `statusCheckRollup` entre dans `_metadata_identity`, donc dans l'empreinte, donc le retirer perime **toutes** les attestations en vol d'un coup (~92 au moment de la mesure). Le livrable est l'issue #17390, avec sa fenetre et son controle positif obligatoire — un test qui **echoue** si un check conclut pendant la lecture du snapshot.

Et la portee se declare : ce levier reduit le cout **par appel**, pas la peremption, qui est la cause dominante.

## Capacite CI — l'arbitrage user du 2026-09-22

Q34 du registre demandait s'il fallait plafonner la concurrence des runners sur ai-01. **Arbitrage user : non — on ne coupe pas la capacite, on l'equilibre entre GitHub, po-2024 et ai-01, et on monte po-2026 si besoin.**

Le cadrage binaire de la question etait fautif, et la mesure du vivant l'a montre. Sur ai-01 :

```
coursia-runner : 10 slots x COURSIA_RUNNER_CPUS=2 = 20 vCPU
coursia-waiters : 16 slots x WAITER_CPUS=0.25 = 4 vCPU
total = 24 vCPU
COURSIA_RUNNER_CPU_BUDGET = 24
coursia-ci.slice CPUQuota = 24 vCPU (nproc = 32)
```

Le dimensionnement est **coherent et delibere** : 24 demandes contre 24 accordes, au vCPU pres. Il n'y avait ni derive a corriger ni sur-souscription a reduire.

Le defaut est **l'absence de marge**. Un budget exactement sature throttle en permanence des que la charge monte, et chaque slot de calcul lance `pytest -n 4` — jusqu'a 40 workers xdist forkant du `git` sur 20 vCPU de quota. De la sortent les `BlockingIOError: [Errno 11]` sur `_fork_exec` (EAGAIN sur `fork()`) et les runners morts sans logs (`conclusion=null` + `BlobNotFound`).

Repartition du pool `coursia-linux`, cible de **129 des 173** declarations `runs-on` du depot :

| Hote | slots online | busy a la mesure |
|---|---:|---:|
| `myia-ai-01` | 10 | 8 |
| `myia-po-2024` | 8 | 7 |
| `myia-po-2026` | 2 | 2 |
| **total** | **20** | **17** |

Le levier retenu est donc d'**ajouter** des slots sur po-2026 (cible 8, parite avec po-2024), ce qui soulage ai-01 par le tirage de file sans toucher a sa configuration.

**Non etabli, et pas devine** : `docker ps` rend 0 conteneur runner sur ai-01 alors que 28 `Runner.Listener` tournent. La cause n'est pas mesuree. De meme, la corruption d'etiquette `cour sia-linux` signalee par po-2026 n'est pas mesurable depuis ai-01 (`actions/runners` rend 403 sous `myia-ai-01`, readlink sur `/proc/<pid>/cwd` refuse).

### Piege de provisionnement a ne pas rejouer

Deployer une unite systemd declarant N slots **sans** son drop-in de sizing fait retomber `supervise.sh` sur `MEMORY=4g` par defaut ; le garde de budget refuse, `Restart=always` reboucle toutes les 30 s, et le pool ne monte **jamais**. Les deux fichiers partent ensemble ou aucun. Et `assert_cpu_budget()` **sort immediatement sans rien imprimer quand le budget vaut 0** : son silence n'est pas un feu vert, c'est une absence de mesure. Detail : [`persist/README.md`](../../scripts/ci/docker/linux-runner/persist/README.md).
Loading