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:
parent
2c0ce8b9ab
commit
dbc07b4006
2 changed files with 193 additions and 31 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue