@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.
- package/.claude-plugin/marketplace.json +16 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +183 -17
- 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/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 +110 -17
- package/dist/orchestrate/cycle.js +208 -26
- package/dist/orchestrate/guard.js +148 -3
- package/dist/orchestrate/launch.js +43 -5
- package/dist/orchestrate/node-env.js +77 -0
- package/dist/orchestrate/registry.js +5 -4
- package/dist/orchestrate/schemas.js +7 -1
- package/dist/orchestrate/snapshot.js +99 -0
- package/dist/orchestrate/table.js +11 -1
- package/dist/plan.js +29 -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
|
@@ -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:
|
|
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:
|
|
228
|
-
steps.push({ kind: 'review', model:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|