fix(execute): never tear down a running or merged wave; keep progress past cleanup (green)

Seven defects from an independent review of the 6.0 session-order flow,
all verified against the text before fixing:

1. Step 3 branched only on pending/failed/stopped/unreadable, so a session
   still writing in-progress (or in_progress) next to a failed one had its
   live worktree force-removed. status now classifies each session as
   completed, running (no file, pending, in-progress, in_progress, or any
   unknown status) or ended-bad (failed, stopped, partial, blocked,
   unreadable), keeping the raw status; Step 3 stops on any running FIRST
   and touches no worktree, branch or wave.json.
2. The Phase 2.5 wave-in-flight guard was cwd-relative; from a subdirectory
   it missed wave.json and Check 4 force-removed the wave. The guard is now
   anchored at the git toplevel, and Check 4 opens with a block that refuses
   to clean while wave.json exists.
3. Step 6 read progress files from worktrees that Step 3/5 had removed. A
   new archive subcommand copies each to orders/wave-W-session-N.progress.json
   before any worktree goes; Step 6 and Phase 8 read the copies.
4. NO_WAVE always prepared the lowest wave, so a re-run after a wave-2
   failure re-ran wave 1. record-merged writes orders/merged-waves.json
   after each merged wave (Step 5 keeps it); next-wave returns the lowest
   unmerged wave or none (then Step 4). Phase 8 clears the orders dir only
   after a completed report; the record refuses another plan's path.
5. Phase 7.6 named a deleted worktree and promised an unwired re-run. The
   order now names the main checkout and says the recovery session's own
   audit is the check.
6. (test side, previous commit) progress paths are asserted literally.
7. Hard rule 12 now carries the awaiting-sessions exception.

npm test: 1262 tests, 1260 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-02 12:09:12 +02:00
commit dbc07b4006
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 193 additions and 31 deletions

View file

