@sylad/cadence 1.23.0 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,7 +27,7 @@ import { costlyHypotheses, specScreen, specText } from './spec.js';
27
27
  import { fichesDuDossier } from '../spec.js';
28
28
  import { candidates, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
29
29
  import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
30
- import { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, longDuration, removePrePush, snapshot } from './guard.js';
30
+ import { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, keepGateGuard, longDuration, removePrePush, snapshot } from './guard.js';
31
31
  import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
32
32
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
33
33
  import { groupLots, runPool } from './pool.js';
@@ -37,8 +37,10 @@ import { schemaFor } from './schemas.js';
37
37
  import { resolveLotRepos } from '../repos.js';
38
38
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
39
39
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
40
- import { quotaText, renderTable, resumeCommand } from './table.js';
40
+ import { quotaText, renderDetail, renderTable, resumeCommand } from './table.js';
41
41
  import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
42
+ const REPO_WAIT_MS = 3 * 60 * 60_000;
43
+ const REPO_WAIT_POLL_MS = 5_000;
42
44
  export const DEFAULT_BUDGET = 2_000_000;
43
45
  /**
44
46
  * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
@@ -159,6 +161,8 @@ export function parseOrchestrateArgs(argv) {
159
161
  if (!isDay(a.since))
160
162
  throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
161
163
  }
164
+ else if (t === '--detail')
165
+ a.detail = value(t);
162
166
  else if (t === '--watch')
163
167
  a.watch = true;
164
168
  else if (t === '--interval')
@@ -643,7 +647,15 @@ function statusView(args, launch, cap, now) {
643
647
  // `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
644
648
  const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
645
649
  const lots = store.lots();
646
- return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
650
+ if (args.detail !== undefined) {
651
+ try {
652
+ return { live: [], table: renderDetail(lots, args.detail), running };
653
+ }
654
+ catch (e) {
655
+ throw new RafError(e.message);
656
+ }
657
+ }
658
+ return { live, table: [...renderTable(store.readWave(), lots, { compact: true }), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
647
659
  }
648
660
  /** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
649
661
  function statusWave(args) {
@@ -711,6 +723,10 @@ export async function orchestrate(argv, io, deps) {
711
723
  priorityOf(args, launch); // une clé priority: illisible du cadence.yaml : refus avant d'agir, lots donnés ou non
712
724
  if (args.watch && args.status === undefined)
713
725
  throw new RafError('--watch s\'utilise avec --status');
726
+ if (args.detail !== undefined && args.status === undefined)
727
+ throw new RafError('--detail s\'utilise avec --status');
728
+ if (args.detail !== undefined && args.watch)
729
+ throw new RafError('--detail ne se combine pas avec --watch (il donne le détail d\'un lot, pas le tableau)');
714
730
  if (args.interval !== undefined && !args.watch)
715
731
  throw new RafError('--interval s\'utilise avec --status --watch');
716
732
  // --drop / --stop-after-current (L79) pilotent une vague VIVANTE : ni avec --status (rien à piloter), ni --stop-after-current avec --resume (il le contredit). --resume --drop retire le lot avant de rejouer la vague arrêtée.
@@ -790,7 +806,7 @@ export async function orchestrate(argv, io, deps) {
790
806
  if (args.drop.length || args.stopAfterCurrent)
791
807
  return control(args, io);
792
808
  if (args.lots.length === 0 && !args.continue)
793
- throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
809
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--detail projet:lot | --watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
794
810
  const refusals = [];
795
811
  const targets = resolveTargets(args, io, refusals);
796
812
  const pre = await preflight(args, targets, io, deps, launch);
@@ -1093,14 +1109,17 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1093
1109
  const repos = [...new Set(lots.flatMap(lotRepoPaths))];
1094
1110
  /** Libère un dépôt : son verrou et sa garde de push (L132 : dès que ses lots sont finis, pas à la fin de la vague). */
1095
1111
  const freed = new Set();
1096
- /** Lots que la vague joue à la fois sur les dépôts qu'elle tient encore (un par dépôt, un par lot en worktree) : ce que le registre compte pour les créneaux libres. Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
1097
- const lanes = () => ctxs.some((c) => c.tree) ? groupLots(ctxs).filter((g) => g.every((c) => lotRepoPaths(c.lot).every((r) => !freed.has(r)))).reduce((n, g) => n + (g.every((c) => c.tree) ? g.length : 1), 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
1112
+ /** Lots dont la vague a fini de jouer (prêts, rendus, en échec ou suspendus) : ils ne tiennent plus de créneau. */
1113
+ const settled = new Set();
1114
+ /** Lots que la vague a encore à jouer, en cours ou en attente, sur les dépôts qu'elle tient encore (un par dépôt, un par lot en worktree) : ce que le registre compte pour les créneaux libres (L433 : un lot fini ne garde plus de créneau, 0 quand plus rien n'est à jouer). Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
1115
+ const lanes = () => ctxs.some((c) => c.tree) ? groupLots(ctxs).filter((g) => g.every((c) => lotRepoPaths(c.lot).every((r) => !freed.has(r)))).reduce((n, g) => { const todo = g.filter((c) => !settled.has(c)).length; return n + (g.every((c) => c.tree) ? todo : Math.min(1, todo)); }, 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
1098
1116
  const freeRepo = (r) => {
1099
1117
  freed.add(r);
1100
1118
  const file = join(sharedStateDir(r), REPO_LOCK);
1101
1119
  releaseLock(file, process.pid);
1102
1120
  held.splice(0, held.length, ...held.filter((f) => f !== file));
1103
1121
  removePrePush(r, wave.id);
1122
+ keepGateGuard(r);
1104
1123
  try {
1105
1124
  updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
1106
1125
  }
@@ -1112,9 +1131,12 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1112
1131
  for (const f of held)
1113
1132
  releaseLock(f, process.pid);
1114
1133
  held.length = 0;
1115
- for (const r of repos)
1116
- if (!freed.has(r))
1117
- removePrePush(r, wave.id);
1134
+ for (const r of repos) {
1135
+ if (freed.has(r))
1136
+ continue;
1137
+ removePrePush(r, wave.id);
1138
+ keepGateGuard(r);
1139
+ }
1118
1140
  try {
1119
1141
  unregisterWave(home, process.pid);
1120
1142
  }
@@ -1125,13 +1147,14 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1125
1147
  for (const r of repos) {
1126
1148
  const file = join(sharedStateDir(r), REPO_LOCK);
1127
1149
  const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
1128
- if (!l.ok) {
1150
+ // Le verrou déjà à ce pid : pris par la reprise qui a attendu l'autre vague (L443).
1151
+ if (!l.ok && l.held.pid !== process.pid) {
1129
1152
  io.err(`orchestrate : ${basename(r)} : une orchestration y est déjà en cours (${l.held.wave}, pid ${l.held.pid})`);
1130
1153
  release();
1131
1154
  return 2;
1132
1155
  }
1133
1156
  held.push(file);
1134
- if (l.stale)
1157
+ if (l.ok && l.stale)
1135
1158
  io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(r)}, pid ${l.stale.pid} mort)`);
1136
1159
  const hook = installPrePush(r, wave.id);
1137
1160
  if (!hook.ok) {
@@ -1159,7 +1182,10 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1159
1182
  return DROPPED;
1160
1183
  return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
1161
1184
  },
1162
- onWait: (h, ms, limit) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${limit} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
1185
+ onWait: (h, ms, limit, lead) => {
1186
+ const who = [...new Set(h.map((x) => x.wave)), ...lead.map((x) => `session du lead ${x.label}`)];
1187
+ wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length + lead.length}/${limit} en cours : ${who.join(', ')})`);
1188
+ },
1163
1189
  onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
1164
1190
  });
1165
1191
  try {
@@ -1247,6 +1273,13 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1247
1273
  if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
1248
1274
  wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : rendu à l'intégration : reprise par ${resumeCommand(wctx.id, '--integrate', lotKey(c.lot.project, c.lot.lot))}`);
1249
1275
  // Un dépôt dont tous les lots de la vague (reprise comprise) sont prêts ou rendus peut être livré pendant que la vague continue ailleurs.
1276
+ settled.add(c);
1277
+ try {
1278
+ updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
1279
+ }
1280
+ catch {
1281
+ // registre illisible : le décompte reste celui du dernier écrit
1282
+ }
1250
1283
  if (wctx.incident)
1251
1284
  return;
1252
1285
  for (const r of lotRepoPaths(c.lot)) {
@@ -1510,133 +1543,193 @@ async function resume(args, argv, io, deps, launch, today) {
1510
1543
  (drops.includes(lotKey(l.project, l.lot)) ? removed : live).push(l);
1511
1544
  }
1512
1545
  // Comme au départ : un dépôt tenu par une autre orchestration vivante est refusé avant toute écriture.
1513
- for (const repo of new Set(live.flatMap(lotRepoPaths))) {
1514
- const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
1515
- if (busy && busy.pid !== process.pid)
1516
- refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
1546
+ // L443 : une reprise d'intégration (--integrate, --replay) attend la fin de l'autre vague au lieu de sortir en 2 ; toute autre reprise refuse comme avant.
1547
+ // Un refus déjà connu (vague qui tourne encore, claude introuvable, session en vie…) sort en 2 tout de suite : attendre 3 h ne le lèverait pas.
1548
+ const waitFor = integ.length && !refusals.length ? { waitMs: deps.repoWait?.waitMs ?? REPO_WAIT_MS, pollMs: deps.repoWait?.pollMs ?? REPO_WAIT_POLL_MS } : null;
1549
+ const deadline = Date.now() + (waitFor?.waitMs ?? 0);
1550
+ // Pendant l'attente (jusqu'à 3 h) la reprise est inscrite parmi les vagues vivantes : le garde « la vague tourne encore » d'une seconde reprise la voit, et ne vient pas attendre à son tour.
1551
+ let waiting = false;
1552
+ // Verrous pris à la fin de l'attente : tenus jusqu'à `execute` (qui les retrouve à son pid), pour qu'une autre reprise qui attendait le même dépôt ne le voie pas libre pendant le .nvmrc, l'arbre et l'écriture des lots.
1553
+ const taken = [];
1554
+ const stopBefore = store.control().stopAfterCurrent;
1555
+ try {
1556
+ for (const repo of new Set(live.flatMap(lotRepoPaths))) {
1557
+ const file = join(sharedStateDir(repo), REPO_LOCK);
1558
+ let announced = null;
1559
+ for (;;) {
1560
+ let busy = activeLock(file);
1561
+ if (waitFor && (!busy || busy.pid === process.pid)) {
1562
+ const t = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
1563
+ if (t.ok) {
1564
+ taken.push(file);
1565
+ if (t.stale)
1566
+ io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(repo)}, pid ${t.stale.pid} mort)`);
1567
+ break;
1568
+ }
1569
+ busy = t.held;
1570
+ }
1571
+ if (!busy || busy.pid === process.pid)
1572
+ break;
1573
+ // Le verrou porte l'id de la vague : tenu par un processus vivant de CETTE vague, c'est une autre reprise de la même vague,
1574
+ // entre son verrou et son inscription par `execute` (le garde « tourne encore » ci-dessus ne la voit pas encore) : refus, pas d'attente.
1575
+ if (busy.wave === wave.id) {
1576
+ refusals.push(`la vague ${wave.id} tourne encore (pid ${busy.pid}, verrou de ${basename(repo)})`);
1577
+ break;
1578
+ }
1579
+ // Un refus déjà posé sur un dépôt précédent (même vague, attente échue) : les suivants ne font plus attendre, la reprise sort en 2.
1580
+ if (!waitFor || refusals.length || Date.now() >= deadline) {
1581
+ refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
1582
+ break;
1583
+ }
1584
+ if (!waiting) {
1585
+ registerWave(cadenceHome(), { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos: [] });
1586
+ waiting = true;
1587
+ }
1588
+ if (announced !== busy.wave)
1589
+ io.out(`orchestrate : ${basename(repo)} : tenu par la vague ${busy.wave} (pid ${busy.pid}) : attente de sa fin avant de reprendre l'intégration`);
1590
+ announced = busy.wave;
1591
+ await new Promise((res) => setTimeout(res, Math.min(waitFor.pollMs, Math.max(1, deadline - Date.now()))));
1592
+ }
1593
+ }
1517
1594
  }
1518
- // Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
1519
- // et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
1520
- const nodes = new Map();
1521
- for (const l of live) {
1522
- const node = resolveNode(l.repo, nvmVersionsDir(io.env));
1523
- if (node.kind === 'missing')
1524
- refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
1525
- else
1526
- nodes.set(l, node);
1595
+ finally {
1596
+ if (waiting)
1597
+ unregisterWave(cadenceHome(), process.pid);
1527
1598
  }
1528
- // Tous les refus avant la moindre écriture (l'arbre sale compris) : une reprise refusée ne change pas l'état de la vague.
1529
- const dirty = new Set();
1530
- for (const repo of live.flatMap(lotRepoPaths))
1531
- if (!dirty.has(repo)) {
1532
- dirty.add(repo);
1533
- const d = await trackedDirty(repo);
1599
+ // Avant `execute`, tout refus ou échec rend les verrous pris ; `execute` les garde ensuite et les rend à sa fin.
1600
+ let played = false;
1601
+ try {
1602
+ // Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
1603
+ // et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
1604
+ const nodes = new Map();
1605
+ for (const l of live) {
1606
+ const node = resolveNode(l.repo, nvmVersionsDir(io.env));
1607
+ if (node.kind === 'missing')
1608
+ refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
1609
+ else
1610
+ nodes.set(l, node);
1611
+ }
1612
+ // Tous les refus avant la moindre écriture (l'arbre sale compris) : une reprise refusée ne change pas l'état de la vague.
1613
+ const dirty = new Set();
1614
+ for (const repo of live.flatMap(lotRepoPaths))
1615
+ if (!dirty.has(repo)) {
1616
+ dirty.add(repo);
1617
+ const d = await trackedDirty(repo);
1618
+ if (d.length)
1619
+ refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1620
+ }
1621
+ // Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
1622
+ for (const l of live) {
1623
+ const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1624
+ if (!existsSync(join(dir, '.git')))
1625
+ continue;
1626
+ const d = await trackedDirty(dir);
1534
1627
  if (d.length)
1535
- refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1628
+ refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1536
1629
  }
1537
- // Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
1538
- for (const l of live) {
1539
- const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1540
- if (!existsSync(join(dir, '.git')))
1541
- continue;
1542
- const d = await trackedDirty(dir);
1543
- if (d.length)
1544
- refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1545
- }
1546
- if (refusals.length) {
1547
- for (const r of refusals)
1548
- io.err(`orchestrate : ${r}`);
1549
- return 2;
1550
- }
1551
- for (const [l, node] of nodes) {
1552
- if (node.kind === 'ok')
1553
- l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
1554
- else
1555
- delete l.node;
1556
- }
1557
- // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
1558
- const recovered = new Budget(0);
1559
- // La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
1560
- const workdirOf = (l) => {
1561
- const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1562
- return existsSync(join(dir, '.git')) ? dir : l.repo;
1563
- };
1564
- for (const l of [...live, ...removed])
1565
- for (const s of l.steps)
1566
- if (s.status === 'interrupted')
1567
- countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
1568
- wave.consumed += recovered.consumed;
1569
- wave.cacheRead += recovered.cacheRead;
1570
- if (recovered.consumed || recovered.cacheRead)
1571
- store.writeWave(wave);
1572
- for (const [l, r] of raised) {
1573
- const k = lotKey(l.project, l.lot);
1574
- const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
1575
- l.budget = r.to;
1576
- l.status = 'suspended';
1577
- l.next = r.step;
1578
- l.outcome = null;
1579
- l.warnings.push(text);
1580
- store.journal(`${k} → suspended — ${text}`);
1581
- io.out(`${k} → suspended — ${text}`);
1582
- }
1583
- for (const l of integ) {
1584
- const k = lotKey(l.project, l.lot);
1585
- const lead = leadReviewed.get(l);
1586
- if (lead) {
1587
- // La revue du lead tient lieu de revue du lot : le rebase reporte son verdict (même patch-id) ou joue la revue courte du diff rebasé.
1588
- l.next = 'integrate';
1589
- l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
1590
- l.verdict = lead.verdict;
1591
- l.constats = [];
1592
- }
1593
- const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
1594
- if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
1595
- l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
1596
- l.status = 'suspended';
1597
- l.outcome = null;
1598
- const text = lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
1599
- store.journal(`${k} → suspended — ${text}`);
1600
- io.out(`${k} → suspended — ${text}`);
1601
- }
1602
- for (const l of live) {
1603
- if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
1604
- l.status = 'suspended';
1605
- store.writeLot(l);
1606
- }
1607
- if (args.budget !== undefined)
1608
- wave.budget = wave.consumed + args.budget;
1609
- store.clearStopRequest(); // un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci
1610
- for (const l of removed) {
1611
- const k = lotKey(l.project, l.lot);
1612
- store.requestDrop(k);
1613
- store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
1614
- io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
1615
- l.next = null;
1616
- l.status = 'handed-back';
1617
- l.outcome = DROPPED;
1618
- store.writeLot(l);
1619
- store.journal(`${k} → handed-back — ${DROPPED}`);
1620
- // Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
1621
- const branch = lotBranch(wave.id, l.project, l.lot);
1622
- if (baseBranchOf(l.repo, branch)) {
1623
- const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
1624
- io.out(line);
1625
- store.journal(line);
1630
+ if (refusals.length) {
1631
+ for (const r of refusals)
1632
+ io.err(`orchestrate : ${r}`);
1633
+ return 2;
1626
1634
  }
1627
- }
1628
- if (live.length === 0) {
1629
- if (lots.every(lotFinished)) {
1630
- wave.status = 'done';
1635
+ for (const [l, node] of nodes) {
1636
+ if (node.kind === 'ok')
1637
+ l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
1638
+ else
1639
+ delete l.node;
1640
+ }
1641
+ // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
1642
+ const recovered = new Budget(0);
1643
+ // La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
1644
+ const workdirOf = (l) => {
1645
+ const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1646
+ return existsSync(join(dir, '.git')) ? dir : l.repo;
1647
+ };
1648
+ for (const l of [...live, ...removed])
1649
+ for (const s of l.steps)
1650
+ if (s.status === 'interrupted')
1651
+ countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
1652
+ wave.consumed += recovered.consumed;
1653
+ wave.cacheRead += recovered.cacheRead;
1654
+ if (recovered.consumed || recovered.cacheRead)
1631
1655
  store.writeWave(wave);
1656
+ for (const [l, r] of raised) {
1657
+ const k = lotKey(l.project, l.lot);
1658
+ const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
1659
+ l.budget = r.to;
1660
+ l.status = 'suspended';
1661
+ l.next = r.step;
1662
+ l.outcome = null;
1663
+ l.warnings.push(text);
1664
+ store.journal(`${k} → suspended — ${text}`);
1665
+ io.out(`${k} → suspended — ${text}`);
1632
1666
  }
1633
- for (const line of renderTable(wave, lots))
1634
- io.out(line);
1635
- for (const line of keptLines(lots.map((l) => l.repo)))
1636
- io.out(line);
1637
- return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
1667
+ for (const l of integ) {
1668
+ const k = lotKey(l.project, l.lot);
1669
+ const lead = leadReviewed.get(l);
1670
+ if (lead) {
1671
+ // La revue du lead tient lieu de revue du lot : le rebase reporte son verdict (même patch-id) ou joue la revue courte du diff rebasé.
1672
+ l.next = 'integrate';
1673
+ l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
1674
+ l.verdict = lead.verdict;
1675
+ l.constats = [];
1676
+ }
1677
+ const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
1678
+ if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
1679
+ l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
1680
+ l.status = 'suspended';
1681
+ l.outcome = null;
1682
+ const text = lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
1683
+ store.journal(`${k} → suspended — ${text}`);
1684
+ io.out(`${k} → suspended — ${text}`);
1685
+ }
1686
+ for (const l of live) {
1687
+ if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
1688
+ l.status = 'suspended';
1689
+ store.writeLot(l);
1690
+ }
1691
+ if (args.budget !== undefined)
1692
+ wave.budget = wave.consumed + args.budget;
1693
+ // Un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci ; celui demandé pendant l'attente (la reprise est alors visible de --stop-after-current) est gardé.
1694
+ if (!(waiting && !stopBefore && store.control().stopAfterCurrent))
1695
+ store.clearStopRequest();
1696
+ for (const l of removed) {
1697
+ const k = lotKey(l.project, l.lot);
1698
+ store.requestDrop(k);
1699
+ store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
1700
+ io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
1701
+ l.next = null;
1702
+ l.status = 'handed-back';
1703
+ l.outcome = DROPPED;
1704
+ store.writeLot(l);
1705
+ store.journal(`${k} → handed-back — ${DROPPED}`);
1706
+ // Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
1707
+ const branch = lotBranch(wave.id, l.project, l.lot);
1708
+ if (baseBranchOf(l.repo, branch)) {
1709
+ const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
1710
+ io.out(line);
1711
+ store.journal(line);
1712
+ }
1713
+ }
1714
+ if (live.length === 0) {
1715
+ if (lots.every(lotFinished)) {
1716
+ wave.status = 'done';
1717
+ store.writeWave(wave);
1718
+ }
1719
+ for (const line of renderTable(wave, lots))
1720
+ io.out(line);
1721
+ for (const line of keptLines(lots.map((l) => l.repo)))
1722
+ io.out(line);
1723
+ return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
1724
+ }
1725
+ played = true;
1726
+ return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
1727
+ }
1728
+ finally {
1729
+ if (!played)
1730
+ for (const f of taken)
1731
+ releaseLock(f, process.pid);
1638
1732
  }
1639
- return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
1640
1733
  }
1641
1734
  /** `loggedIn` de la sortie JSON de `claude auth status` ; null si elle n'est pas lisible (versions sans cette commande : pas de refus). */
1642
1735
  export function parseAuthStatus(text) {
@@ -2,8 +2,18 @@ import { chmodSync, existsSync, lstatSync, readdirSync, statSync, readFileSync,
2
2
  import { execFile, execFileSync } from 'node:child_process';
3
3
  import { appendFileSync, mkdirSync } from 'node:fs';
4
4
  import { dirname, isAbsolute, join, relative } from 'node:path';
5
+ import { readOrchestrateConfig } from '../config.js';
5
6
  import { gitCommonDir, gitRoot, hooksDir, indexLockPath } from '../git.js';
6
7
  const MARK = '# cadence orchestrate — hook pre-push temporaire (retiré à la fin de la vague)';
8
+ /** Le hook que la vague laisse derrière elle quand le projet déclare une porte (L426) : il ne garde que le verdict de la porte au push. */
9
+ const GATE_MARK = '# cadence — hook pre-push de la porte (L426) : refuse un push sans verdict vert de `cadence verify --gate`';
10
+ const gateHook = `#!/bin/sh
11
+ ${GATE_MARK}
12
+ if command -v raf >/dev/null 2>&1; then raf hook pre-push "$@"; [ $? -ne 3 ] || exit 1; fi
13
+ exit 0
14
+ `;
15
+ /** Un hook de cadence (de la vague ou de la porte) : le remplacer ne touche à aucune règle du projet. */
16
+ const ours = (text) => text.includes(MARK) || text.includes(GATE_MARK);
7
17
  const waveLine = (wave) => `# wave: ${wave}`;
8
18
  const hook = (wave) => `#!/bin/sh
9
19
  ${MARK}
@@ -204,7 +214,7 @@ export function cleanPlaywrightOutput(repo) {
204
214
  */
205
215
  export function installPrePush(repo, wave = '?') {
206
216
  const file = hookPath(repo);
207
- if (existsSync(file) && !readFileSync(file, 'utf8').includes(MARK)) {
217
+ if (existsSync(file) && !ours(readFileSync(file, 'utf8'))) {
208
218
  return { ok: false, reason: `un hook pre-push existe déjà (${file}) : l'orchestrateur ne le remplace pas` };
209
219
  }
210
220
  mkdirSync(dirname(file), { recursive: true });
@@ -234,10 +244,32 @@ export function removePrePush(repo, wave) {
234
244
  removePlaywrightExclude(repo);
235
245
  restoreExclude(repo);
236
246
  }
247
+ /**
248
+ * Fin de la garde de la vague sur un dépôt (L426) : quand le projet déclare une porte (`orchestrate.test` ou `orchestrate.build`), le hook de la vague
249
+ * est remplacé par un hook qui ne garde que la porte, sinon le push de livraison, qui vient après la fin des lots du dépôt, ne serait plus contrôlé.
250
+ * Rien n'est laissé quand aucune porte n'est déclarée (ou la config illisible), quand un autre hook pre-push est en place (celui du projet, ou celui d'une autre
251
+ * vague vivante : il reste), ni quand le dossier des hooks est dans l'arbre suivi (le fichier y serait un fichier non suivi du dépôt).
252
+ */
253
+ export function keepGateGuard(repo) {
254
+ try {
255
+ const config = readOrchestrateConfig(join(gitRoot(repo) ?? repo, 'cadence.yaml'));
256
+ if (!config.test && !config.build)
257
+ return;
258
+ }
259
+ catch {
260
+ return;
261
+ }
262
+ const file = hookPath(repo);
263
+ if (existsSync(file) || excludeLine(repo) !== null)
264
+ return;
265
+ mkdirSync(dirname(file), { recursive: true });
266
+ writeFileSync(file, gateHook);
267
+ chmodSync(file, 0o755);
268
+ }
237
269
  /** Pourrait-on poser le hook ? (préconditions, sans écrire) */
238
270
  export function canInstallPrePush(repo) {
239
271
  const file = hookPath(repo);
240
- return existsSync(file) && !readFileSync(file, 'utf8').includes(MARK) ? `un hook pre-push existe déjà (${file}) : l'orchestrateur ne le remplace pas` : null;
272
+ return existsSync(file) && !ours(readFileSync(file, 'utf8')) ? `un hook pre-push existe déjà (${file}) : l'orchestrateur ne le remplace pas` : null;
241
273
  }
242
274
  /** git sans bloquer la boucle d'événements (un `ls-remote` peut durer 20 s) ; null en cas d'échec ou de délai. */
243
275
  function out(repo, args, timeout = 20_000) {
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { gitCommonDir } from '../git.js';
5
5
  import { sharedStateDir } from '../state.js';
6
- import { activeLock, releaseLock, takeLock } from './lock.js';
6
+ import { releaseLock, takeLock } from './lock.js';
7
7
  import { mergeUnreleased, moveToUnreleased, settleLotEntries, unreleasedSections } from './changelog-merge.js';
8
8
  import { mutexFor } from './mutex.js';
9
9
  import { Plan } from '../plan.js';
@@ -19,20 +19,17 @@ export const MAX_REPLAYS = 2;
19
19
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
20
  /**
21
21
  * Prend la main sur le dépôt pour une intégration ou une écriture de plan du programme (R10, R21, R26) : d'abord la file en mémoire du dépôt (les lots d'une
22
- * vague partagent un pid, `takeLock` rendrait chacun déjà porteur), puis le verrou fichier, qui ne départage que les autres processus. L'attente est bornée
23
- * par `waitMs` (R25) ; au-delà, le porteur est rendu et rien n'est pris. Une attente ne retient aucun créneau de session : ce verrou n'a rien à voir avec eux.
22
+ * vague partagent un pid, `takeLock` rendrait chacun déjà porteur), puis le verrou fichier, qui ne départage que les autres processus. La file en mémoire
23
+ * s'attend sans borne (L425) : son porteur est un lot de ce processus, qui libère toujours, et une intégration (rebase rejoué + porte) dure plus que la borne.
24
+ * L'attente du verrou fichier d'un autre processus est bornée par `waitMs` (R25), comptée à partir du tour ; au-delà, le porteur est rendu et rien n'est pris.
25
+ * Une attente ne retient aucun créneau de session : ce verrou n'a rien à voir avec eux.
24
26
  */
25
27
  export async function takeRepoWrite(repo, who, o = {}) {
26
28
  const waitMs = o.waitMs ?? INTEGRATE_WAIT_MS;
27
29
  const pollMs = o.pollMs ?? INTEGRATE_POLL_MS;
28
30
  const file = join(sharedStateDir(repo), INTEGRATION_LOCK);
31
+ const turn = (await mutexFor(`integrate:${gitCommonDir(repo)}`).acquire());
29
32
  const deadline = Date.now() + waitMs;
30
- const turn = await mutexFor(`integrate:${gitCommonDir(repo)}`).acquire(waitMs);
31
- if (!turn) {
32
- // Un autre lot de ce processus tient la main : c'est lui le porteur (il a posé le verrou fichier).
33
- const holder = activeLock(file);
34
- return { ok: false, holder: { wave: holder?.wave ?? who.wave, pid: holder?.pid ?? process.pid } };
35
- }
36
33
  for (;;) {
37
34
  const lock = { pid: process.pid, wave: who.wave, started: new Date().toISOString() };
38
35
  const got = takeLock(file, lock);
@@ -192,7 +192,7 @@ export class SlotAbandoned extends Error {
192
192
  }
193
193
  /**
194
194
  * Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
195
- * soit sous le plafond commun — `cap` (le plafond de cette vague) ou, s'il est plus bas, celui d'une autre vague vivante
195
+ * — plus les sessions du lead inscrites par `lead hold` (L424), qu'aucune vague ne porte — soit sous le plafond commun — `cap` (le plafond de cette vague) ou, s'il est plus bas, celui d'une autre vague vivante
196
196
  * (`commonCap`, relu à chaque tour, L395) —, puis prend le premier créneau libre (un pid mort est repris) et
197
197
  * rend sa libération. Toutes les vagues appliquent le même plafond : jamais « 4/3 en cours ».
198
198
  * Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
@@ -221,13 +221,15 @@ export async function acquireSlot(home, cap, opts) {
221
221
  const limit = commonCap(liveWaves(home), cap); // relu à chaque tour : une vague au plafond bas qui s'arrête le relève
222
222
  if (reportedAt !== null)
223
223
  checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
224
- if (live(home).length < limit) {
224
+ // Les sessions du lead (`lead hold`) tournent hors de toute vague mais prennent un créneau : relues à chaque comptage (L424).
225
+ const taken = () => live(home).length + liveHolds(home, new Date()).length;
226
+ if (taken() < limit) {
225
227
  for (let k = 0; k < MAX_SLOT_INDEX; k++) {
226
228
  const file = join(slotsDir(home), `slot-${k}.lock`);
227
229
  const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });
228
230
  if (!got.ok)
229
231
  continue;
230
- if (live(home).length <= limit) {
232
+ if (taken() <= limit) {
231
233
  if (reportedAt !== null)
232
234
  opts.onGot?.(now() - t0);
233
235
  return () => releaseLock(file, process.pid);
@@ -240,7 +242,7 @@ export async function acquireSlot(home, cap, opts) {
240
242
  checkAbandon(); // la première tentative a échoué : l'attente commence, la demande déjà posée l'interrompt sans attendre un tour
241
243
  if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
242
244
  reportedAt = now();
243
- opts.onWait?.(live(home), reportedAt - t0, limit);
245
+ opts.onWait?.(live(home), reportedAt - t0, limit, liveHolds(home, new Date()));
244
246
  }
245
247
  await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
246
248
  }