@sylad/cadence 0.7.0 → 0.9.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +274 -17
- package/agents/qa-reviewer.md +15 -6
- package/bin/cadence.js +6 -1
- package/dist/audit.js +41 -19
- package/dist/check.js +5 -2
- package/dist/clean.js +225 -0
- package/dist/cli.js +113 -12
- package/dist/config.js +82 -6
- package/dist/deliver.js +83 -32
- package/dist/git.js +12 -2
- package/dist/link.js +15 -1
- package/dist/orchestrate/briefs.js +52 -0
- package/dist/orchestrate/command.js +498 -0
- package/dist/orchestrate/cycle.js +673 -0
- package/dist/orchestrate/guard.js +131 -0
- package/dist/orchestrate/launch.js +221 -0
- package/dist/orchestrate/lock.js +85 -0
- package/dist/orchestrate/pool.js +51 -0
- package/dist/orchestrate/result.js +94 -0
- package/dist/orchestrate/schemas.js +48 -0
- package/dist/orchestrate/state.js +93 -0
- package/dist/orchestrate/table.js +73 -0
- package/dist/plan.js +61 -2
- package/dist/proc.js +260 -0
- package/dist/recurring.js +19 -0
- package/dist/schedule.js +4 -1
- package/dist/session.js +18 -1
- package/dist/verify.js +114 -0
- package/package.json +18 -3
- package/skills/lead/SKILL.md +33 -13
- package/skills/session-close/SKILL.md +25 -4
- package/skills/session-start/SKILL.md +3 -0
- package/templates/orchestrate/fix-minors.md +18 -0
- package/templates/orchestrate/fix.md +16 -0
- package/templates/orchestrate/implement.md +20 -0
- package/templates/orchestrate/review-recheck.md +11 -0
- package/templates/orchestrate/review-small.md +11 -0
- package/templates/orchestrate/review.md +8 -0
- package/templates/orchestrate/ux.md +10 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
import { readOrchestrateConfig, readPlanConfig } from '../config.js';
|
|
6
|
+
import { isDay, toDay } from '../dates.js';
|
|
7
|
+
import { gitRoot } from '../git.js';
|
|
8
|
+
import { onTermination } from '../proc.js';
|
|
9
|
+
import { Plan, RafError, isOpen } from '../plan.js';
|
|
10
|
+
import { AGENTS_DIR } from '../skills.js';
|
|
11
|
+
import { pidAlive, sharedStateDir } from '../state.js';
|
|
12
|
+
import { loadTemplates, objective, renderBrief } from './briefs.js';
|
|
13
|
+
import { Budget, MAX_PASSES, countInterrupted } from './cycle.js';
|
|
14
|
+
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
15
|
+
import { buildArgs, killSessions, readAgents, realClaude } from './launch.js';
|
|
16
|
+
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
17
|
+
import { runPool } from './pool.js';
|
|
18
|
+
import { schemaFor } from './schemas.js';
|
|
19
|
+
import { excludeState, lotKey, newLot, RunStore } from './state.js';
|
|
20
|
+
import { quotaText, renderTable } from './table.js';
|
|
21
|
+
export const DEFAULT_BUDGET = 2_000_000;
|
|
22
|
+
/** `1500000`, `1.5M`, `800k`. */
|
|
23
|
+
export function parseBudget(text) {
|
|
24
|
+
const m = /^(\d+(?:[.,]\d+)?)\s*([kKmM]?)$/.exec(text.trim());
|
|
25
|
+
if (!m)
|
|
26
|
+
throw new RafError(`--budget invalide : ${text} (1500000, 1.5M, 800k)`);
|
|
27
|
+
const n = Number(m[1].replace(',', '.')) * ({ '': 1, k: 1e3, m: 1e6 }[m[2].toLowerCase()]);
|
|
28
|
+
if (!(n > 0))
|
|
29
|
+
throw new RafError(`--budget invalide : ${text}`);
|
|
30
|
+
return Math.round(n);
|
|
31
|
+
}
|
|
32
|
+
const LOT_LIKE = /^(?:[\w.-]+:)?[A-Za-z]+\d+(?:@\w+)?$/;
|
|
33
|
+
const MODELS = ['sonnet', 'opus', 'haiku'];
|
|
34
|
+
function lotArg(text) {
|
|
35
|
+
const m = /^(?:([\w.-]+):)?([\w.-]+?)(?:@(\w+))?$/.exec(text);
|
|
36
|
+
if (!m)
|
|
37
|
+
throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
|
|
38
|
+
if (m[3] && !MODELS.includes(m[3]))
|
|
39
|
+
throw new RafError(`modèle inconnu : @${m[3]} (attendu : ${MODELS.join(', ')})`);
|
|
40
|
+
return { project: m[1], lot: m[2], model: m[3] };
|
|
41
|
+
}
|
|
42
|
+
export function parseOrchestrateArgs(argv) {
|
|
43
|
+
const a = { lots: [], dryRun: false, answers: [] };
|
|
44
|
+
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const t = argv[i];
|
|
47
|
+
const value = (flag) => {
|
|
48
|
+
if (argv[i + 1] === undefined)
|
|
49
|
+
throw new RafError(`${flag} attend une valeur`);
|
|
50
|
+
return argv[++i];
|
|
51
|
+
};
|
|
52
|
+
if (t === '--dry-run')
|
|
53
|
+
a.dryRun = true;
|
|
54
|
+
else if (t === '--status' || t === '--resume') {
|
|
55
|
+
const v = optional(i);
|
|
56
|
+
if (v !== undefined)
|
|
57
|
+
i++;
|
|
58
|
+
a[t === '--status' ? 'status' : 'resume'] = v ?? true;
|
|
59
|
+
}
|
|
60
|
+
else if (t === '--budget')
|
|
61
|
+
a.budget = parseBudget(value(t));
|
|
62
|
+
else if (t === '--wave')
|
|
63
|
+
a.wave = value(t);
|
|
64
|
+
else if (t === '--answer') {
|
|
65
|
+
const target = lotArg(value(t));
|
|
66
|
+
a.answers.push({ project: target.project, lot: target.lot, text: value(t) });
|
|
67
|
+
}
|
|
68
|
+
else if (t.startsWith('-'))
|
|
69
|
+
throw new RafError(`option inconnue : ${t}`);
|
|
70
|
+
else
|
|
71
|
+
a.lots.push(lotArg(t));
|
|
72
|
+
}
|
|
73
|
+
return a;
|
|
74
|
+
}
|
|
75
|
+
/** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
|
|
76
|
+
export function projectEnv(repo) {
|
|
77
|
+
const configPath = join(repo, 'cadence.yaml');
|
|
78
|
+
const planConfig = readPlanConfig(configPath);
|
|
79
|
+
const planPath = resolve(repo, planConfig?.path ?? 'docs/plan/raf.yaml');
|
|
80
|
+
return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
|
|
81
|
+
}
|
|
82
|
+
const isProject = (dir) => existsSync(join(dir, 'docs/plan/raf.yaml')) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
|
|
83
|
+
function resolveTargets(args, io, refusals) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
for (const l of args.lots) {
|
|
87
|
+
const projectDir = l.project ? resolve(io.cwd, l.project) : io.cwd;
|
|
88
|
+
const project = l.project ?? basename(io.cwd);
|
|
89
|
+
const key = lotKey(project, l.lot);
|
|
90
|
+
if (seen.has(key)) {
|
|
91
|
+
refusals.push(`${key} : lot donné deux fois`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
seen.add(key);
|
|
95
|
+
if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
|
96
|
+
refusals.push(`${key} : ${l.project ? `dossier ${projectDir} introuvable` : 'dossier courant illisible'}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!isProject(projectDir)) {
|
|
100
|
+
refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (docs/plan/raf.yaml, ou cadence.yaml avec une clé plan:)`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const repo = gitRoot(projectDir);
|
|
104
|
+
if (!repo) {
|
|
105
|
+
refusals.push(`${key} : ${projectDir} n'est pas dans un dépôt git`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
out.push({ project, projectDir, repo, lot: l.lot, model: l.model ?? 'sonnet' });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
async function trackedDirty(repo) {
|
|
113
|
+
return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
|
|
114
|
+
}
|
|
115
|
+
/** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
|
|
116
|
+
async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
117
|
+
const refusals = [];
|
|
118
|
+
if (io.env.CADENCE_ORCHESTRATED)
|
|
119
|
+
refusals.push(`orchestrate ne se lance pas depuis une session orchestrée (vague ${io.env.CADENCE_ORCHESTRATED})`);
|
|
120
|
+
const info = deps.claudeInfo();
|
|
121
|
+
if (!info)
|
|
122
|
+
refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
|
|
123
|
+
else if (!info.jsonSchema)
|
|
124
|
+
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
|
+
const lots = [];
|
|
129
|
+
const repoChecked = new Set();
|
|
130
|
+
const byProject = new Map();
|
|
131
|
+
for (const t of targets) {
|
|
132
|
+
const key = lotKey(t.project, t.lot);
|
|
133
|
+
let env;
|
|
134
|
+
let plan;
|
|
135
|
+
try {
|
|
136
|
+
env = projectEnv(t.repo);
|
|
137
|
+
plan = env.loadPlan();
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
refusals.push(`${key} : ${e.message}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const lot = plan.lots().find((l) => l.id === t.lot);
|
|
144
|
+
if (!lot) {
|
|
145
|
+
refusals.push(`${key} : lot inconnu`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!isOpen(lot.status)) {
|
|
149
|
+
refusals.push(`${key} : le lot est ${lot.status}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (plan.readonly && lot.status === 'todo' && !env.config.start) {
|
|
153
|
+
refusals.push(`${key} : plan en lecture seule : démarrer le lot avec l'outil du projet (ou déclarer orchestrate.start dans cadence.yaml)`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const earlier = byProject.get(t.project) ?? [];
|
|
157
|
+
const open = lot.after.filter((d) => {
|
|
158
|
+
const dep = plan.lots().find((x) => x.id === d);
|
|
159
|
+
return dep && isOpen(dep.status) && !earlier.includes(d);
|
|
160
|
+
});
|
|
161
|
+
if (open.length) {
|
|
162
|
+
refusals.push(`${key} : dépendance(s) ni terminée(s) ni plus tôt dans la vague : ${open.join(', ')}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
byProject.set(t.project, [...earlier, t.lot]);
|
|
166
|
+
if (!repoChecked.has(t.repo)) {
|
|
167
|
+
repoChecked.add(t.repo);
|
|
168
|
+
const dirty = await trackedDirty(t.repo);
|
|
169
|
+
if (dirty.length)
|
|
170
|
+
refusals.push(`${basename(t.repo)} : arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
|
|
171
|
+
const hook = canInstallPrePush(t.repo);
|
|
172
|
+
if (hook)
|
|
173
|
+
refusals.push(`${basename(t.repo)} : ${hook}`);
|
|
174
|
+
const busy = activeLock(join(sharedStateDir(t.repo), REPO_LOCK));
|
|
175
|
+
if (busy && !opts.resume)
|
|
176
|
+
refusals.push(`${basename(t.repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
177
|
+
}
|
|
178
|
+
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
179
|
+
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 });
|
|
180
|
+
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
181
|
+
lots.push(state);
|
|
182
|
+
}
|
|
183
|
+
return { refusals, lots };
|
|
184
|
+
}
|
|
185
|
+
function agentsOf(deps) {
|
|
186
|
+
return readAgents(deps.agentsDir);
|
|
187
|
+
}
|
|
188
|
+
function waveId(io, requested, launch) {
|
|
189
|
+
if (requested) {
|
|
190
|
+
if (!/^[\w.-]+$/.test(requested))
|
|
191
|
+
throw new RafError(`--wave invalide : ${requested}`);
|
|
192
|
+
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')}`;
|
|
196
|
+
let id = base;
|
|
197
|
+
for (let i = 2; existsSync(join(RunStore.runsDir(launch), id)); i++)
|
|
198
|
+
id = `${base}-${i}`;
|
|
199
|
+
return id;
|
|
200
|
+
}
|
|
201
|
+
function dryRun(lots, io, deps, budget, id) {
|
|
202
|
+
const tmp = mkdtempSync(join(tmpdir(), 'cadence-orchestrate-'));
|
|
203
|
+
const agents = agentsOf(deps);
|
|
204
|
+
io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
|
|
205
|
+
const repos = [...new Set(lots.map((l) => l.repo))];
|
|
206
|
+
for (const l of lots) {
|
|
207
|
+
const env = projectEnv(l.repo);
|
|
208
|
+
const lot = env.loadPlan().lot(l.lot);
|
|
209
|
+
const slot = repos.indexOf(l.repo);
|
|
210
|
+
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
211
|
+
io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
|
|
212
|
+
const steps = [{ kind: 'implement', model: l.model }];
|
|
213
|
+
if (l.small)
|
|
214
|
+
steps.push({ kind: l.visible ? 'review-small' : 'review', model: 'opus' });
|
|
215
|
+
else {
|
|
216
|
+
if (l.visible && env.config.ux)
|
|
217
|
+
steps.push({ kind: 'ux', model: 'opus' });
|
|
218
|
+
steps.push({ kind: 'review', model: 'opus' });
|
|
219
|
+
}
|
|
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)' : ''}`);
|
|
221
|
+
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 vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '' };
|
|
224
|
+
for (const s of steps) {
|
|
225
|
+
const file = join(tmp, `${l.project}--${l.lot}--${s.kind}.md`);
|
|
226
|
+
const brief = renderBrief(s.kind, vars, deps.templatesDir);
|
|
227
|
+
writeFileSync(file, brief);
|
|
228
|
+
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));
|
|
229
|
+
io.out(` ${s.kind} : claude ${args.join(' ')}`);
|
|
230
|
+
io.out(` brief : ${file}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
|
|
235
|
+
export async function orchestrate(argv, io, deps) {
|
|
236
|
+
const args = parseOrchestrateArgs(argv);
|
|
237
|
+
const launch = io.cwd;
|
|
238
|
+
const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
|
|
239
|
+
if (args.status !== undefined) {
|
|
240
|
+
const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
|
|
241
|
+
if (!store)
|
|
242
|
+
throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
|
|
243
|
+
for (const line of renderTable(store.readWave(), store.lots()))
|
|
244
|
+
io.out(line);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
if (args.resume !== undefined)
|
|
248
|
+
return resume(args, io, deps, launch, today);
|
|
249
|
+
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"]');
|
|
251
|
+
const refusals = [];
|
|
252
|
+
const targets = resolveTargets(args, io, refusals);
|
|
253
|
+
const pre = await preflight(args, targets, io, deps, launch);
|
|
254
|
+
refusals.push(...pre.refusals);
|
|
255
|
+
const budget = args.budget ?? DEFAULT_BUDGET;
|
|
256
|
+
if (refusals.length) {
|
|
257
|
+
for (const r of refusals)
|
|
258
|
+
io.err(`orchestrate : ${r}`);
|
|
259
|
+
return 2;
|
|
260
|
+
}
|
|
261
|
+
const id = waveId(io, args.wave, launch);
|
|
262
|
+
if (args.dryRun) {
|
|
263
|
+
dryRun(pre.lots, io, deps, budget, id);
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
const store = new RunStore(launch, id);
|
|
267
|
+
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
|
+
for (const l of pre.lots)
|
|
269
|
+
store.writeLot(l);
|
|
270
|
+
return execute(wave, pre.lots, store, io, deps, today);
|
|
271
|
+
}
|
|
272
|
+
function contexts(lots, envs, wctx) {
|
|
273
|
+
return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
|
|
274
|
+
}
|
|
275
|
+
/** 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) {
|
|
277
|
+
const launch = store.launchDir;
|
|
278
|
+
// La configuration des projets est lue avant de poser quoi que ce soit : un cadence.yaml illisible ne laisse ni verrou ni hook.
|
|
279
|
+
const envs = lots.map((lot) => projectEnv(lot.repo));
|
|
280
|
+
// Les gabarits aussi : lus une fois, rendus depuis cet instantané jusqu'à la fin de la vague (ou jusqu'à sa reprise).
|
|
281
|
+
const templates = loadTemplates(deps.templatesDir);
|
|
282
|
+
mkdirSync(join(launch, '.cadence'), { recursive: true });
|
|
283
|
+
excludeState(launch);
|
|
284
|
+
const waveLock = join(launch, '.cadence', 'orchestrate.lock');
|
|
285
|
+
const held = [];
|
|
286
|
+
const repos = [...new Set(lots.map((l) => l.repo))];
|
|
287
|
+
const release = () => {
|
|
288
|
+
for (const f of held)
|
|
289
|
+
releaseLock(f, process.pid);
|
|
290
|
+
held.length = 0;
|
|
291
|
+
for (const r of repos)
|
|
292
|
+
removePrePush(r, wave.id);
|
|
293
|
+
};
|
|
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
|
+
for (const r of repos) {
|
|
303
|
+
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
304
|
+
const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
|
|
305
|
+
if (!l.ok) {
|
|
306
|
+
io.err(`orchestrate : ${basename(r)} : une orchestration y est déjà en cours (${l.held.wave}, pid ${l.held.pid})`);
|
|
307
|
+
release();
|
|
308
|
+
return 2;
|
|
309
|
+
}
|
|
310
|
+
held.push(file);
|
|
311
|
+
if (l.stale)
|
|
312
|
+
io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(r)}, pid ${l.stale.pid} mort)`);
|
|
313
|
+
const hook = installPrePush(r, wave.id);
|
|
314
|
+
if (!hook.ok) {
|
|
315
|
+
io.err(`orchestrate : ${basename(r)} : ${hook.reason}`);
|
|
316
|
+
release();
|
|
317
|
+
return 2;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const budget = new Budget(wave.budget);
|
|
321
|
+
budget.consumed = wave.consumed;
|
|
322
|
+
budget.cacheRead = wave.cacheRead;
|
|
323
|
+
const saveWave = () => {
|
|
324
|
+
wave.consumed = budget.consumed;
|
|
325
|
+
wave.cacheRead = budget.cacheRead;
|
|
326
|
+
wave.budget = budget.limit;
|
|
327
|
+
store.writeWave(wave);
|
|
328
|
+
};
|
|
329
|
+
const wctx = {
|
|
330
|
+
id: wave.id,
|
|
331
|
+
store,
|
|
332
|
+
budget,
|
|
333
|
+
claude: deps.claude,
|
|
334
|
+
agents: agentsOf(deps),
|
|
335
|
+
today,
|
|
336
|
+
claudeHome: deps.claudeHome,
|
|
337
|
+
templates,
|
|
338
|
+
quota: { hit: false },
|
|
339
|
+
incident: null,
|
|
340
|
+
log: (line) => {
|
|
341
|
+
io.out(line);
|
|
342
|
+
store.journal(line);
|
|
343
|
+
},
|
|
344
|
+
saveWave,
|
|
345
|
+
};
|
|
346
|
+
wave.status = 'running';
|
|
347
|
+
wave.pid = process.pid;
|
|
348
|
+
saveWave();
|
|
349
|
+
const ctxs = contexts(lots, envs, wctx);
|
|
350
|
+
// 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(() => {
|
|
352
|
+
killSessions();
|
|
353
|
+
for (const c of ctxs) {
|
|
354
|
+
for (const s of c.lot.steps) {
|
|
355
|
+
if (s.status === 'running')
|
|
356
|
+
s.status = 'interrupted';
|
|
357
|
+
// Les tokens d'une session tuée comptent : relus dans son journal.
|
|
358
|
+
if (s.status === 'interrupted')
|
|
359
|
+
countInterrupted(deps.claudeHome, c.lot.repo, s, budget);
|
|
360
|
+
}
|
|
361
|
+
if (c.lot.status === 'implementing' || c.lot.status === 'reviewing' || c.lot.status === 'fixing')
|
|
362
|
+
c.lot.status = 'suspended';
|
|
363
|
+
store.writeLot(c.lot);
|
|
364
|
+
}
|
|
365
|
+
wave.status = 'interrupted';
|
|
366
|
+
saveWave();
|
|
367
|
+
release();
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
await runPool(ctxs, undefined, all);
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
forget();
|
|
374
|
+
const finished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
|
|
375
|
+
// « done » = plus rien à reprendre : une question en attente ou un lot suspendu garde la vague reprenable.
|
|
376
|
+
wave.status = wctx.incident ? 'interrupted' : wctx.quota.hit ? 'suspended-quota' : lots.some((l) => l.status === 'suspended') ? 'suspended-budget' : all.every(finished) ? 'done' : 'interrupted';
|
|
377
|
+
saveWave();
|
|
378
|
+
release();
|
|
379
|
+
}
|
|
380
|
+
if (wctx.incident)
|
|
381
|
+
io.err(`orchestrate : incident — ${wctx.incident}`);
|
|
382
|
+
if (wctx.quota.hit)
|
|
383
|
+
io.err(`orchestrate : quota atteint — ${quotaText(wctx.quota.message ?? '')}`.trim());
|
|
384
|
+
for (const line of renderTable(wave, all))
|
|
385
|
+
io.out(line);
|
|
386
|
+
if (wave.status === 'suspended-budget' || wave.status === 'suspended-quota')
|
|
387
|
+
return 3;
|
|
388
|
+
return all.every((l) => l.status === 'ready') ? 0 : 1;
|
|
389
|
+
}
|
|
390
|
+
async function resume(args, io, deps, launch, today) {
|
|
391
|
+
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : RunStore.last(launch, { unfinished: true });
|
|
392
|
+
if (!store)
|
|
393
|
+
throw new RafError(typeof args.resume === 'string' ? `vague inconnue : ${args.resume}` : 'aucune vague à reprendre dans ce dossier');
|
|
394
|
+
const wave = store.readWave();
|
|
395
|
+
const refusals = [];
|
|
396
|
+
const info = deps.claudeInfo();
|
|
397
|
+
if (!info)
|
|
398
|
+
refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
|
|
399
|
+
const alive = activeLock(join(launch, '.cadence', 'orchestrate.lock'));
|
|
400
|
+
if (alive && alive.pid !== process.pid)
|
|
401
|
+
refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
|
|
402
|
+
if (wave.status === 'done')
|
|
403
|
+
refusals.push(`la vague ${wave.id} est terminée`);
|
|
404
|
+
const lots = store.lots();
|
|
405
|
+
for (const a of args.answers) {
|
|
406
|
+
const l = lots.find((x) => x.lot === a.lot && (a.project ? x.project === a.project : true));
|
|
407
|
+
if (!l)
|
|
408
|
+
refusals.push(`--answer ${a.project ? `${a.project}:` : ''}${a.lot} : lot inconnu dans la vague ${wave.id}`);
|
|
409
|
+
else if (l.status !== 'question')
|
|
410
|
+
refusals.push(`--answer ${lotKey(l.project, l.lot)} : le lot n'attend pas de réponse (${l.status})`);
|
|
411
|
+
else {
|
|
412
|
+
l.pendingAnswer = a.text;
|
|
413
|
+
l.answers.push(a.text);
|
|
414
|
+
l.questions = [];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const live = [];
|
|
418
|
+
for (const l of lots) {
|
|
419
|
+
const finished = l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
|
|
420
|
+
if (finished)
|
|
421
|
+
continue;
|
|
422
|
+
for (const s of l.steps) {
|
|
423
|
+
if (s.status !== 'running')
|
|
424
|
+
continue;
|
|
425
|
+
if (s.pid && pidAlive(s.pid))
|
|
426
|
+
refusals.push(`${lotKey(l.project, l.lot)} : session encore en vie, pid ${s.pid}`);
|
|
427
|
+
else
|
|
428
|
+
s.status = 'interrupted';
|
|
429
|
+
}
|
|
430
|
+
live.push(l);
|
|
431
|
+
}
|
|
432
|
+
// Comme au départ : un dépôt tenu par une autre orchestration vivante est refusé avant toute écriture.
|
|
433
|
+
for (const repo of new Set(live.map((l) => l.repo))) {
|
|
434
|
+
const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
|
|
435
|
+
if (busy && busy.pid !== process.pid)
|
|
436
|
+
refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
437
|
+
}
|
|
438
|
+
if (refusals.length) {
|
|
439
|
+
for (const r of refusals)
|
|
440
|
+
io.err(`orchestrate : ${r}`);
|
|
441
|
+
return 2;
|
|
442
|
+
}
|
|
443
|
+
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
444
|
+
const recovered = new Budget(0);
|
|
445
|
+
for (const l of live)
|
|
446
|
+
for (const s of l.steps)
|
|
447
|
+
if (s.status === 'interrupted')
|
|
448
|
+
countInterrupted(deps.claudeHome, l.repo, s, recovered);
|
|
449
|
+
wave.consumed += recovered.consumed;
|
|
450
|
+
wave.cacheRead += recovered.cacheRead;
|
|
451
|
+
if (recovered.consumed || recovered.cacheRead)
|
|
452
|
+
store.writeWave(wave);
|
|
453
|
+
for (const l of live) {
|
|
454
|
+
if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
|
|
455
|
+
l.status = 'suspended';
|
|
456
|
+
store.writeLot(l);
|
|
457
|
+
}
|
|
458
|
+
if (args.budget !== undefined)
|
|
459
|
+
wave.budget = wave.consumed + args.budget;
|
|
460
|
+
const dirty = new Set();
|
|
461
|
+
for (const l of live)
|
|
462
|
+
if (!dirty.has(l.repo)) {
|
|
463
|
+
dirty.add(l.repo);
|
|
464
|
+
const d = await trackedDirty(l.repo);
|
|
465
|
+
if (d.length)
|
|
466
|
+
refusals.push(`${basename(l.repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
467
|
+
}
|
|
468
|
+
if (refusals.length) {
|
|
469
|
+
for (const r of refusals)
|
|
470
|
+
io.err(`orchestrate : ${r}`);
|
|
471
|
+
return 2;
|
|
472
|
+
}
|
|
473
|
+
if (live.length === 0) {
|
|
474
|
+
for (const line of renderTable(wave, lots))
|
|
475
|
+
io.out(line);
|
|
476
|
+
return 0;
|
|
477
|
+
}
|
|
478
|
+
return execute(wave, live, store, io, deps, today, lots);
|
|
479
|
+
}
|
|
480
|
+
/** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
|
|
481
|
+
export function realOrchestrateDeps(env) {
|
|
482
|
+
const bin = env.CADENCE_CLAUDE_BIN || 'claude';
|
|
483
|
+
return {
|
|
484
|
+
claude: realClaude(bin, env),
|
|
485
|
+
claudeInfo: () => {
|
|
486
|
+
try {
|
|
487
|
+
const version = execFileSync(bin, ['--version'], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 20_000 }).trim();
|
|
488
|
+
const help = execFileSync(bin, ['--help'], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 20_000 });
|
|
489
|
+
return { version, jsonSchema: help.includes('--json-schema') };
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
agentsDir: AGENTS_DIR,
|
|
496
|
+
claudeHome: env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
|
|
497
|
+
};
|
|
498
|
+
}
|