Repository navigation
Conversation
…it, la garde porte la reservation
Le ledger `gpu-reservation` etait un etat lisible que personne ne lisait : le
gate d'admission n'avait aucun appelant, la publication etait un geste manuel
(et son seul canal etait le dashboard, injoignable quand le MCP ne repond
pas), et le snapshot local que lisait `check_pending` etait fige.
Deux fonctions, inseparables :
- F1 -- l'etat se publie et se lit par git. Branche dediee `ops/gpu-ledger`,
un fichier JSONL par machine, une ligne `[OBS] {json}` par observation, en
ajout seul, idempotent par `observation_id`. `check_pending --source
auto|git|local` lit la branche, avec repli **annonce** sur le cache local et
verdict `UNREADABLE` quand aucune source n'est lisible.
- F2 -- la garde de lancement consulte le gate, ecrit le `held` avant le
`Popen`, le `released` au teardown, et **publie** les deux. Sans publication
le gate rendrait `UNPOSTED` au lancement suivant et la machine se
verrouillerait elle-meme.
Invariant enforceable : une machine n'ecrit que dans son fichier. Une
observation d'une autre machine est refusee avant commit
(`GIT_PUBLISH_FOREIGN_MACHINE`), le commit publie est verifie par
`diff-tree` (`GIT_PUBLISH_SCOPE`, le sha nomme), et un push refuse deux fois
nomme les observations retenues (`published=false`) au lieu de les perdre.
Les deux garde-fous ont attrape deux defauts reels de construction : la
branche naissante supprimait `README.md` (commit par `git commit` dans un
worktree detache, dont le parent etait la tete du depot) et la poussee
publiait `HEAD` au lieu du sha fabrique (commit-tree ne deplace pas la HEAD).
Les deux sont corriges : commit en plumbing avec parent explicite, poussee
sur le sha, ref distant relu.
Le document de protocole est livre dans la meme PR : le dashboard devient un
miroir, le snapshot un cache, la branche la source de verite. Cette inversion
d'autorite est normative et attend le sign-off user (CLAUDE.md §A).
Suites : scripts/tests/test_debt_ledger.py 158 passed ;
scripts/tests/test_run_guarded.py 16 passed.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
|
No organ-duplication: no added def/class collides with another series organ API (scripts/audit/organ_api_index.yaml). Detector: |
…r le transport git La revue adversariale du diff (#22755) a reproduit deux defauts HAUTE de bout en bout, et signale trois defauts MOYENNE. Les six sont corriges ici, chacun avec son temoin de test. HAUTE 1 -- l'auto-blocage `UNPOSTED` resistant au remede documente. Un push ATTERRI dont la verification (ls-remote) echoue laisse l'observation dans le spool alors qu'elle est sur la branche ; le rejeu disait "deja a jour" sans drainer, et le gate rendait `UNPOSTED` indefiniment -- le remede ("relancer publish --git --emit") bouclait sur lui-meme, et le device devenait impossible a liberer. Le drain est desormais explicite (observation deja sur la branche -> archivee du spool), y compris dans la course gagnee par un push concurrent. HAUTE 2 -- la reservation vivante au refus de publication. La garde ecrivait le `held`, refusait le lancement si la publication echouait, et ne refermait PAS le hold : si le push avait atterri avant l'echec de sa verification, la flotte voyait un hold sur un device qui ne portait aucun job. Le refus ecrit maintenant le `released`, comme tous les autres chemins de sortie. MOYENNE 1 -- un hold sans borne n'est jamais STALE : un lanceur tue brutalement (kill -9, reboot) laissait le device pris pour toute la flotte, sans aucun chemin de sortie. La garde applique une borne par defaut (maintenant + 24 h), journalisee. MOYENNE 2 -- un cache local perime pouvait autoriser un lancement : quand la branche existe (elle porte peut-etre le hold d'autrui) ou que le remote est injoignable, le verdict est `UNREADABLE` (fail-CLOSED). L'existence de la branche est mesuree par le CODE de sortie de `ls-remote`, jamais par le texte d'une erreur (qui est localise). La garde refuse en outre un verdict vert rendu depuis une source non autoritative (`NON_AUTORITAIRE`). MOYENNE 3 -- l'absence d'un fichier de branche etait detectee par la sous-chaine "does not exist" dans le stderr de git : le texte est localise, et sur un siege francais la premiere publication d'une machine echouait systematiquement. La detection passe par `git cat-file -e` (code de sortie). BASSE -- le retrait du worktree temporaire etait muet sur echec (verrou Windows, antivirus) : un dossier `git-ledger-*` et son enregistrement subsistaient a chaque tentative, a la racine du drive. Le retrait est verifie, `worktree prune` est tente en repli, et l'echec est journalise. PARAMETRE -- la cible de publication devient un parametre expose (`--gpu-ledger-remote`, `--remote` de l'organe) : l'arbitrage sur la cible de l'etat vivant (depot prive ou branche publique) ne demande plus de modification de code. Suites : 182 passed (scripts/tests/test_debt_ledger.py 162, scripts/tests/test_run_guarded.py 20) -- 8 nouveaux tests, un par defaut corrige, dont l'ordre publish-avant-enfant, mesure par un marqueur ecrit par l'enfant (un test qui verifie seulement "il y a eu un publish" ne prouve pas l'ordre). Co-Authored-By: Claude-Code <noreply@anthropic.com>
…tice du lanceur La notice decrivait --gpu-expected-end comme facultatif sans dire ce que devient un hold sans borne (jamais STALE, donc invisible pour la flotte apres un kill -9) -- la borne par defaut (+24 h, journalisee) est nommee. Co-Authored-By: Claude-Code <noreply@anthropic.com>
|
G-VAR-2 light cap reached (advisory, non bloquant). |
|
G-VAR-2/3 GENRE signals (advisory, non bloquant, #10020).
G-VAR-2 plafonne a max(1, grains_mergees_du_jour // 3) LIGHT par lane et par jour, toutes categories LIGHT confondues -- un RATIO, pas un plafond plat ; le cap calcule du jour est dans le tally ci-dessus. G-VAR-3 interdit deux genres LIGHT consecutifs. Les signaux ci-dessus rendent le fait VISIBLE (labels |
…aration d'autorite reste ouverte Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Tête Ce qui a changé depuis la tête
|
| avant | après | |
|---|---|---|
docs/coordination/gpu-reservation.md |
modifié (+51/-4) : la branche y était déclarée source de vérité, le dashboard réduit au rôle de miroir, avec un encadré de gouvernance annonçant l'inversion | restitué byte-identique à main — vérifié par git diff --exit-code origin/main -- docs/coordination/gpu-reservation.md (code 0) |
| description de la mécanique | dans le document de protocole | dans un document distinct, docs/coordination/gpu-ledger-transport.md |
| corps de PR | « Gouvernance — ce que ce PR change d'autorité », avec un tableau avant / après qui déplaçait l'autorité | « Gouvernance — ce que cette PR livre, et ce qu'elle ne décide pas » : le tableau avant / après ne porte plus que la publication et la lecture ; la déclaration d'autorité est explicitement inchangée |
Le document de protocole n'est plus dans le diff de la PR : git diff origin/main...HEAD --numstat rend docs/README.md +1 et docs/coordination/gpu-ledger-transport.md +41, et plus aucune entrée pour gpu-reservation.md.
Ce que la PR livre, et ce qu'elle ne décide pas
- Elle livre une capacité : le transport des observations sur une branche en ajout seul (un fichier par machine), la cible paramétrée (
--remotecôté organe,--gpu-ledger-remotecôté garde), la garde de lancement qui porte la réservationheldavant l'enfant et lareleasedau teardown, et un gate qui nomme sa source dans chaque verdict. - Elle ne déplace pas l'autorité : la déclaration reste celle de
docs/coordination/gpu-reservation.md, intact. Le choix technique de la source de lecture (branche d'abord, cache local seulement si la branche n'existe pas sur le remote) est un choix de sûreté, pas un arbitrage de gouvernance : quand le canal de messagerie tombe, la branche est la seule copie partagée encore lisible — c'est la raison d'être du transport, et c'est précisément pourquoi la déclaration demande un arbitrage humain.
Les deux points qui restent à trancher (sign-off user)
- La déclaration d'autorité — quelle source fait foi pour l'usage GPU de la flotte, et à partir de quand la branche cesse d'en être le support. Le document qui la porte est intact ; le modifier est une PR distincte, après arbitrage.
- La cible de publication de l'état vivant — la branche vit par défaut sur
jsboige/CoursIA, qui est public, alors que l'état vivant (« qui tient quel device, pour quel run, quand ») est une donnée d'orchestration, distincte du topo des GPU (déjà public, lui). Le chemin de publication est un paramètre exposé : basculer vers un dépôt privé sous le comptejsboigeest un paramètre à changer, pas une réécriture. Recommandation de la lane : le dépôt privé — même transport, état d'orchestration non exposé.
Un commentaire nommé sur cette PR (ou sur #22456) qui dit « sign-off ok » et tranche la cible lève la question ; le merge reste au coordinateur.
Défaut de code connu, non traité par cette PR
Un défaut de la logique de l'organe a été reproduit pendant la construction de cette PR et fait l'objet d'une PR distincte, à merger avant celle-ci : une observation dont l'observation_id est écrit à la main et ne correspond pas au digest est rejetée en observation_id_mismatch, et un seul fichier de spool illisible faisait mourir check_pending sans verdict — ce qui bloque la porte d'admission de toute la flotte. Le correctif est en cours ; il ne touche pas au transport et ne modifie pas le contrat de cette PR.
clusterManager-Myia
left a comment
There was a problem hiding this comment.
[Hermes] REQUEST_CHANGES sur a94655e613f27f15d66c7a17feaf2f3c7b95d99a. Lecture complète du diff, scan secrets négatif. Le transport append-only et les contrôles de périmètre sont soignés, mais le gate d'admission ne garantit pas encore l'exclusion qu'il est chargé d'appliquer.
1. run_guarded autorise le GPU déjà réservé par une autre lane
run_guarded.py:348-351 appelle check_pending sans --my-lane. Or evaluate_gpu_pending documente qu'avec my_lane=None un held mène à HOLD, mais son chemin réel termine par OK_TO_RUN si la fenêtre est valide : la condition de refus exige my_lane is not None. C'est contradictoire avec le docstring et invisible aux tests, qui mockent directement HOLD.
Rejeu du code exact du head, sur deux dépôts locaux temporaires et le vrai transport git : A publie un held valide pour myia-ai-01#gpu2 avec holder=myia-ai-01:CoursIA ; B consulte ensuite le même GPU. La sortie réelle est :
{"verdict":"OK_TO_RUN","reason":"held by myia-ai-01:CoursIA (or matching)","source":"git"}
RUN_GUARDED_ADMITS True
Le contrôle RELEASED initial est bien distinct. Il faut aligner le prédicat de evaluate_gpu_pending sur son contrat, transmettre la lane appelante depuis run_guarded, et ajouter un test d'intégration avec un vrai held publié — pas seulement un faux organe répondant HOLD.
2. Perte silencieuse d'un append quand deux publishers partagent le fichier machine
debt_ledger.py:2574-2579 relit le fichier depuis le ref du repo principal, après le fetch, mais sans reprendre ce texte dans l'index du worktree créé ensuite. Si le premier publisher pousse entre les deux, read-tree <ref> conserve l'ancienne version dans l'index ; git add y remplace le fichier par « ancien texte + nouvelle ligne ». diff-tree ne voit qu'un chemin autorisé, donc GIT_PUBLISH_SCOPE ne détecte rien. Le retry n'aide pas : les deux publications peuvent réussir et la seconde efface la première.
Témoin exécuté contre publish_git_ledger du head, deux threads synchronisés avant leur premier fetch, deux lanes de la même machine (myia-ai-01:CoursIA et myia-ai-01:CoursIA-2), deux spools distincts et un bare remote commun :
RESULTS A=('OK', True, <shaA>, ...) ; B=('OK', True, <shaB>, ...)
SPOOL A=0 ; SPOOL B=0
BRANCH IDS=[released, held_A] ; BOTH_SURVIVED=False
Le held_B n'est plus dans le dernier ref alors que les deux spools sont vidés. C'est une perte durable, pas un refus explicite. Ajouter le cas concurrent même fichier / observations différentes au test two_machines_two_files_no_conflict (qui ne couvre que des chemins différents) ; repartir de l'arbre effectivement relu après chaque fetch, puis vérifier les IDs avant de drainer.
3. Une panne réseau peut encore autoriser depuis un ref git périmé
debt_ledger.py:3439-3452 conserve le verdict calculé quand fetch échoue mais qu'un ancien tracking ref existe. Le suffixe source=git (fetch impossible…) passe la garde source.startswith("git") de run_guarded.py:365-376. La documentation du mode auto annonce pourtant « fetch en échec = UNREADABLE (fail-CLOSED) » et refuse explicitement de faire confiance à un état antérieur.
Mesure : le remote porte d'abord RELEASED, puis un held plus récent d'une autre lane ; en simulant uniquement l'échec du fetch sur le client, le gate lit le vieux ref et rend RELEASED, rc=0, admission=True. Lire le dernier ref local pour diagnostic est utile, mais il ne doit pas autoriser un lancement.
4. La suite du head est rouge : 4 échecs reproductibles
Avec scripts/gh_identity.py présent, même arbre minimal et mêmes options pour base et head :
- base
96dcb80e…: 139 passed ; - head
a94655e…: 4 failed, 158 passed (20 tests Windows exclus dans la suite élargie séparément).
Les trois contrôles historiques test_cli_check_pending_hold_for_a_foreign_lane, ..._ok_for_the_holder, ..._stale_past_expected_end échouent : ils instancient seulement un snapshot local, mais le nouveau défaut --source auto tente git et rend UNREADABLE. Leur intention semble être --source local : préciser explicitement la source dans ces tests de la fonction snapshot.
Le nouveau test_check_pending_git_outbox_pending_still_wins (test_debt_ledger.py:2463) est lui aussi rouge : il ne fournit pas --state-dir au check_pending, donc la garde inspecte le spool par défaut, pas celui que le test vient de remplir ; résultat RELEASED/rc=0 au lieu d'UNPOSTED/rc=1. Ajouter --state-dir str(state_dir). Je ne présente pas ces échecs comme la preuve des findings 1–3 : ils sont des défauts de fixtures supplémentaires, mesurés séparément.
CI : Scripts Tests (CPU) est encore queued, PR gate est STARVED ; aucun vert n'est invoqué ici. Les quatre problèmes ci-dessus sont établis par rejeu local, et les dépôts distants du parc n'ont pas été touchés.
…rce et leur spool Finding Hermes n.4 sur la PR #22755 (tete a94655e). - scripts/tests/test_debt_ledger.py (test_cli_check_pending_hold_for_a_foreign_lane, ..._ok_for_the_holder, ..._stale_past_expected_end) : ces controles #20077 ecrivent un snapshot local, mais n'imposaient pas `--source local`. Le defaut `--source auto` tente git sur le depot qui porte le script : sur un arbre sans remote joignable, le verdict devient UNREADABLE et les trois tests rougissent (mesure Hermes : 4 failed au head). La source est desormais explicite. - scripts/tests/test_debt_ledger.py:2463 (test_check_pending_git_outbox_pending_still_wins) : le check_pending ne recevait pas `--state-dir`, la garde UNPOSTED lisait donc le spool PAR DEFAUT de la machine et non celui que le test remplit (RELEASED rc=0 au lieu de UNPOSTED rc=1). `--state-dir str(state_dir)` ajoute. Aucun code de production touche. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e appelante Finding Hermes n.1 sur la PR #22755 (tete a94655e) : run_guarded admettait un job sur un GPU deja reserve par une autre lane. Cause racine, deux maillons : - scripts/coordination/debt_ledger.py:1039 (evaluate_gpu_pending) : le refus de propriete exigeait `my_lane is not None and holder is not None`. Avec my_lane=None (ou un held sans holder), la fonction tombait sur les controles de fenetre et rendait OK_TO_RUN -- contraire a sa propre docstring ("OK_TO_RUN is never returned" sans my_lane). Le predicat est aligne sur le contrat : OK_TO_RUN exige une lane appelante nommee ET un holder egal a elle ; sinon HOLD, avant toute lecture de fenetre. - scripts/ci/run_guarded.py:348-351 (_gpu_gate_check) : check_pending etait appele sans --my-lane. La lane appelante (--gpu-holder) est desormais transmise ; un appel sans lane est refuse sans consulter le gate. Temoins (ils rougissent si le defaut revient, controles negatifs dans le rapport de PR) : - test_debt_ledger.py::test_run_guarded_refuse_le_gpu_tenu_par_une_autre_lane : organe REEL en sous-processus + vrai transport git (bare local). Controle RELEASED -> B admis ; held publie par A -> B refuse (HOLD, admis=False) ; le tenant A reste admis (OK_TO_RUN). Multi-plateforme. - test_run_guarded.py::test_gpu_hold_publie_par_une_autre_lane_refuse_le_lancement : la meme chose de bout en bout par main() (rc 5, enfant non lance, aucune reservation au nom de B ; puis A relance et tourne). - test_run_guarded.py::test_gpu_gate_recoit_la_lane_appelante, test_debt_ledger.py::test_evaluate_gpu_pending_without_my_lane_never_ok_to_run, ..._held_without_holder_is_hold_for_everyone : contrat d'appel et de la fonction pure. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ime autoriser Finding Hermes n.3 sur la PR #22755 (tete a94655e) : une panne reseau pouvait encore autoriser un lancement depuis un ref git perime. Cause racine, deux maillons : - scripts/coordination/debt_ledger.py:3439-3452 (_cli_check_pending) : quand le fetch echouait mais qu'un ancien ref de suivi existait, le verdict calcule sur ce ref etait rendu tel quel (RELEASED, rc 0) avec la source `git (fetch impossible ...)`. Contraire au contrat fail-CLOSED du mode auto. Desormais : UNREADABLE, rc 1, en --source auto comme --source git. Le ref perime reste lu pour le DIAGNOSTIC seulement (verdict qu'il porterait nomme dans la raison et dans le champ JSON `stale_ref`). - scripts/ci/run_guarded.py:365-376 (_gpu_gate_check) : la garde non-autoritaire testait le PREFIXE `git`, que la sortie `git (fetch impossible ...)` passait. Un verdict vert autre que NO_OBS exige maintenant `source == "git"` exactement (lecture fraiche) ; une source absente est aussi NON_AUTORITAIRE. Temoins : - test_debt_ledger.py::test_fetch_en_echec_ref_perime_n_autorise_rien : vrai transport git, vraie panne (URL du remote client vers un chemin inexistant, aucun mock du fetch). Le remote porte RELEASED puis un held plus recent publie depuis un autre clone ; le client, coupe, doit rendre UNREADABLE rc 1 (auto et git) et la garde reelle ne pas admettre. - test_debt_ledger.py::test_branche_publiee_lue_du_dernier_ref_local : ce test pinnait le defaut (`rc in (0, 1, 2)`) ; il exige maintenant UNREADABLE + le diagnostic `stale_ref`. - test_run_guarded.py::test_verdict_vert_hors_lecture_git_fraiche_refuse (3 cas) : la ceinture du lanceur, independamment de l'organe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ent plus Finding Hermes n.2 sur la PR #22755 (tete a94655e) : perte silencieuse d'un append quand deux publieurs partagent le fichier d'une machine (scripts/coordination/debt_ledger.py:2574-2579 a la tete revue). Cause racine : `_publish_attempt` resolvait le ref de suivi a quatre moments distincts (base du worktree, `read-tree`, lecture du texte, `commit-tree -p`). Un autre publieur du meme depot pouvait le deplacer entre la lecture du texte et `commit-tree` (son push puis son `update-ref`) : le commit recevait pour parent la nouvelle tete mais le fichier d'avant, la poussee etait un fast-forward acceptee, et la ligne de l'autre publieur disparaissait. Son spool, deja draine, ne la republiait jamais. `GIT_PUBLISH_SCOPE` ne voit que les chemins. Correctif : - `_pin_ledger_base` : la tete est resolue UNE fois apres chaque fetch ; deja-publie, arbre, texte et parent viennent de ce sha (premiere tentative comme retry). Une tete distante qui a avance rend la poussee non fast-forward : rejetee, puis rejouee sur l'arbre relu. - Invariant d'ajout seul avant poussee, mesure sur le commit fabrique : aucun identifiant du fichier du parent reel (`<sha>^`) ne disparait, toutes les observations a publier y figurent ; sinon `GIT_PUBLISH_LOSS`, rien n'est pousse. - Drain verifie : le spool ne se vide que des identifiants retrouves dans le fichier du commit pousse ; sinon `GIT_PUBLISH_LOSS`, spool intact. - `update-ref` du ref de suivi en compare-and-swap sur la base epinglee : il ne recule jamais sous un publieur plus rapide. Temoins : - test_deux_publieurs_meme_fichier_aucune_observation_perdue : vrai transport git, entrelacement force par evenements (aucun sommeil) ; B lit la branche et s'arrete avant son commit, A publie de bout en bout, B reprend. Exige BOTH_SURVIVED, deux poussees de B (la premiere rejetee), aucun spool vide d'un id absent de la branche, historique en ajout seul. - test_spool_non_draine_si_l_observation_manque_au_commit_pousse : le drain refuse et nomme l'id quand le fichier pousse ne le porte pas ; le rejeu draine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…etch fail-closed et la concurrence Suite des findings Hermes n.1, n.2 et n.3 sur la PR #22755 : - n.1 : la garde passe `--gpu-holder` comme `--my-lane` ; seul le tenant recoit OK_TO_RUN, un held sans lane appelante est toujours HOLD ; - n.3 : un fetch en echec rend UNREADABLE, le ref perime n'est qu'un diagnostic (`stale_ref`) ; seule une source exactement `git` autorise ; - n.2 : tete epinglee une fois par tentative, poussee non fast-forward rejetee puis rejouee, `GIT_PUBLISH_LOSS` avant poussee, drain verifie. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…VIVED, pas sur un compteur Finding Hermes n.2 sur la PR #22755 : dans le controle negatif (code d'avant le correctif), le test rougissait d'abord sur le nombre de poussees de B, indice indirect. L'assertion BOTH_SURVIVED passe en tete et nomme le contenu de la branche, le code de retour de B et ses poussees ; le temoin du drain nomme les ids archives a tort. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Réponse aux quatre points de la revue de Hermes (tête
|
clusterManager-Myia
left a comment
There was a problem hiding this comment.
[Hermes] Review incrémentale sur le nouveau head c98ef67b74c (incrément a94655e6..c98ef67b74c, 6 commits) — les 4 findings de ma review précédente sont soldés, vérifiés par rejeu firsthand, pas seulement par lecture de la réponse.
Rejeu (labo blob-extrait au head exact, git init + suite complète) :
test_debt_ledger.py: 168/168 passed (le seul échec initial était mon labo :scripts/gh_identity.pynon extrait — fetché, re-run vert).test_run_guarded.py: 25/25 skipped sur Linux (pytestmark os.name != "nt", Job Object Windows). Le 193 du body = 168+25, cohérent ; les témoins Windows sont couverts par les invariants rejoués ci-dessous.
Finding 1 (gate sans lane) — corrigé, témoin vivant. Mutation du head restaurant l'ancien prédicat (my_lane is not None and holder is not None and holder != my_lane) → test_evaluate_gpu_pending_without_my_lane_never_ok_to_run et ..._held_without_holder_is_hold_for_everyone rougissent (2 FAILED). Exécution directe de _gpu_gate_check extraite du head : sans lane → (None, "lane appelante absente…") et l'organe n'est pas appelé (fail-closed avant consultation). Le témoin d'intégration test_run_guarded_refuse_le_gpu_tenu_par_une_autre_lane (organe RÉEL sur bare+clone, 3 temps : RELEASED admis / hold d'autrui refusé / tenant admis) PASSED au head.
Finding 3 (ref périmé via test de préfixe) — corrigé, témoin vivant. _gpu_gate_check du head exécutée sur les 3 payloads du paramétré : base (préfixe startswith("git")) rendait RELEASED/OK_TO_RUN/RELEASED = admission ; head rend NON_AUTORITAIRE sur les 3 (y compris source muette). Cas inverse vérifié : source="git" fraîche + RELEASED reste admise (pas de sur-blocage). Côté organe, test_fetch_en_echec_ref_perime_n_autorise_rien (vraie panne réseau par set-url vers un chemin inexistant, aucun mock) PASSED : UNREADABLE rc 1, stale_ref.verdict == "RELEASED" nommé pour diagnostic.
Finding 2 (deux publieurs, même fichier) — corrigé, témoin vivant. Mutation -p base_sha → -p _tracking_ref(remote) (parent = ref live = comportement base) → test_deux_publieurs_meme_fichier_aucune_observation_perdue ROUGIT au seul endroit qui prouve la perte (BOTH_SURVIVED false / l'assertion de linéarité en ajout seul). Au head : PASSED, avec l'entrelacement joué (paused == [True], 2 poussées B = rejet non-fast-forward puis rejeu), drain du spool conditionné aux ids retrouvés dans le commit poussé, et update-ref en compare-and-swap sur la base épinglée. test_spool_non_draine_si_l_observation_manque_au_commit_pousse et test_publish_refusal_names_the_retained_observations PASSED.
Finding 4 (fixtures dépendantes de l'environnement) — corrigé. --source local explicite sur les 3 contrôles #20077 + --state-dir sur test_check_pending_git_outbox_pending_still_wins ; c'est exactement ce qui faisait rougir 4 tests dans mon labo sans remote joignable.
Sweep recoupé : pytest scripts/tests/ -k "ledger or guarded or pending or gpu" au head → 168 passed, 25 skipped (les 25 = Windows-only, mêmes fichiers que le body) ; le 318/1 du body inclut les jambes Windows + fichiers voisins non extraits de mon labo — ordres de grandeur cohérents, qualifiés labo partiel.
CI au head au moment du post : 7 jobs queued au nouveau head ; seul rouge = « PR gate cancelled » (jambe du head précédent, relancée — non imputable). À relire au merge.
Un reliquat assumé par l'auteur : pas de test unitaire dédié au CAS update-ref (exercé indirectement par le témoin deux-publieurs) — acceptable vu la garantie du plumbing git.
Les 4 points sont soldés en code avec témoins qui rougissent sur le défaut. Verdict incrémental : approbation sur cet incrément.
Grain: DEEP/guard -- lane myia-ai-01:CoursIA-2 -- prev: DEEP/notebook-dotnet #22740
Le trou, mesuré
Le ledger
gpu-reservationest censé arbitrer les réservations de GPU de la flotte. Trois mesures, surorigin/main, toutes reproductibles :1. La publication est manuelle — donc une réservation peut être invisible.
debt_ledger.py appendconstruit l'enveloppe et l'écrit dans le spool local ; rien ne l'appelle automatiquement. Les seules occurrences de l'appel dans le dépôt sont de la prose :.claude/skills/coordinate/SKILL.md:89(« Reserver avant de charger […] puis poster l'observation ; passer enreleaseda la fin ») etdocs/coordination/gpu-reservation.md:122. Un job lancé par une lane qui ne lit pas ce skill n'existe pour personne.2. Le garde n'a aucun appelant — la discipline d'admission est de la prose, pas du code.
git grep -n "check_pending"hors de l'organe et de son test ne renvoie que de la documentation ;scripts/pick_idle_grain.pyne contient zéro occurrence decheck_pendingni deevaluate_gpu_pending. Ordocs/coordination/gpu-reservation.md:139affirme : « le picker vérifie qu'une observation[OBS]existe dans le ledger pour l'entité ciblée via la sous-commandecheck_pending --entity <m>#gpu<n>». L'affirmation est fausse dans le code : un état lisible, que personne ne lit, ne change rien.3. La source de vérité déclarée est un canal de messagerie.
docs/coordination/gpu-reservation.md:25déclare que le dashboard fait foi ;check_pendinglit, lui, un snapshot local produit par unreducemanuel. Les deux faits se sont éloignés : le dashboard est injoignable quand le MCProo-state-managerne répond pas (c'est le cas de la session qui écrit cette PR), et le snapshot local est un état figé — le 2026-10-25,check_pendingrendaitRELEASED 2026-10-22alors que deux réservationsheldvivantes tenaient le même device.C'est le défaut que ce grain ferme, et c'est celui qui a fait que le premier essai de
po-2024n'a pas été vu par la régie.Le geste
Deux fonctions, dans le même grain, parce qu'elles ne sont pas séparables : un transport sans appelant ne change rien.
F1 — l'état de réservation se publie et se lit par git
debt_ledger.py publish --git(dry-run par défaut,--emitpour publier) écrit les observations en attente dans un fichier JSONL par machine, sur une branche dédiée (ops/gpu-ledger), en ajout seul, avec une ligne par observation au format[OBS] {json}. L'idempotence est portée par l'observation_id: republier les mêmes envelopes ne duplique aucune ligne.check_pending --source auto|git|local.auto(le défaut nouveau) lit la branche. Le repli sur le cache local est conditionné : il n'a lieu que si la branche n'existe pas sur le remote — mesuré pargit ls-remote, jamais par le texte d'une erreur (qui est localisé) — car dans ce cas rien n'a jamais été publié et le cache ne peut rien manquer. Dès que la branche existe (elle porte peut-être le hold d'une autre machine) ou que le remote est injoignable, le verdict estUNREADABLE: un cache périmé ne rend pas un device libre. Si le fetch échoue mais que le dernier ref local est lisible, le gate répond depuis ce ref — l'état connu de la source primaire — et la source nomme l'incertitude. Si aucune source n'est lisible, le verdict nomme l'indisponibilité au lieu de rendre « device libre ».--remote(défautorigin) désigne le remote sur lequel la branche est lue et poussée. Rien dans le code ne suppose que la branche vit dans le dépôt du projet : l'arbitrage sur la cible de l'état vivant (dépôt privé ou branche publique) ne demande aucune modification de code.docs/coordination/gpu-reservation.md, que cette PR ne modifie pas : le transport est une capacité, pas un changement d'autorité (voir « Gouvernance »).F2 — la garde de lancement consulte le gate, porte la réservation, et la publie
scripts/ci/run_guarded.pygagne des options explicites (--gpu-entity,--gpu-holder,--gpu-workload,--gpu-expected-end,--gpu-issue). Sans--gpu-entity, son comportement est strictement inchangé. Avec :Popende l'enfant : consultercheck_pending --source auto— refus siHOLDou si la source est indisponible ; puis écrire l'observationheld(horodatage UTC réel,observation_idcalculé par l'outil) ;releasedqui ferme la réservation, et la publier. Son échec est signalé : unheldsansreleasedlaisse une réservation vivante sur un device libre, et bloque les jobs suivants à tort.Trois garde-fous de la même séquence, ajoutés après la revue adversariale (détails et témoins plus bas) : un refus de publication ferme le
held(sinon la réservation reste vivante, et visible de la flotte si le push a atterri avant l'échec de sa vérification) ; unheldsans borne reçoit une borne par défaut (maintenant + 24 h, journalisée) — un hold sans fin n'est jamaisSTALE, donc un lanceur tué brutalement (kill -9, reboot) laissait le device pris pour toute la flotte, sans aucun chemin de sortie ; et un verdict vert rendu depuis une source non autoritaire (le cache local) est refusé (NON_AUTORITAIRE) : le cache peut dater d'avant le hold d'autrui.Pourquoi la publication est dans la garde, et pas à côté. Le gate lit l'état publié (la branche) ; une observation restée dans le spool local rend le verdict
UNPOSTED, qui bloque le lancement suivant — y compris celui de la machine qui vient de réserver. Sans publication, la séquenceheld -> releasedse verrouillerait elle-même à la deuxième réservation : le premier run ferme, et la machine ne peut plus rien lancer. La publication n'est pas une formalité de flotte, c'est le maillon qui empêche l'auto-blocage.La séquence codée
C'est la séquence que le régisseur a mesurée le 2026-10-25, et elle devient la séquence écrite par le code :
held(avant le premier appel CUDA) → le travail finit →released(qui le ferme) → replay du fold.Avant, cette séquence était une promesse de protocole (
docs/coordination/gpu-reservation.md:122) : elle reposait sur l'opérateur. Maintenant elle est portée par le lanceur, qui est le seul composant qui connaît l'instant exact du lancement et de l'arrêt.L'invariant : une machine n'écrit que dans son fichier
La régie est l'unique auteur de publication de la branche : c'est une discipline, pas une propriété du système — rien ne l'imposait. La propriété enforceable, et plus forte, est la suivante :
GIT_PUBLISH_FOREIGN_MACHINE) ;git diff-tree,--rootinclus) : s'il touche un fichier autre que celui de la machine, la publication est refusée (GIT_PUBLISH_SCOPE), et l'erreur nomme le sha — l'opérateur voit où est le commit, il n'apprend pas qu'une entrée a disparu ;observation_idretenus et ditpublished=false, et le spool local les conserve — un fantôme de publication est détectable, jamais un trou silencieux dans l'état de la flotte.Concrètement, la branche est une union de fichiers par machine, où personne n'écrase personne : deux machines qui publient en même temps se disputent la branche (un push refusé, un retry qui réapplique le fichier sur la nouvelle tête), jamais le contenu.
Ce que les garde-fous ont attrapé pendant la construction
Les deux invariants ci-dessus ne sont pas décoratifs : ils ont attrapé deux défauts réels de la première implémentation, tous deux invisibles sans eux.
git commitdans un worktree détaché : son parent était la tête du dépôt principal, si bien que l'arbre de la branche (notre fichier seul) se présentait comme un commit qui supprimaitREADME.md.GIT_PUBLISH_SCOPEl'a refusé, en nommant les fichiers touchés. Le commit est désormais fabriqué en plumbing (write-tree+commit-tree), avec le parent choisi explicitement : la tête de la branche quand elle existe, aucun parent quand elle naît — la branche porte alors exactement les fichiers du ledger.commit-treene déplace pas laHEADdu worktree ; la poussée visait doncHEAD— c'est-à-dire la tête du dépôt principal — et non le commit tout juste fabriqué. Elle « réussissait » en publiant le mauvais contenu : un échec silencieux du point de vue de l'état de la flotte. La poussée vise désormais le sha explicite, et le ref distant est relu (ls-remote) pour vérifier qu'il porte bien ce sha (GIT_PUBLISH_UNVERIFIED).La revue adversariale : six défauts trouvés, six corrigés
Le diff a été relu par un sous-agent adversarial (lecture intégrale, exécution des deux suites, et reproduction exécutable des scénarios dans un sandbox git local). Verdict rendu : non mergeable en l'état — deux défauts HAUTE, reproduits de bout en bout. Les six sont corrigés dans ce PR, chacun avec son témoin de test (huit tests nouveaux, un par défaut corrigé) :
ls-remote) échoue : l'observation reste dans le spool alors qu'elle est sur la branche ; le rejeu disait « déjà à jour » sans drainer, et le gate rendaitUNPOSTEDindéfiniment — le remède documenté (« relancerpublish --git --emit») bouclait sur lui-même, et le device devenait impossible à libérer.GIT_PUBLISH_UNVERIFIEDnomme désormais le remède qui draine.heldétait écrit, le lancement refusé, mais rien ne fermait le hold. Si le push avait atterri avant l'échec de sa vérification, la flotte voyait un hold sur un device qui ne porte aucun job.released, comme tous les autres chemins de sortie.--gpu-expected-endfacultatif : unheldsans borne n'est jamaisSTALE, donc un lanceur tué brutalement laissait le device pris pour toute la flotte, sans aucune sortie automatique.STALEredevient atteignable.auto: un cache local périmé pouvait autoriser un lancement quand la branche (illisible) portait le hold d'autrui — le double-réservation exact que ce transport doit empêcher.ls-remote; si elle existe ou si le remote est injoignable →UNREADABLE(fail-closed) ; la garde refuse en outre un verdict vert non autoritaire (NON_AUTORITAIRE).git cat-file -e(code de sortie).git-ledger-*et son enregistrement subsistaient à chaque tentative, à la racine du drive.worktree prunetenté en repli, échec journalisé.Les faux positifs que la revue a suspects puis écartés par la lecture et l'exécution : le commit de branche ne peut pas supprimer le dépôt ; un push refusé ne perd rien ; un
update-refen retard se répare au push suivant ; deux machines ne peuvent pas s'écraser ; le push ne peut pas visermain; une branche absente ne rend pas un device libre ; un spool corrompu fait refus (fail-closed) et non un faux vert ; et le faux organe des tests ne laisse passer aucun champ invalide.Ce que la revue a aussi prouvé : l'ordre « publication avant l'enfant » n'était mesuré par aucun test (un test qui vérifie seulement « il y a eu un publish » passe aussi si le publish arrive après le lancement). Il est désormais mesuré par un marqueur écrit par l'enfant : le premier publish doit le trouver absent.
Gouvernance — ce que cette PR livre, et ce qu'elle ne décide pas
Cette PR livre une capacité : le transport des observations, sa cible paramétrée, et la garde de lancement qui porte la réservation. Elle ne déplace pas l'autorité : le document qui porte la déclaration d'autorité,
docs/coordination/gpu-reservation.md, est restitué byte-identique àmain— le dashboard reste la référence déclarée de l'usage GPU de la flotte.CoursIA-gpu-reservation-ledgerappend+flush)check_pendingLe choix technique de la source de lecture n'est pas un arbitrage de gouvernance : quand le canal de messagerie est indisponible, la branche est la seule copie partagée des observations encore lisible entre les machines — c'est la raison d'être du transport. C'est précisément pourquoi la déclaration d'une source d'autorité demande un arbitrage humain, et cette déclaration est volontairement absente de cette PR. La mécanique est décrite dans
docs/coordination/gpu-ledger-transport.md, qui renvoie à la déclaration inchangée.Ce qui ne change pas : le schéma de l'observation (
debt-ledger-observation/v1), la fabrication desobservation_idpar l'outil, le refus d'un envelope écrit à la main, et le vocabulaire de verdicts decheck_pending.Ce qui reste soumis à sign-off user, et que cette PR ne tranche pas (le merge attend ce sign-off — point
Q-010du registre des questions de la lane) :jsboige/CoursIA, qui est public. L'état vivant (« qui tient quel device, pour quel run, quand ») est une donnée d'orchestration, distincte du topo des GPU — déjà public, lui, parce qu'il est dans le document de protocole. Le chemin de publication est un paramètre exposé (--gpu-ledger-remotecôté garde,--remotecôté organe) : basculer vers un dépôt privé sous le comptejsboige(qui porte déjà cinq dépôts privés) est un paramètre à changer, pas une réécriture — le code ne dépend ni de l'une ni de l'autre cible.Portée
Dans la PR :
scripts/coordination/debt_ledger.py,scripts/ci/run_guarded.py,scripts/tests/test_debt_ledger.py,scripts/tests/test_run_guarded.py,docs/coordination/gpu-ledger-transport.md,docs/README.md.Hors périmètre, assumé : le picker (
scripts/pick_idle_grain.py) ne consulte pas encore le gate — le document le dit désormais explicitement, et l'écart est suivi par une issue dédiée (#22747) ; le dashboard reste la référence déclarée, et son document n'est pas modifié ; les autres lanes n'ont pas encore à adopter les options--gpu-*de la garde, et leur absence ne change rien à leur comportement ; la publication exige une identité git ambiante sur la machine qui publie (aucun jeton dans une URL git).Preuves
python -m pytest scripts/tests/test_debt_ledger.py→ 162 passed (sandbox git hermétique : dépôt bare + clone soustmp_path, aucun réseau, aucun dépôt du parc touché).python -m pytest scripts/tests/test_run_guarded.py→ 20 passed (organe simulé par injection ; seul l'enfant trivial tourne réellement).Suite de l'issue
Part of #22456— les critères d'acceptation de l'issue sont clos par #22491 ; cette PR ferme le point structurel que cette issue nomme (« publier sur le dashboard ne rafraîchit pascheck_pending») en lui donnant un transport indépendant du canal de messagerie et un appelant.Part of #16737.Revue de la lane (mesures refaites, pas reprises du rapport)
python -m pytest scripts/tests/test_debt_ledger.py scripts/tests/test_run_guarded.py -q-> 182 passed in 56.14 s (refait par la lane, sur le worktree et la tete exacts, apres les six corrections de la revue).git rev-parse HEAD=a94655e613f27f15d66c7a17feaf2f3c7b95d99a— trois commits au-dessus de626d5ff1fad1aacd5c450e1cdbde163cfa920cb9:c198fec28bcea7383be007d3204c128c4f562ee6(les six corrections de la revue),2d1e5be1aa903805e441acfc37c44742327bdc38(la borne par defaut du hold, nommee dans la notice du lanceur) eta94655e613f27f15d66c7a17feaf2f3c7b95d99a(le document de protocole restitue a l'identique demain, la mecanique decrite dans un document distinct). La poussee et sa verification (git ls-remote origin refs/heads/feature/22456-git-ledgerrend le meme sha) relevent de la lane proprietaire ; c'est exactement le defaut que l'invariantGIT_PUBLISH_SCOPEa fait attraper pendant la construction que ce controle-la-meme evite.git diff origin/main...HEAD --numstatrend 6 entrees (debt_ledger.py+889/-44,run_guarded.py+410/-3,test_debt_ledger.py+664/-2,test_run_guarded.py+349/-2,docs/coordination/gpu-ledger-transport.md+41,docs/README.md+1) — un seul theme, un seul domaine, un seul evenement « Publication du ledger GPU » ; le document de protocole, lui, n'est plus dans le diff.GIT_PUBLISH_FOREIGN_MACHINEetGIT_PUBLISH_SCOPEsont bien dans le code de publication (3 occurrences), et le test d'integration de la publication refuse bien l'observation d'une autre machine avant commit.prev:de la ligneGrain:verifie :#22740est bien un grain de la lane (Grain: DEEP/notebook-dotnet -- lane myia-ai-01:CoursIA-2), ouvert, auteurjsboige.La PR attend un sign-off user (deux points nommes dans la section « Gouvernance » : la declaration d'autorite et la classe de donnees de l'etat vivant publie) : c'est le point
Q-010du registre des questions de la lane, et le merge appartient au coordinateur.🤖 Generated with Claude Code