@sylad/cadence 0.10.1 → 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.
- package/.claude-plugin/marketplace.json +16 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +219 -21
- package/agents/code-reviewer.md +8 -3
- package/agents/precheck-reader.md +14 -0
- package/agents/qa-reviewer.md +19 -2
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +3 -2
- package/dist/audit.js +34 -7
- package/dist/cli.js +113 -13
- package/dist/config.js +104 -15
- package/dist/deliver.js +10 -3
- package/dist/news.js +17 -1
- package/dist/orchestrate/app.js +189 -0
- package/dist/orchestrate/briefs.js +16 -0
- package/dist/orchestrate/command.js +224 -45
- package/dist/orchestrate/cycle.js +208 -26
- package/dist/orchestrate/guard.js +148 -3
- package/dist/orchestrate/launch.js +43 -5
- package/dist/orchestrate/lock.js +19 -5
- package/dist/orchestrate/node-env.js +77 -0
- package/dist/orchestrate/pool.js +4 -3
- package/dist/orchestrate/registry.js +120 -0
- package/dist/orchestrate/schemas.js +7 -1
- package/dist/orchestrate/snapshot.js +99 -0
- package/dist/orchestrate/state.js +22 -0
- package/dist/orchestrate/table.js +11 -1
- package/dist/plan.js +29 -0
- package/dist/proc.js +19 -0
- package/dist/session.js +6 -3
- package/dist/state.js +34 -14
- package/package.json +1 -1
- package/skills/lead/SKILL.md +17 -8
- package/templates/orchestrate/fix-minors.md +3 -0
- package/templates/orchestrate/fix.md +4 -0
- package/templates/orchestrate/implement.md +5 -1
- package/templates/orchestrate/precheck.md +10 -0
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +3 -0
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/ux.md +2 -0
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { basename, join, resolve } from 'node:path';
|
|
5
5
|
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
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave } from './registry.js';
|
|
14
|
+
import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
|
|
15
|
+
import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
|
|
14
16
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
15
|
-
import { buildArgs, killSessions, readAgents, realClaude } from './launch.js';
|
|
17
|
+
import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
16
18
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
17
19
|
import { runPool } from './pool.js';
|
|
18
20
|
import { schemaFor } from './schemas.js';
|
|
19
21
|
import { excludeState, lotKey, newLot, RunStore } from './state.js';
|
|
22
|
+
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
20
23
|
import { quotaText, renderTable } from './table.js';
|
|
24
|
+
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
21
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));
|
|
31
|
+
/** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
|
|
32
|
+
export const DEFAULT_MAX_SESSIONS = 2;
|
|
22
33
|
/** `1500000`, `1.5M`, `800k`. */
|
|
23
34
|
export function parseBudget(text) {
|
|
24
35
|
const m = /^(\d+(?:[.,]\d+)?)\s*([kKmM]?)$/.exec(text.trim());
|
|
@@ -29,6 +40,11 @@ export function parseBudget(text) {
|
|
|
29
40
|
throw new RafError(`--budget invalide : ${text}`);
|
|
30
41
|
return Math.round(n);
|
|
31
42
|
}
|
|
43
|
+
export function parseMaxSessions(text, what = '--max-sessions') {
|
|
44
|
+
if (!/^\d+$/.test(text.trim()) || Number(text) < 1)
|
|
45
|
+
throw new RafError(`${what} invalide : ${text} (entier ≥ 1)`);
|
|
46
|
+
return Number(text);
|
|
47
|
+
}
|
|
32
48
|
const LOT_LIKE = /^(?:[\w.-]+:)?[A-Za-z]+\d+(?:@\w+)?$/;
|
|
33
49
|
const MODELS = ['sonnet', 'opus', 'haiku'];
|
|
34
50
|
function lotArg(text) {
|
|
@@ -59,6 +75,8 @@ export function parseOrchestrateArgs(argv) {
|
|
|
59
75
|
}
|
|
60
76
|
else if (t === '--budget')
|
|
61
77
|
a.budget = parseBudget(value(t));
|
|
78
|
+
else if (t === '--max-sessions')
|
|
79
|
+
a.maxSessions = parseMaxSessions(value(t));
|
|
62
80
|
else if (t === '--wave')
|
|
63
81
|
a.wave = value(t);
|
|
64
82
|
else if (t === '--answer') {
|
|
@@ -75,7 +93,7 @@ export function parseOrchestrateArgs(argv) {
|
|
|
75
93
|
/** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
|
|
76
94
|
export function projectEnv(repo) {
|
|
77
95
|
const configPath = join(repo, 'cadence.yaml');
|
|
78
|
-
const planConfig = readPlanConfig(configPath);
|
|
96
|
+
const planConfig = readPlanConfig(configPath, repo);
|
|
79
97
|
const planPath = resolve(repo, planConfig?.path ?? 'docs/plan/raf.yaml');
|
|
80
98
|
return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
|
|
81
99
|
}
|
|
@@ -122,9 +140,6 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
122
140
|
refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
|
|
123
141
|
else if (!info.jsonSchema)
|
|
124
142
|
refusals.push(`claude ${info.version} n'a pas --json-schema : mettre claude à jour`);
|
|
125
|
-
const held = activeLock(join(launch, '.cadence', 'orchestrate.lock'));
|
|
126
|
-
if (held && !opts.resume)
|
|
127
|
-
refusals.push(`une vague est déjà en cours dans ${launch} (${held.wave}, pid ${held.pid})`);
|
|
128
143
|
const lots = [];
|
|
129
144
|
const repoChecked = new Set();
|
|
130
145
|
const byProject = new Map();
|
|
@@ -175,9 +190,18 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
175
190
|
if (busy && !opts.resume)
|
|
176
191
|
refusals.push(`${basename(t.repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
177
192
|
}
|
|
193
|
+
const node = resolveNode(t.repo, nvmVersionsDir(io.env));
|
|
194
|
+
if (node.kind === 'missing') {
|
|
195
|
+
refusals.push(`${key} : ${node.message}`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
178
198
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
179
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);
|
|
180
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 } : {}) };
|
|
181
205
|
lots.push(state);
|
|
182
206
|
}
|
|
183
207
|
return { refusals, lots };
|
|
@@ -185,14 +209,17 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
185
209
|
function agentsOf(deps) {
|
|
186
210
|
return readAgents(deps.agentsDir);
|
|
187
211
|
}
|
|
212
|
+
const WAVE_ID_RE = /^[\w.-]+$/;
|
|
213
|
+
/** Identifiant par défaut : jour et minute de lancement ; le suffixe qui départage deux vagues vient de `RunStore.reserve`. */
|
|
214
|
+
function defaultWaveBase(io) {
|
|
215
|
+
const n = io.now();
|
|
216
|
+
return `${toDay(n)}-${String(n.getHours()).padStart(2, '0')}${String(n.getMinutes()).padStart(2, '0')}`;
|
|
217
|
+
}
|
|
218
|
+
/** Identifiant d'une simulation : le premier libre, sans rien réserver ni écrire. */
|
|
188
219
|
function waveId(io, requested, launch) {
|
|
189
|
-
if (requested)
|
|
190
|
-
if (!/^[\w.-]+$/.test(requested))
|
|
191
|
-
throw new RafError(`--wave invalide : ${requested}`);
|
|
220
|
+
if (requested)
|
|
192
221
|
return requested;
|
|
193
|
-
|
|
194
|
-
const n = io.now();
|
|
195
|
-
const base = `${toDay(n)}-${String(n.getHours()).padStart(2, '0')}${String(n.getMinutes()).padStart(2, '0')}`;
|
|
222
|
+
const base = defaultWaveBase(io);
|
|
196
223
|
let id = base;
|
|
197
224
|
for (let i = 2; existsSync(join(RunStore.runsDir(launch), id)); i++)
|
|
198
225
|
id = `${base}-${i}`;
|
|
@@ -208,29 +235,70 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
208
235
|
const lot = env.loadPlan().lot(l.lot);
|
|
209
236
|
const slot = repos.indexOf(l.repo);
|
|
210
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)`);
|
|
211
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
|
+
}
|
|
212
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;
|
|
213
249
|
if (l.small)
|
|
214
|
-
steps.push({ kind: l.visible ? 'review-small' : 'review', model:
|
|
250
|
+
steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
|
|
215
251
|
else {
|
|
216
252
|
if (l.visible && env.config.ux)
|
|
217
|
-
steps.push({ kind: 'ux', model:
|
|
218
|
-
steps.push({ kind: 'review', model:
|
|
253
|
+
steps.push({ kind: 'ux', model: full });
|
|
254
|
+
steps.push({ kind: 'review', model: l.light ? light : full });
|
|
219
255
|
}
|
|
220
|
-
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)' : ''}`);
|
|
221
257
|
io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
|
|
222
|
-
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 »');
|
|
223
|
-
const
|
|
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: '' };
|
|
224
262
|
for (const s of steps) {
|
|
225
263
|
const file = join(tmp, `${l.project}--${l.lot}--${s.kind}.md`);
|
|
226
264
|
const brief = renderBrief(s.kind, vars, deps.templatesDir);
|
|
227
265
|
writeFileSync(file, brief);
|
|
228
|
-
const
|
|
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));
|
|
229
268
|
io.out(` ${s.kind} : claude ${args.join(' ')}`);
|
|
230
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'}`);
|
|
231
272
|
}
|
|
232
273
|
}
|
|
233
274
|
}
|
|
275
|
+
function maxSessions(args, io) {
|
|
276
|
+
if (args.maxSessions !== undefined)
|
|
277
|
+
return args.maxSessions;
|
|
278
|
+
const env = io.env.CADENCE_MAX_SESSIONS;
|
|
279
|
+
return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
|
|
280
|
+
}
|
|
281
|
+
function duration(ms) {
|
|
282
|
+
const s = Math.round(ms / 1000);
|
|
283
|
+
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
|
|
284
|
+
}
|
|
285
|
+
/** Vagues vivantes (toutes, quel que soit leur dossier de départ) et dépôts qu'elles tiennent. */
|
|
286
|
+
function liveLines() {
|
|
287
|
+
const home = cadenceHome();
|
|
288
|
+
const waves = liveWaves(home);
|
|
289
|
+
if (waves.length === 0)
|
|
290
|
+
return [];
|
|
291
|
+
const slots = liveSlots(home).length;
|
|
292
|
+
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}`];
|
|
293
|
+
for (const w of waves)
|
|
294
|
+
lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
|
|
295
|
+
return lines;
|
|
296
|
+
}
|
|
297
|
+
/** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
|
|
298
|
+
function waveExists(wave, io) {
|
|
299
|
+
io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
|
|
300
|
+
return 2;
|
|
301
|
+
}
|
|
234
302
|
/** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
|
|
235
303
|
export async function orchestrate(argv, io, deps) {
|
|
236
304
|
const args = parseOrchestrateArgs(argv);
|
|
@@ -238,16 +306,24 @@ export async function orchestrate(argv, io, deps) {
|
|
|
238
306
|
const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
|
|
239
307
|
if (args.status !== undefined) {
|
|
240
308
|
const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
|
|
309
|
+
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
310
|
+
const live = liveLines();
|
|
311
|
+
for (const line of live)
|
|
312
|
+
io.out(line);
|
|
313
|
+
if (!store && live.length && args.status === true)
|
|
314
|
+
return 0; // sans identifiant : les vagues vivantes suffisent
|
|
241
315
|
if (!store)
|
|
242
316
|
throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
|
|
317
|
+
if (live.length)
|
|
318
|
+
io.out('');
|
|
243
319
|
for (const line of renderTable(store.readWave(), store.lots()))
|
|
244
320
|
io.out(line);
|
|
245
321
|
return 0;
|
|
246
322
|
}
|
|
247
323
|
if (args.resume !== undefined)
|
|
248
|
-
return resume(args, io, deps, launch, today);
|
|
324
|
+
return resume(args, argv, io, deps, launch, today);
|
|
249
325
|
if (args.lots.length === 0)
|
|
250
|
-
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--dry-run] | --status [vague] | --resume [vague] [--answer projet:lot "réponse"]');
|
|
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"]');
|
|
251
327
|
const refusals = [];
|
|
252
328
|
const targets = resolveTargets(args, io, refusals);
|
|
253
329
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -258,22 +334,78 @@ export async function orchestrate(argv, io, deps) {
|
|
|
258
334
|
io.err(`orchestrate : ${r}`);
|
|
259
335
|
return 2;
|
|
260
336
|
}
|
|
261
|
-
|
|
337
|
+
if (args.wave !== undefined && !WAVE_ID_RE.test(args.wave))
|
|
338
|
+
throw new RafError(`--wave invalide : ${args.wave}`);
|
|
262
339
|
if (args.dryRun) {
|
|
340
|
+
const id = waveId(io, args.wave, launch);
|
|
341
|
+
if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
|
|
342
|
+
return waveExists(args.wave, io);
|
|
263
343
|
dryRun(pre.lots, io, deps, budget, id);
|
|
264
344
|
return 0;
|
|
265
345
|
}
|
|
266
|
-
|
|
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 });
|
|
361
|
+
if (!store)
|
|
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
|
+
}
|
|
380
|
+
const id = store.id;
|
|
267
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)) };
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
382
|
+
// Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
|
|
383
|
+
// le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
|
|
384
|
+
const abandon = () => {
|
|
385
|
+
if (!existsSync(join(store.dir, 'wave.json')))
|
|
386
|
+
rmSync(store.dir, { recursive: true, force: true });
|
|
387
|
+
};
|
|
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
|
+
}
|
|
395
|
+
const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
|
|
396
|
+
abandon();
|
|
397
|
+
return code;
|
|
398
|
+
}
|
|
399
|
+
catch (e) {
|
|
400
|
+
abandon();
|
|
401
|
+
throw e;
|
|
402
|
+
}
|
|
271
403
|
}
|
|
272
404
|
function contexts(lots, envs, wctx) {
|
|
273
405
|
return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
|
|
274
406
|
}
|
|
275
407
|
/** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
|
|
276
|
-
async function execute(wave, lots, store, io, deps, today, all = lots) {
|
|
408
|
+
async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
277
409
|
const launch = store.launchDir;
|
|
278
410
|
// La configuration des projets est lue avant de poser quoi que ce soit : un cadence.yaml illisible ne laisse ni verrou ni hook.
|
|
279
411
|
const envs = lots.map((lot) => projectEnv(lot.repo));
|
|
@@ -281,7 +413,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
|
|
|
281
413
|
const templates = loadTemplates(deps.templatesDir);
|
|
282
414
|
mkdirSync(join(launch, '.cadence'), { recursive: true });
|
|
283
415
|
excludeState(launch);
|
|
284
|
-
const
|
|
416
|
+
const home = cadenceHome();
|
|
285
417
|
const held = [];
|
|
286
418
|
const repos = [...new Set(lots.map((l) => l.repo))];
|
|
287
419
|
const release = () => {
|
|
@@ -290,15 +422,13 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
|
|
|
290
422
|
held.length = 0;
|
|
291
423
|
for (const r of repos)
|
|
292
424
|
removePrePush(r, wave.id);
|
|
425
|
+
try {
|
|
426
|
+
unregisterWave(home, process.pid);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// registre illisible : l'entrée d'un processus mort est écartée à la prochaine inscription
|
|
430
|
+
}
|
|
293
431
|
};
|
|
294
|
-
const got = takeLock(waveLock, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
|
|
295
|
-
if (!got.ok) {
|
|
296
|
-
io.err(`orchestrate : une vague est déjà en cours dans ${launch} (${got.held.wave}, pid ${got.held.pid})`);
|
|
297
|
-
return 2;
|
|
298
|
-
}
|
|
299
|
-
held.push(waveLock);
|
|
300
|
-
if (got.stale)
|
|
301
|
-
io.err(`orchestrate : verrou de vague périmé retiré (${got.stale.wave}, pid ${got.stale.pid} mort)`);
|
|
302
432
|
for (const r of repos) {
|
|
303
433
|
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
304
434
|
const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
|
|
@@ -317,6 +447,28 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
|
|
|
317
447
|
return 2;
|
|
318
448
|
}
|
|
319
449
|
}
|
|
450
|
+
try {
|
|
451
|
+
registerWave(home, { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos, cap });
|
|
452
|
+
}
|
|
453
|
+
catch (e) {
|
|
454
|
+
release(); // verrous et hooks ne restent pas posés si le démarrage échoue
|
|
455
|
+
throw e;
|
|
456
|
+
}
|
|
457
|
+
// Plafond de sessions simultanées, toutes vagues confondues : chaque session attend un créneau libre avant de partir.
|
|
458
|
+
const claude = async (args, o) => {
|
|
459
|
+
const free = await acquireSlot(home, cap, {
|
|
460
|
+
wave: wave.id,
|
|
461
|
+
pollMs: deps.slotPollMs,
|
|
462
|
+
onWait: (h, ms) => wctx.log(`en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
|
|
463
|
+
onGot: (ms) => wctx.log(`créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
464
|
+
});
|
|
465
|
+
try {
|
|
466
|
+
return await deps.claude(args, o);
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
free();
|
|
470
|
+
}
|
|
471
|
+
};
|
|
320
472
|
const budget = new Budget(wave.budget);
|
|
321
473
|
budget.consumed = wave.consumed;
|
|
322
474
|
budget.cacheRead = wave.cacheRead;
|
|
@@ -330,7 +482,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
|
|
|
330
482
|
id: wave.id,
|
|
331
483
|
store,
|
|
332
484
|
budget,
|
|
333
|
-
claude
|
|
485
|
+
claude,
|
|
334
486
|
agents: agentsOf(deps),
|
|
335
487
|
today,
|
|
336
488
|
claudeHome: deps.claudeHome,
|
|
@@ -348,8 +500,9 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
|
|
|
348
500
|
saveWave();
|
|
349
501
|
const ctxs = contexts(lots, envs, wctx);
|
|
350
502
|
// Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
|
|
351
|
-
const forget = onTermination(() => {
|
|
503
|
+
const forget = onTermination(async () => {
|
|
352
504
|
killSessions();
|
|
505
|
+
await stopApps(); // les applications de la revue UX : SIGTERM, SIGKILL après 10 s
|
|
353
506
|
for (const c of ctxs) {
|
|
354
507
|
for (const s of c.lot.steps) {
|
|
355
508
|
if (s.status === 'running')
|
|
@@ -367,7 +520,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
|
|
|
367
520
|
release();
|
|
368
521
|
});
|
|
369
522
|
try {
|
|
370
|
-
await runPool(ctxs,
|
|
523
|
+
await runPool(ctxs, cap, all);
|
|
371
524
|
}
|
|
372
525
|
finally {
|
|
373
526
|
forget();
|
|
@@ -387,17 +540,23 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
|
|
|
387
540
|
return 3;
|
|
388
541
|
return all.every((l) => l.status === 'ready') ? 0 : 1;
|
|
389
542
|
}
|
|
390
|
-
async function resume(args, io, deps, launch, today) {
|
|
543
|
+
async function resume(args, argv, io, deps, launch, today) {
|
|
391
544
|
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : RunStore.last(launch, { unfinished: true });
|
|
392
545
|
if (!store)
|
|
393
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
|
+
}
|
|
394
553
|
const wave = store.readWave();
|
|
395
554
|
const refusals = [];
|
|
396
555
|
const info = deps.claudeInfo();
|
|
397
556
|
if (!info)
|
|
398
557
|
refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
|
|
399
|
-
const alive =
|
|
400
|
-
if (alive
|
|
558
|
+
const alive = liveWaves(cadenceHome()).find((w) => w.wave === wave.id && w.cwd === launch && w.pid !== process.pid);
|
|
559
|
+
if (alive)
|
|
401
560
|
refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
|
|
402
561
|
if (wave.status === 'done')
|
|
403
562
|
refusals.push(`la vague ${wave.id} est terminée`);
|
|
@@ -435,11 +594,27 @@ async function resume(args, io, deps, launch, today) {
|
|
|
435
594
|
if (busy && busy.pid !== process.pid)
|
|
436
595
|
refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
437
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
|
+
}
|
|
438
607
|
if (refusals.length) {
|
|
439
608
|
for (const r of refusals)
|
|
440
609
|
io.err(`orchestrate : ${r}`);
|
|
441
610
|
return 2;
|
|
442
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
|
+
}
|
|
443
618
|
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
444
619
|
const recovered = new Budget(0);
|
|
445
620
|
for (const l of live)
|
|
@@ -475,11 +650,14 @@ async function resume(args, io, deps, launch, today) {
|
|
|
475
650
|
io.out(line);
|
|
476
651
|
return 0;
|
|
477
652
|
}
|
|
478
|
-
return execute(wave, live, store, io, deps, today, lots);
|
|
653
|
+
return execute(wave, live, store, io, deps, today, maxSessions(args, io), lots);
|
|
479
654
|
}
|
|
480
655
|
/** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
|
|
481
656
|
export function realOrchestrateDeps(env) {
|
|
482
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 });
|
|
483
661
|
return {
|
|
484
662
|
claude: realClaude(bin, env),
|
|
485
663
|
claudeInfo: () => {
|
|
@@ -493,6 +671,7 @@ export function realOrchestrateDeps(env) {
|
|
|
493
671
|
}
|
|
494
672
|
},
|
|
495
673
|
agentsDir: AGENTS_DIR,
|
|
674
|
+
snapshot: child ? undefined : { packageRoot: PACKAGE_ROOT, reexec: spawnReexec },
|
|
496
675
|
claudeHome: env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
|
|
497
676
|
};
|
|
498
677
|
}
|