@sylad/cadence 0.11.0 → 0.12.0

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.
@@ -0,0 +1,189 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ import { linkNewFile, lockFault, pidAlive, removeStaleFile } from '../state.js';
6
+ import { withoutLaunchVars } from './snapshot.js';
7
+ /** Défaut de `orchestrate.ux.timeout` : secondes d'attente de la réponse de l'application. */
8
+ export const APP_TIMEOUT_S = 300;
9
+ /** Délai entre le SIGTERM et le SIGKILL du groupe de l'application. */
10
+ export const APP_KILL_AFTER_MS = 10_000;
11
+ /** L'environnement de l'application : celui de l'orchestrateur, avec le Node du projet en tête du PATH quand il y en a un (sans préfixe nvm, qui sort en code 3 sous sh). */
12
+ function appEnv(nodeBin) {
13
+ const env = withoutLaunchVars(process.env);
14
+ return nodeBin ? { ...env, PATH: [nodeBin, env.PATH ?? ''].filter(Boolean).join(delimiter) } : env;
15
+ }
16
+ const live = new Set();
17
+ /** Au signal de la vague : arrête les applications lancées (SIGTERM, puis SIGKILL après le délai). L'appelant l'attend avant de mourir. */
18
+ export async function stopApps() {
19
+ await Promise.all([...live].map((stop) => stop()));
20
+ }
21
+ function signal(pid, sig) {
22
+ try {
23
+ process.kill(-pid, sig);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /** Vrai quand l'URL répond en HTTP avec un statut inférieur à 500. */
31
+ export async function responds(url) {
32
+ try {
33
+ const r = await fetch(url, { signal: AbortSignal.timeout(2_000), redirect: 'manual' });
34
+ await r.body?.cancel();
35
+ return r.status < 500;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
42
+ function tail(file) {
43
+ try {
44
+ const lines = readFileSync(file, 'utf8').trimEnd().split('\n').slice(-15).join('\n');
45
+ return lines.slice(-1500) || '(journal vide)';
46
+ }
47
+ catch {
48
+ return '(journal illisible)';
49
+ }
50
+ }
51
+ /** Verrou inter-processus de l'URL (nom : le port seul pour la machine locale, sinon hôte:port) : deux vagues (deux processus) qui déclarent la même URL ne lancent pas deux applications sur le même port. */
52
+ export function uxLockFile(url) {
53
+ const u = new URL(url);
54
+ const port = u.port || (u.protocol === 'https:' ? '443' : '80');
55
+ // localhost, 127.0.0.1 et ::1 désignent la même machine : même port, même verrou
56
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
57
+ return join(tmpdir(), `cadence-ux-${local ? '' : `${u.hostname.replace(/[^\w.-]/g, '_')}-`}${port}.lock`);
58
+ }
59
+ /** Contenu du verrou : un pid, ou null quand le fichier est vide, illisible, ou rendu entre-temps. */
60
+ function readUrlLock(file) {
61
+ try {
62
+ return { text: readFileSync(file, 'utf8').trim(), age: Date.now() - statSync(file).mtimeMs };
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Pose du verrou : lien vers un fichier complet (le verrou n'existe jamais à moitié écrit, comme `writeLock` de state.ts).
70
+ * Un verrou d'un pid mort est écarté par renommage, puis retiré seulement s'il est encore celui qu'on a lu — sinon
71
+ * il est rendu à son nouveau porteur (comme `removeStaleLock`) : deux vagues ne se volent pas le verrou.
72
+ * Faux quand un processus vivant le tient. `pid` : celui du porteur (le nôtre, sauf test).
73
+ */
74
+ export function takeUrlLock(file, pid = process.pid) {
75
+ for (let attempt = 0; attempt < 3; attempt++) {
76
+ if (linkNewFile(file, String(pid), `${pid}.${attempt}`))
77
+ return true;
78
+ const seen = readUrlLock(file);
79
+ if (!seen)
80
+ continue; // rendu entre-temps
81
+ // Un fichier vide est un verrou posé par une version qui l'écrivait en deux temps : on ne le vole qu'au bout de 5 s.
82
+ if (seen.text === '' ? seen.age < 5_000 : pidAlive(Number(seen.text)))
83
+ return false;
84
+ removeStaleFile(file, (aside) => readUrlLock(aside)?.text === seen.text);
85
+ }
86
+ return false;
87
+ }
88
+ function releaseUrlLock(file) {
89
+ try {
90
+ if (Number(readFileSync(file, 'utf8').trim()) === process.pid)
91
+ rmSync(file, { force: true });
92
+ }
93
+ catch {
94
+ /* déjà rendu */
95
+ }
96
+ }
97
+ /**
98
+ * Lance l'application de la revue UX : prend le verrou de l'URL (attente comptée dans le délai), sonde l'URL (qui répond
99
+ * déjà : rien n'est lancé), lance la commande depuis `cwd` dans son propre groupe de processus (sortie dans `log`),
100
+ * puis sonde jusqu'à une réponse HTTP < 500 — le groupe lancé encore vivant — ou au délai. Le verrou est rendu à `stop`.
101
+ */
102
+ export async function startApp(o) {
103
+ const none = async () => { };
104
+ const deadline = Date.now() + o.timeoutMs;
105
+ const lock = uxLockFile(o.url);
106
+ const held = { state: { kind: 'unverified', cause: `url tenue par une autre vague (${o.url})` }, stop: none };
107
+ try {
108
+ while (!takeUrlLock(lock)) {
109
+ if (Date.now() >= deadline)
110
+ return held;
111
+ await sleep(o.every ?? 500);
112
+ }
113
+ }
114
+ catch (e) {
115
+ // Verrou impossible à poser (pas de liens physiques…) : comme un journal illisible, la revue UX n'a pas lieu, la cause est dite.
116
+ return { state: { kind: 'unverified', cause: lockFault(lock, e) }, stop: none };
117
+ }
118
+ const release = () => releaseUrlLock(lock);
119
+ if (Date.now() >= deadline) {
120
+ release(); // obtenu après le délai : toute l'attente a été passée à celle du verrou, il ne reste rien pour lancer l'application
121
+ return held;
122
+ }
123
+ try {
124
+ return await launch(o, deadline, release);
125
+ }
126
+ catch (e) {
127
+ release();
128
+ throw e;
129
+ }
130
+ }
131
+ /** `release` rend le verrou : dès que rien ne tourne (busy, échec), sinon à la fin de `stop`, une fois le groupe disparu. */
132
+ async function launch(o, deadline, release) {
133
+ const none = async () => { };
134
+ const available = Math.max(0, deadline - Date.now()); // le délai de l'étape moins l'attente du verrou : c'est lui que le message annonce
135
+ if (await responds(o.url)) {
136
+ release();
137
+ return { state: { kind: 'busy' }, stop: none };
138
+ }
139
+ let fd;
140
+ try {
141
+ fd = openSync(o.log, 'a');
142
+ }
143
+ catch (e) {
144
+ release();
145
+ return { state: { kind: 'unverified', cause: `journal ${o.log} : ${e.message}` }, stop: none };
146
+ }
147
+ const child = spawn('sh', ['-c', o.command], { cwd: o.cwd, env: appEnv(o.nodeBin), stdio: ['ignore', fd, fd], detached: true });
148
+ closeSync(fd);
149
+ const pid = child.pid;
150
+ if (pid === undefined) {
151
+ const cause = await new Promise((r) => child.once('error', (e) => r(e.message)));
152
+ release();
153
+ return { state: { kind: 'unverified', cause: `lancement impossible : ${cause}` }, stop: none };
154
+ }
155
+ const gone = {};
156
+ child.once('error', (e) => (gone.why = `lancement impossible : ${e.message}`));
157
+ child.once('close', (code, sig) => (gone.why = `la commande s'est arrêtée (${sig ?? `code ${code}`})`));
158
+ const stop = async () => {
159
+ live.delete(stop);
160
+ try {
161
+ if (!signal(pid, 'SIGTERM'))
162
+ return;
163
+ const limit = Date.now() + (o.killAfterMs ?? APP_KILL_AFTER_MS);
164
+ while (Date.now() < limit && signal(pid, 0))
165
+ await sleep(50);
166
+ signal(pid, 'SIGKILL');
167
+ for (let i = 0; i < 40 && signal(pid, 0); i++)
168
+ await sleep(25); // le SIGKILL n'est pas instantané : le groupe disparu, pas seulement signalé
169
+ }
170
+ finally {
171
+ release(); // après l'arrêt de l'application seulement : l'autre vague ne trouve pas un port encore tenu
172
+ }
173
+ };
174
+ live.add(stop);
175
+ for (;;) {
176
+ if (await responds(o.url)) {
177
+ if (signal(pid, 0))
178
+ return { state: { kind: 'ready' }, stop };
179
+ gone.why ??= 'la commande s\'est arrêtée alors que l\'URL répond (autre processus ?)'; // la réponse n'est pas la nôtre
180
+ break;
181
+ }
182
+ if (gone.why || Date.now() >= deadline)
183
+ break;
184
+ await sleep(o.every ?? 500);
185
+ }
186
+ const why = gone.why ?? `l'URL n'a pas répondu en ${Math.round(available / 1000)} s`;
187
+ await stop();
188
+ return { state: { kind: 'unverified', cause: `${why}. Fin du journal :\n${tail(o.log)}` }, stop: none };
189
+ }
@@ -4,7 +4,23 @@ import { fileURLToPath } from 'node:url';
4
4
  import { RafError } from '../plan.js';
5
5
  /** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
6
6
  export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
7
+ /** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir le README). */
8
+ export function newsText(lot, captureDir) {
9
+ return [
10
+ `This lot is visible: write its News entry. Run \`cadence news new ${lot}\` (it creates \`docs/nouveautes/<date>-<slug>.md\`), then write the text in terms of what changes for the user, factual (what the user sees or can now do, no implementation detail, no promise).`,
11
+ `The entry's title is user-side: \`cadence news new\` takes the lot's public title when it has one; otherwise rewrite the title in the user's words with \`--title\` (never the internal title of the lot).`,
12
+ captureDir
13
+ ? `Add a screenshot of the result: copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (a Playwright capture lands in the wave's output directory, outside the repository: \`${captureDir}\`, which this session may read; the file you keep for the entry is the one exception, copied into \`docs/nouveautes/captures/\`); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`
14
+ : `Add a screenshot of the result: save it under the OS temp directory (never inside the repository), then copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (the file you keep for the entry is the one exception to "nothing inside the repository"); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`,
15
+ 'Commit the entry with the lot (`cadence news check` must pass).',
16
+ ].join('\n');
17
+ }
18
+ /** Consigne des briefs de correction d'un lot `visible` : où la vague range les captures Playwright (même dossier que l'étape implement). */
19
+ export function capturesText(captureDir) {
20
+ return `Playwright captures of this wave land in \`${captureDir}\` (outside the repository; this session may read it): a screenshot a finding asks for is copied from there into \`docs/nouveautes/captures/\`.`;
21
+ }
7
22
  const FILES = {
23
+ precheck: 'precheck.md',
8
24
  implement: 'implement.md',
9
25
  fix: 'fix.md',
10
26
  'fix-minors': 'fix-minors.md',
@@ -6,20 +6,28 @@ import { readOrchestrateConfig, readPlanConfig } from '../config.js';
6
6
  import { isDay, toDay } from '../dates.js';
7
7
  import { gitRoot } from '../git.js';
8
8
  import { onTermination } from '../proc.js';
9
+ import { stopApps } from './app.js';
9
10
  import { Plan, RafError, isOpen } from '../plan.js';
10
11
  import { AGENTS_DIR } from '../skills.js';
11
12
  import { pidAlive, sharedStateDir } from '../state.js';
12
13
  import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave } from './registry.js';
13
- import { loadTemplates, objective, renderBrief } from './briefs.js';
14
- import { Budget, MAX_PASSES, countInterrupted } from './cycle.js';
14
+ import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
+ import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
15
16
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
16
- import { buildArgs, killSessions, readAgents, realClaude } from './launch.js';
17
+ import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
17
18
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
18
19
  import { runPool } from './pool.js';
19
20
  import { schemaFor } from './schemas.js';
20
21
  import { excludeState, lotKey, newLot, RunStore } from './state.js';
22
+ import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
21
23
  import { quotaText, renderTable } from './table.js';
24
+ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
22
25
  export const DEFAULT_BUDGET = 2_000_000;
26
+ /** Tokens comptés par jour d'estimate (L78) ; mesuré le 06-10 : des lots de 0,5 j ont mangé 192 à 531 k, des lots de 1 j 455 à 468 k (2 M pour 6 lots) ; le plafond est posé sous ces mesures pour arrêter vraiment. */
27
+ const LOT_BUDGET_PER_DAY = 400_000;
28
+ const LOT_BUDGET_FLOOR = 200_000;
29
+ /** Budget d'un lot, dérivé de son estimate : empêche quelques lots d'avaler le budget de la vague au détriment des autres. */
30
+ export const lotBudget = (estimate) => Math.max(LOT_BUDGET_FLOOR, Math.round(estimate * LOT_BUDGET_PER_DAY));
23
31
  /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
24
32
  export const DEFAULT_MAX_SESSIONS = 2;
25
33
  /** `1500000`, `1.5M`, `800k`. */
@@ -85,7 +93,7 @@ export function parseOrchestrateArgs(argv) {
85
93
  /** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
86
94
  export function projectEnv(repo) {
87
95
  const configPath = join(repo, 'cadence.yaml');
88
- const planConfig = readPlanConfig(configPath);
96
+ const planConfig = readPlanConfig(configPath, repo);
89
97
  const planPath = resolve(repo, planConfig?.path ?? 'docs/plan/raf.yaml');
90
98
  return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
91
99
  }
@@ -182,9 +190,18 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
182
190
  if (busy && !opts.resume)
183
191
  refusals.push(`${basename(t.repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
184
192
  }
193
+ const node = resolveNode(t.repo, nvmVersionsDir(io.env));
194
+ if (node.kind === 'missing') {
195
+ refusals.push(`${key} : ${node.message}`);
196
+ continue;
197
+ }
185
198
  const small = lot.estimate <= 0.5 || lot.quickwin;
186
199
  const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, model: t.model, readOnlyPlan: plan.readonly });
200
+ state.light = lot.estimate <= env.config.review.threshold;
201
+ state.budget = lotBudget(lot.estimate);
187
202
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
203
+ if (node.kind === 'ok')
204
+ state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
188
205
  lots.push(state);
189
206
  }
190
207
  return { refusals, lots };
@@ -218,26 +235,40 @@ function dryRun(lots, io, deps, budget, id) {
218
235
  const lot = env.loadPlan().lot(l.lot);
219
236
  const slot = repos.indexOf(l.repo);
220
237
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
238
+ io.out(` budget du lot : ${l.budget} tokens comptés (dérivé de l'estimate)`);
221
239
  io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
240
+ if (l.node) {
241
+ const sk = l.node.skipped;
242
+ const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
243
+ io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
244
+ }
222
245
  const steps = [{ kind: 'implement', model: l.model }];
246
+ if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot))
247
+ steps.unshift({ kind: 'precheck', model: 'sonnet' });
248
+ const { light, full } = env.config.review;
223
249
  if (l.small)
224
- steps.push({ kind: l.visible ? 'review-small' : 'review', model: 'opus' });
250
+ steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
225
251
  else {
226
252
  if (l.visible && env.config.ux)
227
- steps.push({ kind: 'ux', model: 'opus' });
228
- steps.push({ kind: 'review', model: 'opus' });
253
+ steps.push({ kind: 'ux', model: full });
254
+ steps.push({ kind: 'review', model: l.light ? light : full });
229
255
  }
230
- io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
256
+ io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.light ? ` — revue légère (${light}, pas de passe des mineurs ; un bloquant ou un majeur → correction puis revue ${full})` : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
231
257
  io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
232
- io.out(' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
233
- const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '' };
258
+ io.out(l.light ? ' revue conforme avec mineurs : pas de passe des mineurs, ils sont rendus en notes' : ' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
259
+ const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, `${l.project}--${l.lot}`, 'playwright');
260
+ // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
261
+ const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
234
262
  for (const s of steps) {
235
263
  const file = join(tmp, `${l.project}--${l.lot}--${s.kind}.md`);
236
264
  const brief = renderBrief(s.kind, vars, deps.templatesDir);
237
265
  writeFileSync(file, brief);
238
- const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: env.config.addDirs, timeoutMs: 0 }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
266
+ const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
267
+ const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: playwright && (s.kind === 'implement') ? [...env.config.addDirs, pwDir] : env.config.addDirs, timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
239
268
  io.out(` ${s.kind} : claude ${args.join(' ')}`);
240
269
  io.out(` brief : ${file}`);
270
+ const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
271
+ io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${l.project}--${l.lot}/playwright)` : 'aucun'}`);
241
272
  }
242
273
  }
243
274
  }
@@ -290,7 +321,7 @@ export async function orchestrate(argv, io, deps) {
290
321
  return 0;
291
322
  }
292
323
  if (args.resume !== undefined)
293
- return resume(args, io, deps, launch, today);
324
+ return resume(args, argv, io, deps, launch, today);
294
325
  if (args.lots.length === 0)
295
326
  throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] | --status [vague] | --resume [vague] [--answer projet:lot "réponse"]');
296
327
  const refusals = [];
@@ -312,13 +343,42 @@ export async function orchestrate(argv, io, deps) {
312
343
  dryRun(pre.lots, io, deps, budget, id);
313
344
  return 0;
314
345
  }
315
- const store = RunStore.reserve(launch, args.wave ?? defaultWaveBase(io), { exact: args.wave !== undefined });
346
+ if (deps.snapshot) {
347
+ // Refus avant de réserver la vague : rien n'est créé si les dépendances ne se résolvent pas.
348
+ try {
349
+ resolveModulesDir(deps.snapshot.packageRoot);
350
+ }
351
+ catch (e) {
352
+ if (!(e instanceof SnapshotRefusal))
353
+ throw e;
354
+ io.err(`orchestrate : ${e.message}`);
355
+ return 2;
356
+ }
357
+ }
358
+ // Processus relancé depuis l'instantané : la vague a déjà été réservée par le processus d'origine.
359
+ const reserved = deps.snapshot ? undefined : io.env[RESERVED_ENV];
360
+ const store = reserved ? new RunStore(launch, reserved) : RunStore.reserve(launch, args.wave ?? defaultWaveBase(io), { exact: args.wave !== undefined });
316
361
  if (!store)
317
362
  return waveExists(args.wave, io);
363
+ if (deps.snapshot) {
364
+ try {
365
+ const tool = takeSnapshot(store.dir, deps.snapshot.packageRoot);
366
+ const code = await deps.snapshot.reexec(tool, argv, { ...io.env, [RESERVED_ENV]: store.id }, io);
367
+ if (!existsSync(join(store.dir, 'wave.json')))
368
+ rmSync(store.dir, { recursive: true, force: true });
369
+ return code;
370
+ }
371
+ catch (e) {
372
+ rmSync(store.dir, { recursive: true, force: true });
373
+ if (e instanceof SnapshotRefusal) {
374
+ io.err(`orchestrate : ${e.message}`);
375
+ return 2;
376
+ }
377
+ throw e;
378
+ }
379
+ }
318
380
  const id = store.id;
319
381
  const wave = { id, created: io.now().toISOString(), cwd: launch, budget, consumed: 0, cacheRead: 0, status: 'running', pid: process.pid, lots: pre.lots.map((l) => lotKey(l.project, l.lot)) };
320
- for (const l of pre.lots)
321
- store.writeLot(l);
322
382
  // Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
323
383
  // le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
324
384
  const abandon = () => {
@@ -326,6 +386,12 @@ export async function orchestrate(argv, io, deps) {
326
386
  rmSync(store.dir, { recursive: true, force: true });
327
387
  };
328
388
  try {
389
+ // Dans la section gardée : un lien ou une écriture qui échoue ne laisse pas la vague réservée sans wave.json.
390
+ for (const l of pre.lots) {
391
+ if (l.node)
392
+ l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
393
+ store.writeLot(l);
394
+ }
329
395
  const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
330
396
  abandon();
331
397
  return code;
@@ -434,8 +500,9 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
434
500
  saveWave();
435
501
  const ctxs = contexts(lots, envs, wctx);
436
502
  // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
437
- const forget = onTermination(() => {
503
+ const forget = onTermination(async () => {
438
504
  killSessions();
505
+ await stopApps(); // les applications de la revue UX : SIGTERM, SIGKILL après 10 s
439
506
  for (const c of ctxs) {
440
507
  for (const s of c.lot.steps) {
441
508
  if (s.status === 'running')
@@ -473,10 +540,16 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
473
540
  return 3;
474
541
  return all.every((l) => l.status === 'ready') ? 0 : 1;
475
542
  }
476
- async function resume(args, io, deps, launch, today) {
543
+ async function resume(args, argv, io, deps, launch, today) {
477
544
  const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : RunStore.last(launch, { unfinished: true });
478
545
  if (!store)
479
546
  throw new RafError(typeof args.resume === 'string' ? `vague inconnue : ${args.resume}` : 'aucune vague à reprendre dans ce dossier');
547
+ if (deps.snapshot) {
548
+ // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
549
+ if (snapshotExists(store.dir))
550
+ return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
551
+ io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
552
+ }
480
553
  const wave = store.readWave();
481
554
  const refusals = [];
482
555
  const info = deps.claudeInfo();
@@ -521,11 +594,27 @@ async function resume(args, io, deps, launch, today) {
521
594
  if (busy && busy.pid !== process.pid)
522
595
  refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
523
596
  }
597
+ // Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
598
+ // et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
599
+ const nodes = new Map();
600
+ for (const l of live) {
601
+ const node = resolveNode(l.repo, nvmVersionsDir(io.env));
602
+ if (node.kind === 'missing')
603
+ refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
604
+ else
605
+ nodes.set(l, node);
606
+ }
524
607
  if (refusals.length) {
525
608
  for (const r of refusals)
526
609
  io.err(`orchestrate : ${r}`);
527
610
  return 2;
528
611
  }
612
+ for (const [l, node] of nodes) {
613
+ if (node.kind === 'ok')
614
+ l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
615
+ else
616
+ delete l.node;
617
+ }
529
618
  // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
530
619
  const recovered = new Budget(0);
531
620
  for (const l of live)
@@ -566,6 +655,9 @@ async function resume(args, io, deps, launch, today) {
566
655
  /** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
567
656
  export function realOrchestrateDeps(env) {
568
657
  const bin = env.CADENCE_CLAUDE_BIN || 'claude';
658
+ const child = isSnapshotChild(env);
659
+ // Lues ici, une fois : ni les sessions ni les commandes du projet ne reçoivent les variables de relance.
660
+ env = withoutLaunchVars({ ...env });
569
661
  return {
570
662
  claude: realClaude(bin, env),
571
663
  claudeInfo: () => {
@@ -579,6 +671,7 @@ export function realOrchestrateDeps(env) {
579
671
  }
580
672
  },
581
673
  agentsDir: AGENTS_DIR,
674
+ snapshot: child ? undefined : { packageRoot: PACKAGE_ROOT, reexec: spawnReexec },
582
675
  claudeHome: env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
583
676
  };
584
677
  }