@sylad/cadence 1.8.0 → 1.10.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -54
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/precheck-reader.md +1 -1
- package/agents/qa-reviewer.md +2 -2
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +16 -4
- package/dist/check.js +24 -2
- package/dist/cli.js +258 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +24 -2
- package/dist/deliver.js +125 -37
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +45 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +46 -6
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +358 -41
- package/dist/orchestrate/cycle.js +922 -285
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +129 -0
- package/dist/orchestrate/launch.js +48 -6
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +146 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +54 -1
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +56 -15
- package/dist/orchestrate/table.js +74 -3
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +188 -25
- package/dist/plan.js +112 -7
- package/dist/proc.js +6 -0
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +129 -0
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +92 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +252 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +116 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +75 -7
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +76 -5
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +6 -6
- package/docs/lead.md +40 -13
- package/docs/news.md +30 -1
- package/docs/orchestrate.md +275 -33
- package/docs/overview.md +65 -1
- package/docs/perf.md +14 -8
- package/docs/raf.md +90 -11
- package/docs/ref.md +415 -0
- package/docs/releasing.md +3 -3
- package/docs/session.md +2 -2
- package/docs/skills.md +21 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +30 -1
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +28 -10
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +7 -4
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
|
@@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
3
3
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { homedir, tmpdir } from 'node:os';
|
|
5
5
|
import { basename, join, resolve } from 'node:path';
|
|
6
|
-
import { planLoader, readOrchestrateConfig } from '../config.js';
|
|
6
|
+
import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig } from '../config.js';
|
|
7
7
|
import { isDay, toDay } from '../dates.js';
|
|
8
8
|
import { gitRoot } from '../git.js';
|
|
9
9
|
import { onTermination } from '../proc.js';
|
|
@@ -11,25 +11,30 @@ import { stopApps } from './app.js';
|
|
|
11
11
|
import { RafError, isOpen } from '../plan.js';
|
|
12
12
|
import { AGENTS_DIR } from '../skills.js';
|
|
13
13
|
import { pidAlive, sharedStateDir } from '../state.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { findProjects, isProject as isLeadProject } from '../lead.js';
|
|
15
|
+
import { freeLine, readyFree } from '../ready-free.js';
|
|
16
|
+
import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
|
|
17
|
+
import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
|
|
16
18
|
import { readWaits, renderWaits } from './waits.js';
|
|
17
19
|
import { acceptationFile, acceptText } from './accept.js';
|
|
20
|
+
import { noTestGate, noTestText } from './no-test.js';
|
|
18
21
|
import { ficheReviewText } from './fiche.js';
|
|
19
22
|
import { maquetteText } from './maquette.js';
|
|
20
23
|
import { specScreen, specText } from './spec.js';
|
|
21
24
|
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
22
|
-
import { Budget, DROPPED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
|
|
25
|
+
import { precheckNothingToFind, Budget, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
23
26
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
24
27
|
import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
25
28
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
26
|
-
import { runPool } from './pool.js';
|
|
29
|
+
import { groupLots, runPool } from './pool.js';
|
|
30
|
+
import { repoBranch } from './integrate.js';
|
|
31
|
+
import { baseBranchOf, keptLines, keptTrees, lotBranch, lotTreeDir, planFileSet, recoverTree, removeTree } from './worktree.js';
|
|
27
32
|
import { schemaFor } from './schemas.js';
|
|
28
33
|
import { resolveLotRepos } from '../repos.js';
|
|
29
34
|
import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
30
35
|
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
31
|
-
import { quotaText, renderTable } from './table.js';
|
|
32
|
-
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, waveReadsLotBudget, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
36
|
+
import { quotaText, renderTable, resumeCommand } from './table.js';
|
|
37
|
+
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
33
38
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
34
39
|
/**
|
|
35
40
|
* Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
|
|
@@ -37,8 +42,11 @@ export const DEFAULT_BUDGET = 2_000_000;
|
|
|
37
42
|
* 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
|
|
38
43
|
* de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
|
|
39
44
|
* Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
|
|
45
|
+
* Relevé à 600 k (L247), mesuré sur 16–22-10 : 145 lots à 540 k, p90 451 k, 3 au-dessus (tous maritime-atlas : 12 lots, p90 556 k, max 609 k) ; la marge
|
|
46
|
+
* avant refus du fix passe à 445 k. Les lots à 750 k (60 lots, p90 571 k, 0 dépassement) et à 1,3 M (20 lots, p90 904 k, 1 dépassement) tiennent : inchangés.
|
|
47
|
+
* Remesuré sur 17–23-10 (L280, `docs/perf/recalage-2026-10-24.md`) : 0 lot sur 309 au-dessus de son budget, p90 452 k à 600 k, 563 k à 750 k, 875 k à 1,3 M : tous inchangés, avec au moins 10 % de marge.
|
|
40
48
|
*/
|
|
41
|
-
const LOT_BUDGET_FLOOR =
|
|
49
|
+
const LOT_BUDGET_FLOOR = 600_000;
|
|
42
50
|
/** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
|
|
43
51
|
const LOT_BUDGET_ONE_DAY = 750_000;
|
|
44
52
|
/** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
|
|
@@ -121,7 +129,7 @@ function lotBudgetArg(text) {
|
|
|
121
129
|
return { project: target.project, lot: target.lot, raise, text: m[2] };
|
|
122
130
|
}
|
|
123
131
|
export function parseOrchestrateArgs(argv) {
|
|
124
|
-
const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
|
|
132
|
+
const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, integrate: [], abandonCommits: false };
|
|
125
133
|
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
126
134
|
for (let i = 0; i < argv.length; i++) {
|
|
127
135
|
const t = argv[i];
|
|
@@ -158,12 +166,29 @@ export function parseOrchestrateArgs(argv) {
|
|
|
158
166
|
}
|
|
159
167
|
else if (t === '--max-sessions')
|
|
160
168
|
a.maxSessions = parseMaxSessions(value(t));
|
|
169
|
+
else if (t === '--goal') {
|
|
170
|
+
// Un texte oublié ne doit pas avaler l'option suivante (`--goal --dry-run` lancerait une vraie vague payante).
|
|
171
|
+
const next = argv[i + 1];
|
|
172
|
+
if (next?.startsWith('--'))
|
|
173
|
+
throw new RafError(`--goal attend un texte, pas l'option ${next}`);
|
|
174
|
+
a.goal = value(t).trim();
|
|
175
|
+
if (a.goal === '')
|
|
176
|
+
throw new RafError('--goal attend un texte non vide');
|
|
177
|
+
}
|
|
161
178
|
else if (t === '--wave')
|
|
162
179
|
a.wave = value(t);
|
|
163
180
|
else if (t === '--drop')
|
|
164
181
|
a.drop.push(lotArg(value(t)));
|
|
165
182
|
else if (t === '--stop-after-current')
|
|
166
183
|
a.stopAfterCurrent = true;
|
|
184
|
+
else if (t === '--integrate')
|
|
185
|
+
a.integrate.push(lotArg(value(t)));
|
|
186
|
+
else if (t === '--replay')
|
|
187
|
+
a.integrate.push({ ...lotArg(value(t)), replay: true });
|
|
188
|
+
else if (t === '--remove-worktree')
|
|
189
|
+
a.removeWorktree = lotArg(value(t));
|
|
190
|
+
else if (t === '--abandon-commits')
|
|
191
|
+
a.abandonCommits = true;
|
|
167
192
|
else if (t === '--continue')
|
|
168
193
|
a.continue = true;
|
|
169
194
|
else if (t === '--until')
|
|
@@ -179,6 +204,13 @@ export function parseOrchestrateArgs(argv) {
|
|
|
179
204
|
else
|
|
180
205
|
a.lots.push(lotArg(t));
|
|
181
206
|
}
|
|
207
|
+
// L309 : --wave <id> après un --resume sans valeur désigne la vague à reprendre ; sans cela le --resume reprenait en silence une AUTRE vague.
|
|
208
|
+
if (a.resume !== undefined && a.wave !== undefined) {
|
|
209
|
+
if (a.resume === true)
|
|
210
|
+
a.resume = a.wave;
|
|
211
|
+
else if (a.resume !== a.wave)
|
|
212
|
+
throw new RafError(`--resume ${a.resume} et --wave ${a.wave} désignent deux vagues : une seule (${resumeCommand('<vague>')})`);
|
|
213
|
+
}
|
|
182
214
|
return a;
|
|
183
215
|
}
|
|
184
216
|
/** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
|
|
@@ -187,7 +219,7 @@ export function projectEnv(repo) {
|
|
|
187
219
|
const loadPlan = planLoader(repo);
|
|
188
220
|
return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
|
|
189
221
|
}
|
|
190
|
-
const isProject = (dir) => existsSync(join(dir,
|
|
222
|
+
const isProject = (dir) => existsSync(join(dir, PLAN_DEFAULT)) || existsSync(join(dir, PLAN_LEGACY)) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
|
|
191
223
|
function resolveTargets(args, io, refusals) {
|
|
192
224
|
const out = [];
|
|
193
225
|
const seen = new Set();
|
|
@@ -205,7 +237,7 @@ function resolveTargets(args, io, refusals) {
|
|
|
205
237
|
continue;
|
|
206
238
|
}
|
|
207
239
|
if (!isProject(projectDir)) {
|
|
208
|
-
refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (
|
|
240
|
+
refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (${PLAN_DEFAULT}, ${PLAN_LEGACY}, ou cadence.yaml avec une clé plan:)`);
|
|
209
241
|
continue;
|
|
210
242
|
}
|
|
211
243
|
const repo = gitRoot(projectDir);
|
|
@@ -312,10 +344,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
312
344
|
continue;
|
|
313
345
|
}
|
|
314
346
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
347
|
+
const goal = args.goal ?? lot.goal;
|
|
315
348
|
const screen = specScreen(t.repo, env.config.spec.dir, lot);
|
|
316
|
-
const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
|
|
349
|
+
const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, ...(goal ? { goal } : {}), visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
|
|
317
350
|
if (neighbours.repos.length)
|
|
318
351
|
state.repos = neighbours.repos;
|
|
352
|
+
state.estimate = lot.estimate;
|
|
319
353
|
state.light = lot.estimate <= env.config.review.threshold;
|
|
320
354
|
state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
|
|
321
355
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
@@ -349,13 +383,18 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
349
383
|
const agents = agentsOf(deps);
|
|
350
384
|
io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
|
|
351
385
|
const repos = [...new Set(lots.map((l) => l.repo))];
|
|
386
|
+
const noTestSaid = new Set();
|
|
352
387
|
for (const l of lots) {
|
|
353
388
|
const env = projectEnv(l.repo);
|
|
354
389
|
const lot = env.loadPlan().lot(l.lot);
|
|
355
390
|
const slot = repos.indexOf(l.repo);
|
|
356
391
|
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
392
|
+
if (l.goal)
|
|
393
|
+
io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
|
|
357
394
|
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
|
|
358
|
-
|
|
395
|
+
const mine = lots.filter((o) => o.repo === l.repo);
|
|
396
|
+
const together = env.config.worktrees && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
|
|
397
|
+
io.out(together ? ` file ${basename(l.repo)} · en parallèle des autres lots du dépôt, dans son worktree (cadence/${id}/${lotSlug(l.project, l.lot)}, créé à la prise du créneau)` : ` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
|
|
359
398
|
if (l.repos?.length)
|
|
360
399
|
io.out(` dépôts voisins : ${l.repos.map((r) => r.rel).join(', ')} (verrou, garde de push et --add-dir ; leurs commits citent ${l.lot})`);
|
|
361
400
|
if (l.node) {
|
|
@@ -373,8 +412,13 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
373
412
|
steps.unshift({ kind: 'fiche', model: 'sonnet' });
|
|
374
413
|
if (specStage)
|
|
375
414
|
steps.unshift({ kind: 'spec', model: 'sonnet' });
|
|
376
|
-
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
|
|
377
|
-
|
|
415
|
+
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal)) {
|
|
416
|
+
const nothingToFind = precheckNothingToFind(env.loadPlan(), l.repo, l.lot, l.repos, l.goal);
|
|
417
|
+
if (nothingToFind)
|
|
418
|
+
io.out(` contrôle préalable sauté : ${nothingToFind}`);
|
|
419
|
+
else
|
|
420
|
+
steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
|
|
421
|
+
}
|
|
378
422
|
const { light, full } = env.config.review;
|
|
379
423
|
if (l.small)
|
|
380
424
|
steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
|
|
@@ -384,11 +428,15 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
384
428
|
steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
|
|
385
429
|
}
|
|
386
430
|
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)' : ''}`);
|
|
431
|
+
const noTest = noTestSaid.has(l.repo) ? null : noTestGate(l.repo);
|
|
432
|
+
noTestSaid.add(l.repo);
|
|
433
|
+
if (noTest)
|
|
434
|
+
io.out(` ${basename(l.repo)} · ${noTestText(noTest)}`);
|
|
387
435
|
io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
|
|
388
436
|
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 »');
|
|
389
437
|
const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
|
|
390
438
|
// brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
|
|
391
|
-
const vars = { chemin: l.repo, lot: l.lot, titre: lot.
|
|
439
|
+
const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
|
|
392
440
|
if (specStage)
|
|
393
441
|
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
|
|
394
442
|
if (l.screen && env.config.maquette)
|
|
@@ -434,38 +482,100 @@ function duration(ms) {
|
|
|
434
482
|
const s = Math.round(ms / 1000);
|
|
435
483
|
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
|
|
436
484
|
}
|
|
437
|
-
/** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
|
|
438
|
-
export function liveLines(cap = DEFAULT_MAX_SESSIONS) {
|
|
485
|
+
/** Vagues vivantes (toutes, quel que soit leur dossier de départ), sessions du lead (L255), créneaux libres et dépôts qu'elles tiennent. Rien sans vague ni session du lead. */
|
|
486
|
+
export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
|
|
439
487
|
const home = cadenceHome();
|
|
440
488
|
const waves = liveWaves(home);
|
|
441
|
-
|
|
489
|
+
const holds = liveHolds(home, now);
|
|
490
|
+
if (waves.length === 0 && holds.length === 0)
|
|
442
491
|
return [];
|
|
443
492
|
const slots = liveSlots(home).length;
|
|
444
|
-
const
|
|
493
|
+
const lead = holds.length ? ` · sessions du lead : ${holds.length} (${holds.map((h) => h.label).join(', ')})` : '';
|
|
494
|
+
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}${lead} · créneaux libres : ${freeSlots(waves, cap, holds.length)} sur ${cap}`];
|
|
445
495
|
for (const w of waves)
|
|
446
496
|
lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
|
|
447
497
|
return lines;
|
|
448
498
|
}
|
|
499
|
+
/** Durée à la minute : « 12 min », « 5 h 10 min », « 2 j 3 h ». */
|
|
500
|
+
function longDuration(ms) {
|
|
501
|
+
const m = Math.max(0, Math.floor(ms / 60_000));
|
|
502
|
+
if (m < 60)
|
|
503
|
+
return `${m} min`;
|
|
504
|
+
const h = Math.floor(m / 60);
|
|
505
|
+
if (h < 24)
|
|
506
|
+
return `${h} h ${String(m % 60).padStart(2, '0')} min`;
|
|
507
|
+
return `${Math.floor(h / 24)} j ${h % 24} h`;
|
|
508
|
+
}
|
|
509
|
+
/** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
|
|
510
|
+
export function expiredHoldLines(now = new Date()) {
|
|
511
|
+
return expiredHolds(cadenceHome(), now).map((h) => {
|
|
512
|
+
const since = Date.parse(h.since);
|
|
513
|
+
const age = Number.isFinite(since) ? `inscrite depuis ${longDuration(now.getTime() - since)}, ` : '';
|
|
514
|
+
return `⚠ session du lead échue : ${h.label} (${age}échue depuis ${longDuration(now.getTime() - Date.parse(h.expires))}) — la reprendre (cadence lead hold ${h.label} --ttl <minutes>) ou l'arrêter (cadence lead release ${h.label})`;
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/** `--continue` (L252) : la ligne des lots prêts dont aucune ressource n'est prise par un lot en cours ni par une vague, sans les lots du premier tirage `drawn` ni les ressources qu'ils tiennent. Vide si aucun. */
|
|
518
|
+
function freeLines(launch, drawn) {
|
|
519
|
+
const dirs = isLeadProject(launch) ? [launch] : findProjects(launch);
|
|
520
|
+
const free = readyFree(dirs, cadenceHome(), drawn);
|
|
521
|
+
const line = freeLine(dirs.filter((d) => free.has(d)).map((d) => ({ project: basename(d), free: free.get(d) })), 60);
|
|
522
|
+
return line ? [line] : [];
|
|
523
|
+
}
|
|
449
524
|
/** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
|
|
450
525
|
const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
451
526
|
const DEFAULT_WATCH_INTERVAL = 10;
|
|
452
527
|
/** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
|
|
453
|
-
function statusView(args, launch, cap) {
|
|
528
|
+
function statusView(args, launch, cap, now) {
|
|
454
529
|
const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
|
|
455
|
-
const live = liveLines(cap);
|
|
530
|
+
const live = [...liveLines(cap, now), ...expiredHoldLines(now)];
|
|
531
|
+
const waveLive = liveWaves(cadenceHome()).length > 0;
|
|
456
532
|
if (!store && live.length && args.status === true)
|
|
457
|
-
return { live, running:
|
|
533
|
+
return { live, running: waveLive };
|
|
458
534
|
if (!store)
|
|
459
535
|
throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
|
|
460
536
|
// `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
|
|
461
|
-
const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) :
|
|
462
|
-
|
|
537
|
+
const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
|
|
538
|
+
const lots = store.lots();
|
|
539
|
+
return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
|
|
463
540
|
}
|
|
464
541
|
/** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
|
|
465
542
|
function waveExists(wave, io) {
|
|
466
543
|
io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
|
|
467
544
|
return 2;
|
|
468
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* `--remove-worktree projet:lot` (L279, R20) : seule commande qui retire un worktree conservé. Refus (code 2, rien retiré) pour le worktree d'une vague vivante (même avec `--abandon-commits`), et tant qu'un commit du lot n'est ni
|
|
548
|
+
* intégré à la branche du dépôt ni confirmé abandonné par `--abandon-commits` ; un worktree entièrement intégré se retire sans option. `--wave` désigne la vague
|
|
549
|
+
* quand plusieurs ont laissé un worktree pour ce lot.
|
|
550
|
+
*/
|
|
551
|
+
function removeWorktree(args, io) {
|
|
552
|
+
const t = args.removeWorktree;
|
|
553
|
+
const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
|
|
554
|
+
const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
|
|
555
|
+
const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
|
|
556
|
+
const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
|
|
557
|
+
if (!repo || kept.length === 0) {
|
|
558
|
+
io.err(`orchestrate : ${key} : aucun worktree conservé`);
|
|
559
|
+
return 2;
|
|
560
|
+
}
|
|
561
|
+
if (kept.length > 1) {
|
|
562
|
+
io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
|
|
563
|
+
return 2;
|
|
564
|
+
}
|
|
565
|
+
const k = kept[0];
|
|
566
|
+
if (k.active) {
|
|
567
|
+
// une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
|
|
568
|
+
io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
|
|
569
|
+
return 2;
|
|
570
|
+
}
|
|
571
|
+
const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
|
|
572
|
+
if (!gone.ok) {
|
|
573
|
+
io.err(gone.dirty ? `orchestrate : ${key} : fichiers suivis modifiés dans le worktree (${gone.dirty.join(', ')}), --abandon-commits pour les abandonner` : gone.untracked ? `orchestrate : ${key} : fichiers non suivis dans le worktree (${gone.untracked.join(', ')}), --abandon-commits pour les abandonner` : `orchestrate : ${key} : ${gone.unintegrated} commit(s) non intégré(s), --abandon-commits pour les abandonner`);
|
|
574
|
+
return 2;
|
|
575
|
+
}
|
|
576
|
+
io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
|
|
577
|
+
return 0;
|
|
578
|
+
}
|
|
469
579
|
/** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
|
|
470
580
|
export async function orchestrate(argv, io, deps) {
|
|
471
581
|
const args = parseOrchestrateArgs(argv);
|
|
@@ -487,10 +597,27 @@ export async function orchestrate(argv, io, deps) {
|
|
|
487
597
|
if (args.resume !== undefined && args.stopAfterCurrent)
|
|
488
598
|
throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
|
|
489
599
|
// L228 : la reprise n'a pas de simulation — elle relève le budget, réécrit wave.json et relance les sessions. Refusée ici, avant le réexec de l'instantané (une vague ancienne ne connaît pas ce refus).
|
|
600
|
+
// L309 : la reprise rejoue les lots de la vague ; des lots à lancer seraient ignorés en silence (--continue, lui, tire la suite du plan après la reprise : L147).
|
|
601
|
+
if (args.resume !== undefined && args.lots.length) {
|
|
602
|
+
// Un id de vague qui ressemble à un lot (`--resume suite2`) est lu comme un lot à lancer : le refus donne la forme qui le désigne comme vague.
|
|
603
|
+
const asWave = args.lots.find((l) => l.project === undefined && l.model === undefined && RunStore.find(launch, l.lot));
|
|
604
|
+
const hint = asWave ? `« ${asWave.lot} » est une vague de ce dossier : ${resumeCommand(asWave.lot)}` : `pour désigner la vague : ${resumeCommand('<vague>')}`;
|
|
605
|
+
throw new RafError(`--resume ne se combine pas avec des lots à lancer (il reprend les lots de la vague ; --answer, --drop, --integrate, --replay et --budget projet:lot=+N nomment ceux qu'ils visent) ; ${hint}`);
|
|
606
|
+
}
|
|
490
607
|
if (args.resume !== undefined && args.dryRun)
|
|
491
608
|
throw new RafError('--resume ne se combine pas avec --dry-run (la reprise écrit l\'état de la vague et relance les sessions, elle ne se simule pas)');
|
|
492
609
|
if (args.lotBudgets.length && args.resume === undefined)
|
|
493
610
|
throw new RafError("--budget projet:lot=+N ne vaut qu'avec --resume");
|
|
611
|
+
// L279 : --integrate rejoue une intégration rendue, donc une vague arrêtée ; --abandon-commits ne vaut qu'avec --remove-worktree.
|
|
612
|
+
if (args.integrate.length && args.resume === undefined)
|
|
613
|
+
throw new RafError(`${args.integrate.some((t) => !t.replay) ? '--integrate' : '--replay'} ne vaut qu'avec --resume`);
|
|
614
|
+
if (args.abandonCommits && !args.removeWorktree)
|
|
615
|
+
throw new RafError("--abandon-commits ne vaut qu'avec --remove-worktree");
|
|
616
|
+
if (args.removeWorktree && (args.lots.length || args.resume !== undefined || args.status !== undefined || args.continue || args.dryRun || args.drop.length || args.stopAfterCurrent || args.waits))
|
|
617
|
+
throw new RafError("--remove-worktree ne se combine avec aucune autre action");
|
|
618
|
+
// L258 : l'objectif donné ne vaut que pour UN lot lancé (la clé goal: du lot sert quand plusieurs partent ; --continue tire ses lots lui-même).
|
|
619
|
+
if (args.goal !== undefined && (args.lots.length !== 1 || args.continue || args.resume !== undefined || args.status !== undefined || args.waits || args.drop.length || args.stopAfterCurrent))
|
|
620
|
+
throw new RafError("--goal s'applique à un seul lot lancé : <projet>:<lot> --goal \"…\" (plusieurs lots : la clé goal: de chacun)");
|
|
494
621
|
if (args.since !== undefined && !args.waits)
|
|
495
622
|
throw new RafError('--since s\'utilise avec --waits');
|
|
496
623
|
if (args.waits) {
|
|
@@ -503,7 +630,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
503
630
|
if (args.status !== undefined) {
|
|
504
631
|
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
505
632
|
if (!args.watch) {
|
|
506
|
-
const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
|
|
633
|
+
const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
|
|
507
634
|
for (const line of live)
|
|
508
635
|
io.out(line);
|
|
509
636
|
if (!table)
|
|
@@ -517,7 +644,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
517
644
|
// --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
|
|
518
645
|
const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
519
646
|
for (;;) {
|
|
520
|
-
const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
|
|
647
|
+
const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
|
|
521
648
|
io.out(CLEAR_SCREEN);
|
|
522
649
|
for (const line of live)
|
|
523
650
|
io.out(line);
|
|
@@ -534,12 +661,14 @@ export async function orchestrate(argv, io, deps) {
|
|
|
534
661
|
await sleep((args.interval ?? DEFAULT_WATCH_INTERVAL) * 1000);
|
|
535
662
|
}
|
|
536
663
|
}
|
|
664
|
+
if (args.removeWorktree)
|
|
665
|
+
return removeWorktree(args, io);
|
|
537
666
|
if (args.resume !== undefined)
|
|
538
667
|
return resume(args, argv, io, deps, launch, today);
|
|
539
668
|
if (args.drop.length || args.stopAfterCurrent)
|
|
540
669
|
return control(args, io);
|
|
541
670
|
if (args.lots.length === 0 && !args.continue)
|
|
542
|
-
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] | --drop projet:lot | --stop-after-current');
|
|
671
|
+
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
|
|
543
672
|
const refusals = [];
|
|
544
673
|
const targets = resolveTargets(args, io, refusals);
|
|
545
674
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -565,6 +694,9 @@ export async function orchestrate(argv, io, deps) {
|
|
|
565
694
|
if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
|
|
566
695
|
return waveExists(args.wave, io);
|
|
567
696
|
dryRun(pre.lots, io, deps, budget, id);
|
|
697
|
+
if (args.continue)
|
|
698
|
+
for (const l of freeLines(launch, pre.lots))
|
|
699
|
+
io.out(l);
|
|
568
700
|
if (args.continue)
|
|
569
701
|
await continueDryRun(args, io, deps, launch, pre.lots, budget, firstSkipped);
|
|
570
702
|
return 0;
|
|
@@ -604,6 +736,9 @@ export async function orchestrate(argv, io, deps) {
|
|
|
604
736
|
}
|
|
605
737
|
}
|
|
606
738
|
const id = store.id;
|
|
739
|
+
if (args.continue)
|
|
740
|
+
for (const l of freeLines(launch, pre.lots))
|
|
741
|
+
io.out(l);
|
|
607
742
|
// Les candidats refusés au premier tirage : leur cause dit pourquoi ils ne jouent pas (même ligne que celles des tirages suivants).
|
|
608
743
|
for (const s of firstSkipped) {
|
|
609
744
|
io.err(`orchestrate : lot sauté — ${s}`);
|
|
@@ -781,6 +916,45 @@ async function continueRounds(first, wave, all, store, io, deps, today, args, la
|
|
|
781
916
|
function contexts(lots, envs, wctx) {
|
|
782
917
|
return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
|
|
783
918
|
}
|
|
919
|
+
/**
|
|
920
|
+
* Quels lots jouent dans un worktree (L279). Un groupe de lots du même dépôt (`groupLots`) y joue si `orchestrate.worktrees` est vrai, s'il a au moins deux lots,
|
|
921
|
+
* si le dépôt est sur une branche et si aucun de ses lots n'a de dépôt voisin (`repos:`, R2) ; sinon les lots restent à la suite, avec un avertissement qui dit pourquoi.
|
|
922
|
+
* Un lot dont la branche existe déjà (reprise, R11) retrouve son worktree quel que soit le groupe ; un lot rendu à l'intégration dont la branche a disparu n'en reçoit pas. La branche de départ est celle du dépôt du lead à cet instant,
|
|
923
|
+
* ou celle que la branche du lot a notée à sa création.
|
|
924
|
+
*/
|
|
925
|
+
function planTrees(ctxs, store, wave, log) {
|
|
926
|
+
for (const group of groupLots(ctxs)) {
|
|
927
|
+
const repo = group[0].lot.repo;
|
|
928
|
+
const branch = repoBranch(repo);
|
|
929
|
+
const requested = group.length > 1 && group.every((c) => c.config.worktrees);
|
|
930
|
+
const why = !requested ? null : group.some((c) => c.lot.repos?.length) ? 'des lots du groupe ont des dépôts voisins (repos:)' : !branch ? 'le dépôt est en HEAD détaché' : null;
|
|
931
|
+
if (why)
|
|
932
|
+
log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
|
|
933
|
+
for (const c of group) {
|
|
934
|
+
const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
|
|
935
|
+
// Chemin de reprise d'un lot rendu hors intégration : sa branche d'une vague passée devient celle de cette vague (recoverTree), puis se retrouve comme une reprise (R11).
|
|
936
|
+
const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
|
|
937
|
+
const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch && c.config.worktrees ? recoverTree(repo, lotBr, dir, branch, planFileSet(c.loadPlan(), repo)) : null;
|
|
938
|
+
if (recovered && 'refused' in recovered)
|
|
939
|
+
log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : branche ${recovered.from} d'une vague précédente non reprise : ${recovered.refused} ; le lot repart de zéro (à nettoyer à la main, ou intégration à la main documentée)`);
|
|
940
|
+
else if (recovered) {
|
|
941
|
+
log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : branche ${recovered.from} d'une vague précédente reprise (${recovered.unintegrated} commit(s) non intégré(s)) : le lot repart de son travail`);
|
|
942
|
+
// L311 : le lot repris ne repasse pas par le contrôle préalable. Il reprend les constats de sa dernière revue (état de la vague qui l'a rendu) et repart à leur correction, ou à la revue quand il en a déjà passé une sans constat ; rendu avant sa première revue (ou état illisible ; les constats `tests` d'un lot rendu pour tests rouges ne font pas une revue, L318), il repart à l'écriture.
|
|
943
|
+
const old = new RunStore(store.launchDir, recovered.from.split('/')[1]).readLot(c.lot.project, c.lot.lot);
|
|
944
|
+
c.lot.resumedFrom = recovered.from.split('/')[1];
|
|
945
|
+
c.lot.constats = old?.constats ?? [];
|
|
946
|
+
c.lot.resumedReviewed = !!old && (old.constats.some((k) => k.source === 'code') || old.steps.some((s) => s.kind.startsWith('review')));
|
|
947
|
+
}
|
|
948
|
+
const known = baseBranchOf(repo, lotBr);
|
|
949
|
+
const base = known ?? branch;
|
|
950
|
+
// Un lot repris à l'intégration dont la branche n'existe plus (retirée par --remove-worktree --abandon-commits) n'a plus de commits à intégrer : pas de worktree recréé au sommet, `integrateLot` le rend.
|
|
951
|
+
if (c.lot.next === 'integrate' && !known)
|
|
952
|
+
continue;
|
|
953
|
+
if (((requested && !why) || known) && base)
|
|
954
|
+
c.tree = { dir, branch: lotBr, base, active: false };
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
784
958
|
/** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
|
|
785
959
|
async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
786
960
|
const launch = store.launchDir;
|
|
@@ -795,6 +969,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
795
969
|
const repos = [...new Set(lots.flatMap(lotRepoPaths))];
|
|
796
970
|
/** Libère un dépôt : son verrou et sa garde de push (L132 : dès que ses lots sont finis, pas à la fin de la vague). */
|
|
797
971
|
const freed = new Set();
|
|
972
|
+
/** Lots que la vague joue à la fois sur les dépôts qu'elle tient encore (un par dépôt, un par lot en worktree) : ce que le registre compte pour les créneaux libres. Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
|
|
973
|
+
const lanes = () => ctxs.some((c) => c.tree) ? groupLots(ctxs).filter((g) => g.every((c) => lotRepoPaths(c.lot).every((r) => !freed.has(r)))).reduce((n, g) => n + (g.every((c) => c.tree) ? g.length : 1), 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
|
|
798
974
|
const freeRepo = (r) => {
|
|
799
975
|
freed.add(r);
|
|
800
976
|
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
@@ -802,7 +978,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
802
978
|
held.splice(0, held.length, ...held.filter((f) => f !== file));
|
|
803
979
|
removePrePush(r, wave.id);
|
|
804
980
|
try {
|
|
805
|
-
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)));
|
|
981
|
+
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
|
|
806
982
|
}
|
|
807
983
|
catch {
|
|
808
984
|
// registre illisible : `--status` garde la liste de départ, le verrou est bien tombé
|
|
@@ -852,6 +1028,13 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
852
1028
|
const free = await acquireSlot(home, cap, {
|
|
853
1029
|
wave: wave.id,
|
|
854
1030
|
pollMs: deps.slotPollMs,
|
|
1031
|
+
// L332 : une demande écrite pendant l'attente (`--drop` de ce lot, `--stop-after-current`) l'interrompt ; la session ne part pas.
|
|
1032
|
+
abandon: () => {
|
|
1033
|
+
const ctl = store.control();
|
|
1034
|
+
if (o.lot && ctl.drops.includes(o.lot))
|
|
1035
|
+
return DROPPED;
|
|
1036
|
+
return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
|
|
1037
|
+
},
|
|
855
1038
|
onWait: (h, ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
|
|
856
1039
|
onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
857
1040
|
});
|
|
@@ -887,11 +1070,19 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
887
1070
|
store.journal(line);
|
|
888
1071
|
},
|
|
889
1072
|
saveWave,
|
|
1073
|
+
integrate: deps.integrate,
|
|
890
1074
|
};
|
|
891
1075
|
wave.status = 'running';
|
|
892
1076
|
wave.pid = process.pid;
|
|
893
1077
|
saveWave();
|
|
894
1078
|
const ctxs = contexts(lots, envs, wctx);
|
|
1079
|
+
planTrees(ctxs, store, wave.id, wctx.log);
|
|
1080
|
+
try {
|
|
1081
|
+
updateWaveRepos(home, process.pid, repos, lanes());
|
|
1082
|
+
}
|
|
1083
|
+
catch {
|
|
1084
|
+
// registre illisible : le décompte des créneaux libres reste celui des dépôts
|
|
1085
|
+
}
|
|
895
1086
|
// Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
|
|
896
1087
|
const forget = onTermination(async () => {
|
|
897
1088
|
killSessions();
|
|
@@ -902,11 +1093,18 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
902
1093
|
s.status = 'interrupted';
|
|
903
1094
|
// Les tokens d'une session tuée comptent : relus dans son journal.
|
|
904
1095
|
if (s.status === 'interrupted')
|
|
905
|
-
countInterrupted(deps.claudeHome, c
|
|
1096
|
+
countInterrupted(deps.claudeHome, workDir(c), s, budget);
|
|
906
1097
|
}
|
|
907
1098
|
if (c.lot.status === 'implementing' || c.lot.status === 'reviewing' || c.lot.status === 'fixing')
|
|
908
1099
|
c.lot.status = 'suspended';
|
|
909
1100
|
store.writeLot(c.lot);
|
|
1101
|
+
// Le TMPDIR court de la session tuée (L243) est hors de la vague : sans cela il resterait dans /tmp.
|
|
1102
|
+
try {
|
|
1103
|
+
store.removeTmpDir(c.lot.project, c.lot.lot);
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
// nettoyage de confort
|
|
1107
|
+
}
|
|
910
1108
|
}
|
|
911
1109
|
wave.status = 'interrupted';
|
|
912
1110
|
saveWave();
|
|
@@ -915,6 +1113,12 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
915
1113
|
const finished = lotFinished;
|
|
916
1114
|
try {
|
|
917
1115
|
await runPool(ctxs, cap, all, (c) => {
|
|
1116
|
+
// Un lot rendu ou en échec garde son worktree et sa branche (R12) : le journal en donne le chemin.
|
|
1117
|
+
if (c.tree?.active && c.lot.status !== 'ready')
|
|
1118
|
+
wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : worktree conservé : ${c.tree.dir} (branche ${c.tree.branch})`);
|
|
1119
|
+
// Un lot rendu à l'étape d'intégration (`next: integrate`) : la ligne donne la commande qui le reprend (L279, R27).
|
|
1120
|
+
if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
|
|
1121
|
+
wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : rendu à l'intégration : reprise par ${resumeCommand(wctx.id, '--integrate', lotKey(c.lot.project, c.lot.lot))}`);
|
|
918
1122
|
// Un dépôt dont tous les lots de la vague (reprise comprise) sont prêts ou rendus peut être livré pendant que la vague continue ailleurs.
|
|
919
1123
|
if (wctx.incident)
|
|
920
1124
|
return;
|
|
@@ -922,7 +1126,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
922
1126
|
if (held.includes(join(sharedStateDir(r), REPO_LOCK)) && all.filter((l) => lotRepoPaths(l).includes(r)).every(finished))
|
|
923
1127
|
freeRepo(r);
|
|
924
1128
|
}
|
|
925
|
-
});
|
|
1129
|
+
}, (lots) => lots.every((c) => c.tree));
|
|
926
1130
|
handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
|
|
927
1131
|
}
|
|
928
1132
|
finally {
|
|
@@ -938,6 +1142,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
938
1142
|
io.err(`orchestrate : quota atteint — ${quotaText(wctx.quota.message ?? '')}`.trim());
|
|
939
1143
|
for (const line of renderTable(wave, all))
|
|
940
1144
|
io.out(line);
|
|
1145
|
+
for (const line of keptLines(all.map((l) => l.repo)))
|
|
1146
|
+
io.out(line); // les worktrees conservés, avec leur chemin (R19)
|
|
941
1147
|
if (wave.status === 'suspended-budget' || wave.status === 'suspended-quota')
|
|
942
1148
|
return 3;
|
|
943
1149
|
return all.every((l) => l.status === 'ready') ? 0 : 1;
|
|
@@ -990,21 +1196,72 @@ function control(args, io) {
|
|
|
990
1196
|
if (args.stopAfterCurrent) {
|
|
991
1197
|
store.requestStopAfterCurrent();
|
|
992
1198
|
store.journal('demande : arrêt après les sessions en cours');
|
|
993
|
-
io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par «
|
|
1199
|
+
io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par « ${resumeCommand(store.id)} »`);
|
|
994
1200
|
}
|
|
995
1201
|
return 0;
|
|
996
1202
|
}
|
|
1203
|
+
/** La seule vague non terminée du dossier (L309) ; plusieurs : refus qui les liste, aucune : refus (jamais le choix silencieux de la plus récente). */
|
|
1204
|
+
function singleUnfinished(launch) {
|
|
1205
|
+
const open = RunStore.unfinished(launch);
|
|
1206
|
+
if (open.length === 0)
|
|
1207
|
+
throw new RafError('aucune vague à reprendre dans ce dossier');
|
|
1208
|
+
if (open.length > 1)
|
|
1209
|
+
throw new RafError(`plusieurs vagues non terminées dans ce dossier, laquelle reprendre ?\n${open.map((s) => ` ${s.id} (${s.readWave()?.status ?? 'illisible'}, lancée ${s.readWave()?.created ?? '?'})`).join('\n')}\nreprise : ${resumeCommand('<vague>')}`);
|
|
1210
|
+
return open[0];
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* L309 : l'instantané d'une vague antérieure relit `--wave <id>` comme un simple nom et `--resume` seul comme « la dernière vague non terminée » :
|
|
1214
|
+
* on lui transmet la vague choisie en clair (`--resume <id>`, sans `--wave`), quel que soit le parseur qui la lira.
|
|
1215
|
+
*/
|
|
1216
|
+
export function resumeArgv(argv, id) {
|
|
1217
|
+
const out = [];
|
|
1218
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1219
|
+
const t = argv[i];
|
|
1220
|
+
if (t === '--wave')
|
|
1221
|
+
i++;
|
|
1222
|
+
else if (t === '--resume') {
|
|
1223
|
+
out.push(t, id);
|
|
1224
|
+
const next = argv[i + 1];
|
|
1225
|
+
if (next !== undefined && !next.startsWith('-') && !LOT_LIKE.test(next))
|
|
1226
|
+
i++;
|
|
1227
|
+
}
|
|
1228
|
+
else if (t === '--answer')
|
|
1229
|
+
out.push(t, argv[++i] ?? '', argv[++i] ?? '');
|
|
1230
|
+
else
|
|
1231
|
+
out.push(t);
|
|
1232
|
+
}
|
|
1233
|
+
return out;
|
|
1234
|
+
}
|
|
997
1235
|
async function resume(args, argv, io, deps, launch, today) {
|
|
998
|
-
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) :
|
|
1236
|
+
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : singleUnfinished(launch);
|
|
999
1237
|
if (!store)
|
|
1000
|
-
throw new RafError(
|
|
1238
|
+
throw new RafError(`vague inconnue : ${args.resume}`);
|
|
1001
1239
|
if (deps.snapshot) {
|
|
1002
1240
|
// La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
|
|
1003
|
-
if (snapshotExists(store.dir)) {
|
|
1241
|
+
if (LOT_LIKE.test(store.id) && snapshotExists(store.dir)) {
|
|
1242
|
+
// L309 : `--resume suite2` serait relu par n'importe quel parseur comme un lot à lancer ; `--wave suite2`, un instantané d'avant L309 le prendrait pour un simple nom et reprendrait une autre vague.
|
|
1243
|
+
io.err(`orchestrate : vague ${store.id} : son nom ressemble à un lot, l'instantané ne saurait pas le relire comme une vague : reprise avec le dist/ et les gabarits courants`);
|
|
1244
|
+
}
|
|
1245
|
+
else if (snapshotExists(store.dir)) {
|
|
1004
1246
|
// Une vague d'avant L223 refuserait « projet:lot=+N » comme budget invalide : seul le lot visé justifie de la rejouer avec le dist/ courant.
|
|
1005
|
-
if (!args.lotBudgets.length || waveReadsLotBudget(store.dir))
|
|
1006
|
-
|
|
1007
|
-
|
|
1247
|
+
if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir))) {
|
|
1248
|
+
// L330 : la copie dont l'instantané lie le node_modules a pu être purgée par une mise en service ; on relie, on le journalise, ou on refuse proprement.
|
|
1249
|
+
try {
|
|
1250
|
+
const healed = repairSnapshotModules(store.dir, deps.snapshot.packageRoot);
|
|
1251
|
+
if (healed) {
|
|
1252
|
+
store.journal(healed);
|
|
1253
|
+
io.err(`orchestrate : vague ${store.id} : ${healed}`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch (e) {
|
|
1257
|
+
if (!(e instanceof SnapshotRefusal))
|
|
1258
|
+
throw e;
|
|
1259
|
+
io.err(`orchestrate : vague ${store.id} : ${e.message}`);
|
|
1260
|
+
return 2;
|
|
1261
|
+
}
|
|
1262
|
+
return deps.snapshot.reexec(toolDirOf(store.dir), resumeArgv(argv, store.id), io.env, io);
|
|
1263
|
+
}
|
|
1264
|
+
io.err(`orchestrate : vague ${store.id} : son instantané ne connaît pas ${args.lotBudgets.length && !waveReadsLotBudget(store.dir) ? '--budget projet:lot=+N (antérieur à L223)' : `${args.integrate.some((t) => t.replay) && waveReadsIntegrate(store.dir) ? '--replay' : '--integrate'} (antérieur à L279)`} : reprise avec le dist/ et les gabarits courants`);
|
|
1008
1265
|
}
|
|
1009
1266
|
else {
|
|
1010
1267
|
io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
|
|
@@ -1019,7 +1276,7 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1019
1276
|
if (alive)
|
|
1020
1277
|
refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
|
|
1021
1278
|
// Une vague terminée ne se reprend que pour rejouer un lot visé par --budget projet:lot=+N (L223) ; ce lot est vérifié plus bas.
|
|
1022
|
-
if (wave.status === 'done' && !args.lotBudgets.length)
|
|
1279
|
+
if (wave.status === 'done' && !args.lotBudgets.length && !args.integrate.length)
|
|
1023
1280
|
refusals.push(`la vague ${wave.id} est terminée`);
|
|
1024
1281
|
const lots = store.lots();
|
|
1025
1282
|
for (const a of args.answers) {
|
|
@@ -1072,11 +1329,37 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1072
1329
|
else
|
|
1073
1330
|
raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise, step: budgetStep(l) });
|
|
1074
1331
|
}
|
|
1332
|
+
// --resume --integrate projet:lot (L279, R27) : un lot rendu à l'étape d'intégration (next: integrate) n'est rejoué que si le lead le nomme. Ni --resume seul, ni --drop, --answer ou --budget ne le rejouent.
|
|
1333
|
+
const integ = [];
|
|
1334
|
+
const budgeted = args.lotBudgets.map((b) => lots.filter((x) => x.lot === b.lot && (b.project ? x.project === b.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
|
|
1335
|
+
for (const t of args.integrate) {
|
|
1336
|
+
const matches = lots.filter((x) => x.lot === t.lot && (t.project ? x.project === t.project : true));
|
|
1337
|
+
const opt = t.replay ? '--replay' : '--integrate';
|
|
1338
|
+
const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
|
|
1339
|
+
if (matches.length !== 1) {
|
|
1340
|
+
refusals.push(matches.length ? `${opt} ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `${opt} ${where} : lot inconnu dans la vague ${wave.id}`);
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
const l = matches[0];
|
|
1344
|
+
const key = lotKey(l.project, l.lot);
|
|
1345
|
+
if (integ.includes(l))
|
|
1346
|
+
refusals.push(`${opt} ${key} : lot visé deux fois`);
|
|
1347
|
+
else if (dropped.includes(l))
|
|
1348
|
+
refusals.push(`${opt} ${key} : lot aussi retiré par --drop`);
|
|
1349
|
+
else if (answered.includes(l))
|
|
1350
|
+
refusals.push(`${opt} ${key} : lot aussi visé par --answer`);
|
|
1351
|
+
else if (budgeted.includes(l))
|
|
1352
|
+
refusals.push(`${opt} ${key} : lot aussi visé par --budget`);
|
|
1353
|
+
else if (l.status !== 'handed-back' || (l.next !== 'integrate' && !(t.replay && planNoteHandBack(l))))
|
|
1354
|
+
refusals.push(`${opt} ${key} : le lot n'est pas rendu ${t.replay ? "à l'étape d'intégration ni sur une écriture de plan refusée" : "à l'étape d'intégration"} (${l.status}, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'})`);
|
|
1355
|
+
else
|
|
1356
|
+
integ.push(l);
|
|
1357
|
+
}
|
|
1075
1358
|
// Un lot retiré ne jouera rien : il ne subit ni le verrou du dépôt, ni le .nvmrc, ni l'arbre sale — un refus pour LUI ne doit pas faire échouer la reprise des autres.
|
|
1076
1359
|
const live = [];
|
|
1077
1360
|
const removed = [];
|
|
1078
1361
|
for (const l of lots) {
|
|
1079
|
-
if (lotFinished(l) && !raised.has(l))
|
|
1362
|
+
if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
|
|
1080
1363
|
continue;
|
|
1081
1364
|
for (const s of l.steps) {
|
|
1082
1365
|
if (s.status !== 'running')
|
|
@@ -1113,6 +1396,15 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1113
1396
|
if (d.length)
|
|
1114
1397
|
refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1115
1398
|
}
|
|
1399
|
+
// Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
|
|
1400
|
+
for (const l of live) {
|
|
1401
|
+
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1402
|
+
if (!existsSync(join(dir, '.git')))
|
|
1403
|
+
continue;
|
|
1404
|
+
const d = await trackedDirty(dir);
|
|
1405
|
+
if (d.length)
|
|
1406
|
+
refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1407
|
+
}
|
|
1116
1408
|
if (refusals.length) {
|
|
1117
1409
|
for (const r of refusals)
|
|
1118
1410
|
io.err(`orchestrate : ${r}`);
|
|
@@ -1126,10 +1418,15 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1126
1418
|
}
|
|
1127
1419
|
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
1128
1420
|
const recovered = new Budget(0);
|
|
1421
|
+
// La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
|
|
1422
|
+
const workdirOf = (l) => {
|
|
1423
|
+
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1424
|
+
return existsSync(join(dir, '.git')) ? dir : l.repo;
|
|
1425
|
+
};
|
|
1129
1426
|
for (const l of [...live, ...removed])
|
|
1130
1427
|
for (const s of l.steps)
|
|
1131
1428
|
if (s.status === 'interrupted')
|
|
1132
|
-
countInterrupted(deps.claudeHome, l
|
|
1429
|
+
countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
|
|
1133
1430
|
wave.consumed += recovered.consumed;
|
|
1134
1431
|
wave.cacheRead += recovered.cacheRead;
|
|
1135
1432
|
if (recovered.consumed || recovered.cacheRead)
|
|
@@ -1145,6 +1442,17 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1145
1442
|
store.journal(`${k} → suspended — ${text}`);
|
|
1146
1443
|
io.out(`${k} → suspended — ${text}`);
|
|
1147
1444
|
}
|
|
1445
|
+
for (const l of integ) {
|
|
1446
|
+
const k = lotKey(l.project, l.lot);
|
|
1447
|
+
const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
|
|
1448
|
+
if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
|
|
1449
|
+
l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
|
|
1450
|
+
l.status = 'suspended';
|
|
1451
|
+
l.outcome = null;
|
|
1452
|
+
const text = planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
|
|
1453
|
+
store.journal(`${k} → suspended — ${text}`);
|
|
1454
|
+
io.out(`${k} → suspended — ${text}`);
|
|
1455
|
+
}
|
|
1148
1456
|
for (const l of live) {
|
|
1149
1457
|
if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
|
|
1150
1458
|
l.status = 'suspended';
|
|
@@ -1163,6 +1471,13 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1163
1471
|
l.outcome = DROPPED;
|
|
1164
1472
|
store.writeLot(l);
|
|
1165
1473
|
store.journal(`${k} → handed-back — ${DROPPED}`);
|
|
1474
|
+
// Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
|
|
1475
|
+
const branch = lotBranch(wave.id, l.project, l.lot);
|
|
1476
|
+
if (baseBranchOf(l.repo, branch)) {
|
|
1477
|
+
const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
|
|
1478
|
+
io.out(line);
|
|
1479
|
+
store.journal(line);
|
|
1480
|
+
}
|
|
1166
1481
|
}
|
|
1167
1482
|
if (live.length === 0) {
|
|
1168
1483
|
if (lots.every(lotFinished)) {
|
|
@@ -1171,6 +1486,8 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1171
1486
|
}
|
|
1172
1487
|
for (const line of renderTable(wave, lots))
|
|
1173
1488
|
io.out(line);
|
|
1489
|
+
for (const line of keptLines(lots.map((l) => l.repo)))
|
|
1490
|
+
io.out(line);
|
|
1174
1491
|
return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
|
|
1175
1492
|
}
|
|
1176
1493
|
return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
|