@sylad/cadence 1.8.0 → 1.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -7
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/qa-reviewer.md +2 -2
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +14 -4
- package/dist/check.js +6 -0
- package/dist/cli.js +235 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +23 -2
- package/dist/deliver.js +78 -21
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +37 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +28 -6
- package/dist/orchestrate/command.js +303 -34
- package/dist/orchestrate/cycle.js +720 -273
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +85 -0
- package/dist/orchestrate/launch.js +37 -5
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +123 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +25 -0
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +42 -6
- package/dist/orchestrate/table.js +61 -2
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +80 -19
- package/dist/plan.js +92 -6
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +116 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +78 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +179 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +104 -0
- package/dist/ref/png.js +125 -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 +60 -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 +58 -3
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +5 -5
- package/docs/lead.md +35 -12
- package/docs/news.md +24 -1
- package/docs/orchestrate.md +230 -19
- package/docs/perf.md +13 -8
- package/docs/raf.md +89 -11
- package/docs/ref.md +316 -0
- package/docs/session.md +2 -2
- package/docs/skills.md +9 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +2 -0
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +24 -6
- 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 +6 -3
- 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,29 @@ 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';
|
|
18
20
|
import { ficheReviewText } from './fiche.js';
|
|
19
21
|
import { maquetteText } from './maquette.js';
|
|
20
22
|
import { specScreen, specText } from './spec.js';
|
|
21
23
|
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';
|
|
24
|
+
import { Budget, DROPPED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
23
25
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
24
26
|
import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
25
27
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
26
|
-
import { runPool } from './pool.js';
|
|
28
|
+
import { groupLots, runPool } from './pool.js';
|
|
29
|
+
import { repoBranch } from './integrate.js';
|
|
30
|
+
import { baseBranchOf, keptLines, keptTrees, lotBranch, lotTreeDir, planFileSet, recoverTree, removeTree } from './worktree.js';
|
|
27
31
|
import { schemaFor } from './schemas.js';
|
|
28
32
|
import { resolveLotRepos } from '../repos.js';
|
|
29
33
|
import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
30
34
|
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
31
35
|
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 { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
33
37
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
34
38
|
/**
|
|
35
39
|
* Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
|
|
@@ -37,8 +41,11 @@ export const DEFAULT_BUDGET = 2_000_000;
|
|
|
37
41
|
* 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
42
|
* 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
43
|
* 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.
|
|
44
|
+
* 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
|
|
45
|
+
* 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.
|
|
46
|
+
* 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
47
|
*/
|
|
41
|
-
const LOT_BUDGET_FLOOR =
|
|
48
|
+
const LOT_BUDGET_FLOOR = 600_000;
|
|
42
49
|
/** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
|
|
43
50
|
const LOT_BUDGET_ONE_DAY = 750_000;
|
|
44
51
|
/** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
|
|
@@ -121,7 +128,7 @@ function lotBudgetArg(text) {
|
|
|
121
128
|
return { project: target.project, lot: target.lot, raise, text: m[2] };
|
|
122
129
|
}
|
|
123
130
|
export function parseOrchestrateArgs(argv) {
|
|
124
|
-
const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
|
|
131
|
+
const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, integrate: [], abandonCommits: false };
|
|
125
132
|
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
126
133
|
for (let i = 0; i < argv.length; i++) {
|
|
127
134
|
const t = argv[i];
|
|
@@ -158,12 +165,29 @@ export function parseOrchestrateArgs(argv) {
|
|
|
158
165
|
}
|
|
159
166
|
else if (t === '--max-sessions')
|
|
160
167
|
a.maxSessions = parseMaxSessions(value(t));
|
|
168
|
+
else if (t === '--goal') {
|
|
169
|
+
// Un texte oublié ne doit pas avaler l'option suivante (`--goal --dry-run` lancerait une vraie vague payante).
|
|
170
|
+
const next = argv[i + 1];
|
|
171
|
+
if (next?.startsWith('--'))
|
|
172
|
+
throw new RafError(`--goal attend un texte, pas l'option ${next}`);
|
|
173
|
+
a.goal = value(t).trim();
|
|
174
|
+
if (a.goal === '')
|
|
175
|
+
throw new RafError('--goal attend un texte non vide');
|
|
176
|
+
}
|
|
161
177
|
else if (t === '--wave')
|
|
162
178
|
a.wave = value(t);
|
|
163
179
|
else if (t === '--drop')
|
|
164
180
|
a.drop.push(lotArg(value(t)));
|
|
165
181
|
else if (t === '--stop-after-current')
|
|
166
182
|
a.stopAfterCurrent = true;
|
|
183
|
+
else if (t === '--integrate')
|
|
184
|
+
a.integrate.push(lotArg(value(t)));
|
|
185
|
+
else if (t === '--replay')
|
|
186
|
+
a.integrate.push({ ...lotArg(value(t)), replay: true });
|
|
187
|
+
else if (t === '--remove-worktree')
|
|
188
|
+
a.removeWorktree = lotArg(value(t));
|
|
189
|
+
else if (t === '--abandon-commits')
|
|
190
|
+
a.abandonCommits = true;
|
|
167
191
|
else if (t === '--continue')
|
|
168
192
|
a.continue = true;
|
|
169
193
|
else if (t === '--until')
|
|
@@ -187,7 +211,7 @@ export function projectEnv(repo) {
|
|
|
187
211
|
const loadPlan = planLoader(repo);
|
|
188
212
|
return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
|
|
189
213
|
}
|
|
190
|
-
const isProject = (dir) => existsSync(join(dir,
|
|
214
|
+
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
215
|
function resolveTargets(args, io, refusals) {
|
|
192
216
|
const out = [];
|
|
193
217
|
const seen = new Set();
|
|
@@ -205,7 +229,7 @@ function resolveTargets(args, io, refusals) {
|
|
|
205
229
|
continue;
|
|
206
230
|
}
|
|
207
231
|
if (!isProject(projectDir)) {
|
|
208
|
-
refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (
|
|
232
|
+
refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (${PLAN_DEFAULT}, ${PLAN_LEGACY}, ou cadence.yaml avec une clé plan:)`);
|
|
209
233
|
continue;
|
|
210
234
|
}
|
|
211
235
|
const repo = gitRoot(projectDir);
|
|
@@ -228,6 +252,23 @@ function olderThanEffort(version) {
|
|
|
228
252
|
const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
229
253
|
return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
|
|
230
254
|
}
|
|
255
|
+
/** Variable d'environnement qui lève le refus de `orchestrate.worktrees: true` (mode expérimental, L279 ; lots L299–L301). Posée par le harnais des tests du mode. */
|
|
256
|
+
export const EXPERIMENTAL_WORKTREES_ENV = 'CADENCE_EXPERIMENTAL_WORKTREES';
|
|
257
|
+
/**
|
|
258
|
+
* Seule décision d'activation du mode worktree (L279) : le départ (`preflight`), la file des lots (`planTrees`, `dryRun`) et la reprise (`resume`) passent tous par elle.
|
|
259
|
+
* Le mode joue si la configuration le demande (ou si le lot repris a une branche `cadence/<vague>/…` à retrouver, `branchKept`) ET si la variable vaut exactement `1`.
|
|
260
|
+
*/
|
|
261
|
+
export function worktreesEnabled(config, env, branchKept = false) {
|
|
262
|
+
return (config.worktrees || branchKept) && env[EXPERIMENTAL_WORKTREES_ENV] === '1';
|
|
263
|
+
}
|
|
264
|
+
/** Le refus de la garde d'activation, ou null : le mode est demandé (configuration, ou branche de lot à retrouver) sans que la variable le permette. */
|
|
265
|
+
function worktreesRefusal(key, config, env, keptBranch) {
|
|
266
|
+
const wanted = config.worktrees || !!keptBranch;
|
|
267
|
+
if (!wanted || worktreesEnabled(config, env, !!keptBranch))
|
|
268
|
+
return null;
|
|
269
|
+
const what = config.worktrees ? 'orchestrate.worktrees: true' : `la branche ${keptBranch} (lot joué dans un worktree)`;
|
|
270
|
+
return `${key} : ${what} est refusé : le mode worktree par lot est EXPÉRIMENTAL et non activable tant que les lots L299, L300 et L301 ne sont pas faits (retirer la clé, ou poser ${EXPERIMENTAL_WORKTREES_ENV}=1 pour les tests du mode)`;
|
|
271
|
+
}
|
|
231
272
|
/** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
|
|
232
273
|
async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
233
274
|
const refusals = [];
|
|
@@ -253,6 +294,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
253
294
|
refusals.push(`${key} : ${e.message}`);
|
|
254
295
|
continue;
|
|
255
296
|
}
|
|
297
|
+
// L279 : le mode worktree est une infrastructure expérimentale, non activable tant que L299–L301 ne sont pas faits (refus avant d'agir, `--dry-run` compris).
|
|
298
|
+
const refused = worktreesRefusal(key, env.config, io.env);
|
|
299
|
+
if (refused) {
|
|
300
|
+
refusals.push(refused);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
256
303
|
if (info && olderThanEffort(info.version) && Object.values(env.config.effort).some((e) => e !== 'default')) {
|
|
257
304
|
refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
|
|
258
305
|
continue;
|
|
@@ -312,10 +359,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
312
359
|
continue;
|
|
313
360
|
}
|
|
314
361
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
362
|
+
const goal = args.goal ?? lot.goal;
|
|
315
363
|
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 });
|
|
364
|
+
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
365
|
if (neighbours.repos.length)
|
|
318
366
|
state.repos = neighbours.repos;
|
|
367
|
+
state.estimate = lot.estimate;
|
|
319
368
|
state.light = lot.estimate <= env.config.review.threshold;
|
|
320
369
|
state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
|
|
321
370
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
@@ -354,8 +403,12 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
354
403
|
const lot = env.loadPlan().lot(l.lot);
|
|
355
404
|
const slot = repos.indexOf(l.repo);
|
|
356
405
|
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
406
|
+
if (l.goal)
|
|
407
|
+
io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
|
|
357
408
|
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
|
|
358
|
-
|
|
409
|
+
const mine = lots.filter((o) => o.repo === l.repo);
|
|
410
|
+
const together = worktreesEnabled(env.config, io.env) && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
|
|
411
|
+
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
412
|
if (l.repos?.length)
|
|
360
413
|
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
414
|
if (l.node) {
|
|
@@ -373,7 +426,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
373
426
|
steps.unshift({ kind: 'fiche', model: 'sonnet' });
|
|
374
427
|
if (specStage)
|
|
375
428
|
steps.unshift({ kind: 'spec', model: 'sonnet' });
|
|
376
|
-
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
|
|
429
|
+
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal))
|
|
377
430
|
steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
|
|
378
431
|
const { light, full } = env.config.review;
|
|
379
432
|
if (l.small)
|
|
@@ -388,7 +441,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
388
441
|
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
442
|
const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
|
|
390
443
|
// 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.
|
|
444
|
+
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
445
|
if (specStage)
|
|
393
446
|
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
|
|
394
447
|
if (l.screen && env.config.maquette)
|
|
@@ -434,38 +487,100 @@ function duration(ms) {
|
|
|
434
487
|
const s = Math.round(ms / 1000);
|
|
435
488
|
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
|
|
436
489
|
}
|
|
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) {
|
|
490
|
+
/** 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. */
|
|
491
|
+
export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
|
|
439
492
|
const home = cadenceHome();
|
|
440
493
|
const waves = liveWaves(home);
|
|
441
|
-
|
|
494
|
+
const holds = liveHolds(home, now);
|
|
495
|
+
if (waves.length === 0 && holds.length === 0)
|
|
442
496
|
return [];
|
|
443
497
|
const slots = liveSlots(home).length;
|
|
444
|
-
const
|
|
498
|
+
const lead = holds.length ? ` · sessions du lead : ${holds.length} (${holds.map((h) => h.label).join(', ')})` : '';
|
|
499
|
+
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}${lead} · créneaux libres : ${freeSlots(waves, cap, holds.length)} sur ${cap}`];
|
|
445
500
|
for (const w of waves)
|
|
446
501
|
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
502
|
return lines;
|
|
448
503
|
}
|
|
504
|
+
/** Durée à la minute : « 12 min », « 5 h 10 min », « 2 j 3 h ». */
|
|
505
|
+
function longDuration(ms) {
|
|
506
|
+
const m = Math.max(0, Math.floor(ms / 60_000));
|
|
507
|
+
if (m < 60)
|
|
508
|
+
return `${m} min`;
|
|
509
|
+
const h = Math.floor(m / 60);
|
|
510
|
+
if (h < 24)
|
|
511
|
+
return `${h} h ${String(m % 60).padStart(2, '0')} min`;
|
|
512
|
+
return `${Math.floor(h / 24)} j ${h % 24} h`;
|
|
513
|
+
}
|
|
514
|
+
/** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
|
|
515
|
+
export function expiredHoldLines(now = new Date()) {
|
|
516
|
+
return expiredHolds(cadenceHome(), now).map((h) => {
|
|
517
|
+
const since = Date.parse(h.since);
|
|
518
|
+
const age = Number.isFinite(since) ? `inscrite depuis ${longDuration(now.getTime() - since)}, ` : '';
|
|
519
|
+
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})`;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
/** `--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. */
|
|
523
|
+
function freeLines(launch, drawn) {
|
|
524
|
+
const dirs = isLeadProject(launch) ? [launch] : findProjects(launch);
|
|
525
|
+
const free = readyFree(dirs, cadenceHome(), drawn);
|
|
526
|
+
const line = freeLine(dirs.filter((d) => free.has(d)).map((d) => ({ project: basename(d), free: free.get(d) })), 60);
|
|
527
|
+
return line ? [line] : [];
|
|
528
|
+
}
|
|
449
529
|
/** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
|
|
450
530
|
const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
451
531
|
const DEFAULT_WATCH_INTERVAL = 10;
|
|
452
532
|
/** 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) {
|
|
533
|
+
function statusView(args, launch, cap, now) {
|
|
454
534
|
const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
|
|
455
|
-
const live = liveLines(cap);
|
|
535
|
+
const live = [...liveLines(cap, now), ...expiredHoldLines(now)];
|
|
536
|
+
const waveLive = liveWaves(cadenceHome()).length > 0;
|
|
456
537
|
if (!store && live.length && args.status === true)
|
|
457
|
-
return { live, running:
|
|
538
|
+
return { live, running: waveLive };
|
|
458
539
|
if (!store)
|
|
459
540
|
throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
|
|
460
541
|
// `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
|
-
|
|
542
|
+
const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
|
|
543
|
+
const lots = store.lots();
|
|
544
|
+
return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
|
|
463
545
|
}
|
|
464
546
|
/** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
|
|
465
547
|
function waveExists(wave, io) {
|
|
466
548
|
io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
|
|
467
549
|
return 2;
|
|
468
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* `--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
|
|
553
|
+
* 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
|
|
554
|
+
* quand plusieurs ont laissé un worktree pour ce lot.
|
|
555
|
+
*/
|
|
556
|
+
function removeWorktree(args, io) {
|
|
557
|
+
const t = args.removeWorktree;
|
|
558
|
+
const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
|
|
559
|
+
const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
|
|
560
|
+
const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
|
|
561
|
+
const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
|
|
562
|
+
if (!repo || kept.length === 0) {
|
|
563
|
+
io.err(`orchestrate : ${key} : aucun worktree conservé`);
|
|
564
|
+
return 2;
|
|
565
|
+
}
|
|
566
|
+
if (kept.length > 1) {
|
|
567
|
+
io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
|
|
568
|
+
return 2;
|
|
569
|
+
}
|
|
570
|
+
const k = kept[0];
|
|
571
|
+
if (k.active) {
|
|
572
|
+
// une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
|
|
573
|
+
io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
|
|
574
|
+
return 2;
|
|
575
|
+
}
|
|
576
|
+
const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
|
|
577
|
+
if (!gone.ok) {
|
|
578
|
+
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`);
|
|
579
|
+
return 2;
|
|
580
|
+
}
|
|
581
|
+
io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
|
|
582
|
+
return 0;
|
|
583
|
+
}
|
|
469
584
|
/** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
|
|
470
585
|
export async function orchestrate(argv, io, deps) {
|
|
471
586
|
const args = parseOrchestrateArgs(argv);
|
|
@@ -491,6 +606,16 @@ export async function orchestrate(argv, io, deps) {
|
|
|
491
606
|
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
607
|
if (args.lotBudgets.length && args.resume === undefined)
|
|
493
608
|
throw new RafError("--budget projet:lot=+N ne vaut qu'avec --resume");
|
|
609
|
+
// L279 : --integrate rejoue une intégration rendue, donc une vague arrêtée ; --abandon-commits ne vaut qu'avec --remove-worktree.
|
|
610
|
+
if (args.integrate.length && args.resume === undefined)
|
|
611
|
+
throw new RafError(`${args.integrate.some((t) => !t.replay) ? '--integrate' : '--replay'} ne vaut qu'avec --resume`);
|
|
612
|
+
if (args.abandonCommits && !args.removeWorktree)
|
|
613
|
+
throw new RafError("--abandon-commits ne vaut qu'avec --remove-worktree");
|
|
614
|
+
if (args.removeWorktree && (args.lots.length || args.resume !== undefined || args.status !== undefined || args.continue || args.dryRun || args.drop.length || args.stopAfterCurrent || args.waits))
|
|
615
|
+
throw new RafError("--remove-worktree ne se combine avec aucune autre action");
|
|
616
|
+
// 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).
|
|
617
|
+
if (args.goal !== undefined && (args.lots.length !== 1 || args.continue || args.resume !== undefined || args.status !== undefined || args.waits || args.drop.length || args.stopAfterCurrent))
|
|
618
|
+
throw new RafError("--goal s'applique à un seul lot lancé : <projet>:<lot> --goal \"…\" (plusieurs lots : la clé goal: de chacun)");
|
|
494
619
|
if (args.since !== undefined && !args.waits)
|
|
495
620
|
throw new RafError('--since s\'utilise avec --waits');
|
|
496
621
|
if (args.waits) {
|
|
@@ -503,7 +628,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
503
628
|
if (args.status !== undefined) {
|
|
504
629
|
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
505
630
|
if (!args.watch) {
|
|
506
|
-
const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
|
|
631
|
+
const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
|
|
507
632
|
for (const line of live)
|
|
508
633
|
io.out(line);
|
|
509
634
|
if (!table)
|
|
@@ -517,7 +642,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
517
642
|
// --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
|
|
518
643
|
const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
519
644
|
for (;;) {
|
|
520
|
-
const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
|
|
645
|
+
const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
|
|
521
646
|
io.out(CLEAR_SCREEN);
|
|
522
647
|
for (const line of live)
|
|
523
648
|
io.out(line);
|
|
@@ -534,12 +659,14 @@ export async function orchestrate(argv, io, deps) {
|
|
|
534
659
|
await sleep((args.interval ?? DEFAULT_WATCH_INTERVAL) * 1000);
|
|
535
660
|
}
|
|
536
661
|
}
|
|
662
|
+
if (args.removeWorktree)
|
|
663
|
+
return removeWorktree(args, io);
|
|
537
664
|
if (args.resume !== undefined)
|
|
538
665
|
return resume(args, argv, io, deps, launch, today);
|
|
539
666
|
if (args.drop.length || args.stopAfterCurrent)
|
|
540
667
|
return control(args, io);
|
|
541
668
|
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');
|
|
669
|
+
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
670
|
const refusals = [];
|
|
544
671
|
const targets = resolveTargets(args, io, refusals);
|
|
545
672
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -565,6 +692,9 @@ export async function orchestrate(argv, io, deps) {
|
|
|
565
692
|
if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
|
|
566
693
|
return waveExists(args.wave, io);
|
|
567
694
|
dryRun(pre.lots, io, deps, budget, id);
|
|
695
|
+
if (args.continue)
|
|
696
|
+
for (const l of freeLines(launch, pre.lots))
|
|
697
|
+
io.out(l);
|
|
568
698
|
if (args.continue)
|
|
569
699
|
await continueDryRun(args, io, deps, launch, pre.lots, budget, firstSkipped);
|
|
570
700
|
return 0;
|
|
@@ -604,6 +734,9 @@ export async function orchestrate(argv, io, deps) {
|
|
|
604
734
|
}
|
|
605
735
|
}
|
|
606
736
|
const id = store.id;
|
|
737
|
+
if (args.continue)
|
|
738
|
+
for (const l of freeLines(launch, pre.lots))
|
|
739
|
+
io.out(l);
|
|
607
740
|
// Les candidats refusés au premier tirage : leur cause dit pourquoi ils ne jouent pas (même ligne que celles des tirages suivants).
|
|
608
741
|
for (const s of firstSkipped) {
|
|
609
742
|
io.err(`orchestrate : lot sauté — ${s}`);
|
|
@@ -781,6 +914,39 @@ async function continueRounds(first, wave, all, store, io, deps, today, args, la
|
|
|
781
914
|
function contexts(lots, envs, wctx) {
|
|
782
915
|
return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
|
|
783
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* 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,
|
|
919
|
+
* 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.
|
|
920
|
+
* 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,
|
|
921
|
+
* ou celle que la branche du lot a notée à sa création.
|
|
922
|
+
*/
|
|
923
|
+
function planTrees(ctxs, store, wave, log, env) {
|
|
924
|
+
for (const group of groupLots(ctxs)) {
|
|
925
|
+
const repo = group[0].lot.repo;
|
|
926
|
+
const branch = repoBranch(repo);
|
|
927
|
+
const requested = group.length > 1 && group.every((c) => worktreesEnabled(c.config, env));
|
|
928
|
+
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;
|
|
929
|
+
if (why)
|
|
930
|
+
log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
|
|
931
|
+
for (const c of group) {
|
|
932
|
+
const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
|
|
933
|
+
// 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).
|
|
934
|
+
const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
|
|
935
|
+
const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch && worktreesEnabled(c.config, env, true) ? recoverTree(repo, lotBr, dir, branch, planFileSet(c.loadPlan(), repo)) : null;
|
|
936
|
+
if (recovered && 'refused' in recovered)
|
|
937
|
+
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)`);
|
|
938
|
+
else if (recovered)
|
|
939
|
+
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`);
|
|
940
|
+
const known = baseBranchOf(repo, lotBr);
|
|
941
|
+
const base = known ?? branch;
|
|
942
|
+
// 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.
|
|
943
|
+
if (c.lot.next === 'integrate' && !known)
|
|
944
|
+
continue;
|
|
945
|
+
if (((requested && !why) || (known && worktreesEnabled(c.config, env, true))) && base)
|
|
946
|
+
c.tree = { dir, branch: lotBr, base, active: false };
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
784
950
|
/** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
|
|
785
951
|
async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
786
952
|
const launch = store.launchDir;
|
|
@@ -795,6 +961,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
795
961
|
const repos = [...new Set(lots.flatMap(lotRepoPaths))];
|
|
796
962
|
/** 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
963
|
const freed = new Set();
|
|
964
|
+
/** 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. */
|
|
965
|
+
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
966
|
const freeRepo = (r) => {
|
|
799
967
|
freed.add(r);
|
|
800
968
|
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
@@ -802,7 +970,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
802
970
|
held.splice(0, held.length, ...held.filter((f) => f !== file));
|
|
803
971
|
removePrePush(r, wave.id);
|
|
804
972
|
try {
|
|
805
|
-
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)));
|
|
973
|
+
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
|
|
806
974
|
}
|
|
807
975
|
catch {
|
|
808
976
|
// registre illisible : `--status` garde la liste de départ, le verrou est bien tombé
|
|
@@ -887,11 +1055,19 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
887
1055
|
store.journal(line);
|
|
888
1056
|
},
|
|
889
1057
|
saveWave,
|
|
1058
|
+
integrate: deps.integrate,
|
|
890
1059
|
};
|
|
891
1060
|
wave.status = 'running';
|
|
892
1061
|
wave.pid = process.pid;
|
|
893
1062
|
saveWave();
|
|
894
1063
|
const ctxs = contexts(lots, envs, wctx);
|
|
1064
|
+
planTrees(ctxs, store, wave.id, wctx.log, io.env);
|
|
1065
|
+
try {
|
|
1066
|
+
updateWaveRepos(home, process.pid, repos, lanes());
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
// registre illisible : le décompte des créneaux libres reste celui des dépôts
|
|
1070
|
+
}
|
|
895
1071
|
// Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
|
|
896
1072
|
const forget = onTermination(async () => {
|
|
897
1073
|
killSessions();
|
|
@@ -902,11 +1078,18 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
902
1078
|
s.status = 'interrupted';
|
|
903
1079
|
// Les tokens d'une session tuée comptent : relus dans son journal.
|
|
904
1080
|
if (s.status === 'interrupted')
|
|
905
|
-
countInterrupted(deps.claudeHome, c
|
|
1081
|
+
countInterrupted(deps.claudeHome, workDir(c), s, budget);
|
|
906
1082
|
}
|
|
907
1083
|
if (c.lot.status === 'implementing' || c.lot.status === 'reviewing' || c.lot.status === 'fixing')
|
|
908
1084
|
c.lot.status = 'suspended';
|
|
909
1085
|
store.writeLot(c.lot);
|
|
1086
|
+
// Le TMPDIR court de la session tuée (L243) est hors de la vague : sans cela il resterait dans /tmp.
|
|
1087
|
+
try {
|
|
1088
|
+
store.removeTmpDir(c.lot.project, c.lot.lot);
|
|
1089
|
+
}
|
|
1090
|
+
catch {
|
|
1091
|
+
// nettoyage de confort
|
|
1092
|
+
}
|
|
910
1093
|
}
|
|
911
1094
|
wave.status = 'interrupted';
|
|
912
1095
|
saveWave();
|
|
@@ -915,6 +1098,12 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
915
1098
|
const finished = lotFinished;
|
|
916
1099
|
try {
|
|
917
1100
|
await runPool(ctxs, cap, all, (c) => {
|
|
1101
|
+
// Un lot rendu ou en échec garde son worktree et sa branche (R12) : le journal en donne le chemin.
|
|
1102
|
+
if (c.tree?.active && c.lot.status !== 'ready')
|
|
1103
|
+
wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : worktree conservé : ${c.tree.dir} (branche ${c.tree.branch})`);
|
|
1104
|
+
// Un lot rendu à l'étape d'intégration (`next: integrate`) : la ligne donne la commande qui le reprend (L279, R27).
|
|
1105
|
+
if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
|
|
1106
|
+
wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : rendu à l'intégration : reprise par cadence orchestrate --resume ${wctx.id} --integrate ${lotKey(c.lot.project, c.lot.lot)}`);
|
|
918
1107
|
// 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
1108
|
if (wctx.incident)
|
|
920
1109
|
return;
|
|
@@ -922,7 +1111,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
922
1111
|
if (held.includes(join(sharedStateDir(r), REPO_LOCK)) && all.filter((l) => lotRepoPaths(l).includes(r)).every(finished))
|
|
923
1112
|
freeRepo(r);
|
|
924
1113
|
}
|
|
925
|
-
});
|
|
1114
|
+
}, (lots) => lots.every((c) => c.tree));
|
|
926
1115
|
handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
|
|
927
1116
|
}
|
|
928
1117
|
finally {
|
|
@@ -938,6 +1127,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
938
1127
|
io.err(`orchestrate : quota atteint — ${quotaText(wctx.quota.message ?? '')}`.trim());
|
|
939
1128
|
for (const line of renderTable(wave, all))
|
|
940
1129
|
io.out(line);
|
|
1130
|
+
for (const line of keptLines(all.map((l) => l.repo)))
|
|
1131
|
+
io.out(line); // les worktrees conservés, avec leur chemin (R19)
|
|
941
1132
|
if (wave.status === 'suspended-budget' || wave.status === 'suspended-quota')
|
|
942
1133
|
return 3;
|
|
943
1134
|
return all.every((l) => l.status === 'ready') ? 0 : 1;
|
|
@@ -1002,9 +1193,9 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1002
1193
|
// La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
|
|
1003
1194
|
if (snapshotExists(store.dir)) {
|
|
1004
1195
|
// 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))
|
|
1196
|
+
if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir)))
|
|
1006
1197
|
return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
|
|
1007
|
-
io.err(`orchestrate : vague ${store.id} : son instantané ne connaît pas --budget projet:lot=+N (antérieur à L223) : reprise avec le dist/ et les gabarits courants`);
|
|
1198
|
+
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
1199
|
}
|
|
1009
1200
|
else {
|
|
1010
1201
|
io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
|
|
@@ -1019,7 +1210,7 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1019
1210
|
if (alive)
|
|
1020
1211
|
refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
|
|
1021
1212
|
// 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)
|
|
1213
|
+
if (wave.status === 'done' && !args.lotBudgets.length && !args.integrate.length)
|
|
1023
1214
|
refusals.push(`la vague ${wave.id} est terminée`);
|
|
1024
1215
|
const lots = store.lots();
|
|
1025
1216
|
for (const a of args.answers) {
|
|
@@ -1072,11 +1263,55 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1072
1263
|
else
|
|
1073
1264
|
raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise, step: budgetStep(l) });
|
|
1074
1265
|
}
|
|
1266
|
+
// --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.
|
|
1267
|
+
const integ = [];
|
|
1268
|
+
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]);
|
|
1269
|
+
for (const t of args.integrate) {
|
|
1270
|
+
const matches = lots.filter((x) => x.lot === t.lot && (t.project ? x.project === t.project : true));
|
|
1271
|
+
const opt = t.replay ? '--replay' : '--integrate';
|
|
1272
|
+
const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
|
|
1273
|
+
if (matches.length !== 1) {
|
|
1274
|
+
refusals.push(matches.length ? `${opt} ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `${opt} ${where} : lot inconnu dans la vague ${wave.id}`);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
const l = matches[0];
|
|
1278
|
+
const key = lotKey(l.project, l.lot);
|
|
1279
|
+
if (integ.includes(l))
|
|
1280
|
+
refusals.push(`${opt} ${key} : lot visé deux fois`);
|
|
1281
|
+
else if (dropped.includes(l))
|
|
1282
|
+
refusals.push(`${opt} ${key} : lot aussi retiré par --drop`);
|
|
1283
|
+
else if (answered.includes(l))
|
|
1284
|
+
refusals.push(`${opt} ${key} : lot aussi visé par --answer`);
|
|
1285
|
+
else if (budgeted.includes(l))
|
|
1286
|
+
refusals.push(`${opt} ${key} : lot aussi visé par --budget`);
|
|
1287
|
+
else if (l.status !== 'handed-back' || (l.next !== 'integrate' && !(t.replay && planNoteHandBack(l))))
|
|
1288
|
+
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'})`);
|
|
1289
|
+
else
|
|
1290
|
+
integ.push(l);
|
|
1291
|
+
}
|
|
1292
|
+
// L279 : la garde d'activation du mode worktree vaut aussi à la reprise (--resume, --integrate, --replay) : un lot repris dont la configuration demande les worktrees, ou dont la branche est à retrouver, est refusé sans la variable, avant toute écriture.
|
|
1293
|
+
for (const l of lots) {
|
|
1294
|
+
if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
|
|
1295
|
+
continue;
|
|
1296
|
+
if (drops.includes(lotKey(l.project, l.lot)))
|
|
1297
|
+
continue;
|
|
1298
|
+
let config;
|
|
1299
|
+
try {
|
|
1300
|
+
config = projectEnv(l.repo).config;
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
continue; // un cadence.yaml illisible est refusé par execute, comme avant
|
|
1304
|
+
}
|
|
1305
|
+
const br = lotBranch(wave.id, l.project, l.lot);
|
|
1306
|
+
const refused = worktreesRefusal(lotKey(l.project, l.lot), config, io.env, baseBranchOf(l.repo, br) ? br : undefined);
|
|
1307
|
+
if (refused)
|
|
1308
|
+
refusals.push(refused);
|
|
1309
|
+
}
|
|
1075
1310
|
// 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
1311
|
const live = [];
|
|
1077
1312
|
const removed = [];
|
|
1078
1313
|
for (const l of lots) {
|
|
1079
|
-
if (lotFinished(l) && !raised.has(l))
|
|
1314
|
+
if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
|
|
1080
1315
|
continue;
|
|
1081
1316
|
for (const s of l.steps) {
|
|
1082
1317
|
if (s.status !== 'running')
|
|
@@ -1113,6 +1348,15 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1113
1348
|
if (d.length)
|
|
1114
1349
|
refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1115
1350
|
}
|
|
1351
|
+
// Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
|
|
1352
|
+
for (const l of live) {
|
|
1353
|
+
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1354
|
+
if (!existsSync(join(dir, '.git')))
|
|
1355
|
+
continue;
|
|
1356
|
+
const d = await trackedDirty(dir);
|
|
1357
|
+
if (d.length)
|
|
1358
|
+
refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1359
|
+
}
|
|
1116
1360
|
if (refusals.length) {
|
|
1117
1361
|
for (const r of refusals)
|
|
1118
1362
|
io.err(`orchestrate : ${r}`);
|
|
@@ -1126,10 +1370,15 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1126
1370
|
}
|
|
1127
1371
|
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
1128
1372
|
const recovered = new Budget(0);
|
|
1373
|
+
// La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
|
|
1374
|
+
const workdirOf = (l) => {
|
|
1375
|
+
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1376
|
+
return existsSync(join(dir, '.git')) ? dir : l.repo;
|
|
1377
|
+
};
|
|
1129
1378
|
for (const l of [...live, ...removed])
|
|
1130
1379
|
for (const s of l.steps)
|
|
1131
1380
|
if (s.status === 'interrupted')
|
|
1132
|
-
countInterrupted(deps.claudeHome, l
|
|
1381
|
+
countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
|
|
1133
1382
|
wave.consumed += recovered.consumed;
|
|
1134
1383
|
wave.cacheRead += recovered.cacheRead;
|
|
1135
1384
|
if (recovered.consumed || recovered.cacheRead)
|
|
@@ -1145,6 +1394,17 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1145
1394
|
store.journal(`${k} → suspended — ${text}`);
|
|
1146
1395
|
io.out(`${k} → suspended — ${text}`);
|
|
1147
1396
|
}
|
|
1397
|
+
for (const l of integ) {
|
|
1398
|
+
const k = lotKey(l.project, l.lot);
|
|
1399
|
+
const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
|
|
1400
|
+
if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
|
|
1401
|
+
l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
|
|
1402
|
+
l.status = 'suspended';
|
|
1403
|
+
l.outcome = null;
|
|
1404
|
+
const text = planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
|
|
1405
|
+
store.journal(`${k} → suspended — ${text}`);
|
|
1406
|
+
io.out(`${k} → suspended — ${text}`);
|
|
1407
|
+
}
|
|
1148
1408
|
for (const l of live) {
|
|
1149
1409
|
if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
|
|
1150
1410
|
l.status = 'suspended';
|
|
@@ -1163,6 +1423,13 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1163
1423
|
l.outcome = DROPPED;
|
|
1164
1424
|
store.writeLot(l);
|
|
1165
1425
|
store.journal(`${k} → handed-back — ${DROPPED}`);
|
|
1426
|
+
// Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
|
|
1427
|
+
const branch = lotBranch(wave.id, l.project, l.lot);
|
|
1428
|
+
if (baseBranchOf(l.repo, branch)) {
|
|
1429
|
+
const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
|
|
1430
|
+
io.out(line);
|
|
1431
|
+
store.journal(line);
|
|
1432
|
+
}
|
|
1166
1433
|
}
|
|
1167
1434
|
if (live.length === 0) {
|
|
1168
1435
|
if (lots.every(lotFinished)) {
|
|
@@ -1171,6 +1438,8 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1171
1438
|
}
|
|
1172
1439
|
for (const line of renderTable(wave, lots))
|
|
1173
1440
|
io.out(line);
|
|
1441
|
+
for (const line of keptLines(lots.map((l) => l.repo)))
|
|
1442
|
+
io.out(line);
|
|
1174
1443
|
return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
|
|
1175
1444
|
}
|
|
1176
1445
|
return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
|