@ -10,9 +10,19 @@
// writes <dir>/orders/wave-<W>-session-<N>.md + <dir>/orders/wave.json
// and prints the starters
// status --plan <repo-relative plan> --wave <W> --session-dir <dir>
// prints JSON: each session's progress-file status, all_completed
// prints JSON: each session's raw progress-file status and its state
// (completed | running | ended-bad), running, ended_bad, all_completed
// archive --plan <repo-relative plan> --wave <W> --session-dir <dir>
// copies each progress file to <dir>/orders/wave-<W>-session-<N>.progress.json
// (the worktree copy goes with the worktree)
// record-merged --plan <repo-relative plan> --wave <W> --session-dir <dir>
// adds W to <dir>/orders/merged-waves.json
// next-wave --plan <repo-relative plan> --session-dir <dir>
// prints the lowest wave not yet merged, or `none`
// clear --session-dir <dir>
// removes <dir>/orders (refused while wave.json exists)
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'node:fs';
import { join, dirname, basename, extname, isAbsolute } from 'node:path';
function sectionBody(text, heading) {
@ -162,6 +172,17 @@ export function writeWaveOrders({ strategy, wave, planPath, sessionDir, now = ne
return { dir, orders, state };
}
// A session that has not ended must never be treated as ended: an unknown
// status counts as running, so a collect run waits instead of tearing down a
// live worktree.
const ENDED_BAD = new Set(['failed', 'stopped', 'partial', 'blocked', 'unreadable']);
/** @returns {'completed'|'running'|'ended-bad'} */
export function classifyStatus(status) {
if (status === 'completed') return 'completed';
return ENDED_BAD.has(status) ? 'ended-bad' : 'running';
}
export function waveStatus({ strategy, wave, planPath, sessionDir }) {
const members = strategy.sessions.filter(s => s.wave === wave);
if (members.length === 0) throw new Error(`no sessions in wave ${wave}`);
@ -175,9 +196,60 @@ export function waveStatus({ strategy, wave, planPath, sessionDir }) {
status = 'unreadable';
}
}
return { session: s.n, status, progressFile };
return { session: s.n, status, state: classifyStatus(status), progressFile };
});
return { wave, sessions, all_completed: sessions.every(s => s.status === 'completed') };
return {
wave, sessions,
running: sessions.filter(s => s.state === 'running').map(s => s.session),
ended_bad: sessions.filter(s => s.state === 'ended-bad').map(s => s.session),
all_completed: sessions.every(s => s.state === 'completed'),
};
}
/** Copy each session's progress file out of its worktree, into the orders dir. */
export function archiveWaveProgress({ strategy, wave, planPath, sessionDir }) {
const members = strategy.sessions.filter(s => s.wave === wave);
if (members.length === 0) throw new Error(`no sessions in wave ${wave}`);
const dir = join(sessionDir, 'orders');
mkdirSync(dir, { recursive: true });
return members.map((s) => {
const from = progressFileFor({ strategy, n: s.n, planPath, sessionDir });
if (!existsSync(from)) return { session: s.n, from, to: null };
const to = join(dir, `wave-${wave}-session-${s.n}.progress.json`);
copyFileSync(from, to);
return { session: s.n, from, to };
});
}
function mergedRecord(sessionDir, planPath) {
const file = join(sessionDir, 'orders', 'merged-waves.json');
if (!existsSync(file)) return { file, waves: [] };
const rec = JSON.parse(readFileSync(file, 'utf-8'));
if (rec.plan !== planPath) throw new Error(`${file} belongs to ${rec.plan}, not ${planPath}; finish that plan or clear its orders first`);
return { file, waves: rec.waves };
}
/** Remember that `wave` is merged; survives Step 5, cleared by `clear`. */
export function recordMergedWave({ wave, planPath, sessionDir, now = new Date() }) {
const { file, waves } = mergedRecord(sessionDir, planPath);
const rec = { plan: planPath, waves: [...new Set([...waves, wave])].sort((a, b) => a - b), updated_at: now.toISOString() };
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(rec, null, 2) + '\n');
return rec;
}
/** The lowest wave of the plan not yet merged, or null when every wave is. */
export function nextWave({ strategy, planPath, sessionDir }) {
const { waves } = mergedRecord(sessionDir, planPath);
return strategy.waves.find(w => !waves.includes(w)) ?? null;
}
/** Remove the orders dir (orders, archived progress, merged record). */
export function clearOrders({ sessionDir }) {
const dir = join(sessionDir, 'orders');
if (existsSync(join(dir, 'wave.json'))) throw new Error(`${join(dir, 'wave.json')} exists: a wave is awaiting its sessions, nothing cleared`);
rmSync(dir, { recursive: true, force: true });
return { cleared: dir };
}
// ---- CLI shim ----------------------------------------------------------------
@ -193,18 +265,36 @@ function parseArgs(argv) {
return out;
}
const NEEDS = {
write: ['plan', 'wave'], status: ['plan', 'wave'], archive: ['plan', 'wave'],
'record-merged': ['plan', 'wave'], 'next-wave': ['plan'], clear: [],
};
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseArgs(process.argv.slice(2));
const usage = 'usage: session-orders.mjs write|status --plan <repo-relative plan> --wave <W> --session-dir <dir>';
if (!['write', 'status'].includes(args.cmd) || !args.plan || !Number.isInteger(args.wave) || !args.sessionDir) {
const usage = 'usage: session-orders.mjs write|status|archive|record-merged --plan <repo-relative plan> --wave <W> --session-dir <dir>\n'
+ ' session-orders.mjs next-wave --plan <repo-relative plan> --session-dir <dir>\n'
+ ' session-orders.mjs clear --session-dir <dir>';
const needs = NEEDS[args.cmd];
if (!needs || !args.sessionDir || (needs.includes('plan') && !args.plan) || (needs.includes('wave') && !Number.isInteger(args.wave))) {
process.stderr.write(usage + '\n');
process.exit(1);
}
try {
if (args.cmd === 'clear') {
process.stdout.write(JSON.stringify(clearOrders({ sessionDir: args.sessionDir })) + '\n');
process.exit(0);
}
const strategy = parseExecutionStrategy(readFileSync(args.plan, 'utf-8'));
const opts = { strategy, wave: args.wave, planPath: args.plan, sessionDir: args.sessionDir };
if (args.cmd === 'status') {
process.stdout.write(JSON.stringify(waveStatus(opts), null, 2) + '\n');
} else if (args.cmd === 'archive') {
process.stdout.write(JSON.stringify(archiveWaveProgress(opts), null, 2) + '\n');
} else if (args.cmd === 'record-merged') {
process.stdout.write(JSON.stringify(recordMergedWave(opts), null, 2) + '\n');
} else if (args.cmd === 'next-wave') {
process.stdout.write(String(nextWave(opts) ?? 'none') + '\n');
} else {
const { dir, orders } = writeWaveOrders(opts);
const lines = [