fix(execute): the multi-session path never force-deletes (green)

One invariant closes the data-loss class: Phase 2.55, 2.6 and 7.6 remove
worktrees only with `git worktree remove` without force and branches only
with `git branch -d`. Whatever git refuses (a worktree with changes, an
unmerged or checked-out branch) is kept, reported with what the user can
do, and never retried with force; every unmerged trek/{slug}/* branch
survives every failure path. Step 1 stops on a branch or worktree left
by an earlier attempt instead of deleting it. After a merged wave, a
progress file is dropped from its worktree only when untracked and
byte-identical to its archived copy, so the happy path still cleans up.

B1: Step 3 refuses to merge into a dirty main checkout, and tells a
refused merge (no MERGE_HEAD: stop, keep wave.json) from a real conflict
(abort, Step 5 keeps the branches).
B2: Phase 2.5, Check 4 and Step 0 take the main checkout from the first
`git worktree list` entry and refuse to run from a linked worktree.
B3: wave.json and merged-waves.json carry the plan's sha256 and refuse
another plan or version, naming the run that holds the orders dir and
the clear command; Step 0 stops on that. Step 6 clears the orders dir
when every wave is merged AND Step 4 passed, no longer keyed on the
Phase 8 label the wave path cannot reach.
Also: every session-orders.mjs call runs from the main checkout with a
main-relative plan path; Check 1 no longer counts .claude/trekplan-sessions/
as a dirty tree (it blocked every re-run after a failed wave); a stuck
wave has a documented non-force way out; Hard rule 12 states the
invariant. The test file only narrows one call-site filter that also
matched Step 0's plugin-root existence check.

npm test: 1273 tests, 1271 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 16:18:33 +02:00
commit a915b9d4b0
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 303 additions and 152 deletions

View file

@ -17,12 +17,16 @@
// (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
// wave.json and merged-waves.json carry the plan path and the sha256 of its
// content; status, record-merged and next-wave refuse another plan or another
// version of it (the slug is the file name, so plans can share a session dir).
// 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, copyFileSync, rmSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, dirname, basename, extname, isAbsolute } from 'node:path';
function sectionBody(text, heading) {
@ -138,6 +142,7 @@ export function buildWaveOrders({ strategy, wave, planPath, sessionDir }) {
`- depends on: ${s.dependsOn.length ? s.dependsOn.map(d => `session ${d}`).join(', ') : 'none'}`,
`- worktree: ${worktreePath}`,
`- open the session in: ${openIn}`,
`- progress file: ${progressFile}`,
'',
'## Starter',
'',
@ -159,13 +164,13 @@ export function buildWaveOrders({ strategy, wave, planPath, sessionDir }) {
});
}
export function writeWaveOrders({ strategy, wave, planPath, sessionDir, now = new Date() }) {
export function writeWaveOrders({ strategy, wave, planPath, planSha256, sessionDir, now = new Date() }) {
const orders = buildWaveOrders({ strategy, wave, planPath, sessionDir });
const dir = join(sessionDir, 'orders');
mkdirSync(dir, { recursive: true });
for (const o of orders) writeFileSync(join(dir, o.orderFile), o.body);
const state = {
wave, plan: planPath, sessions: orders.map(o => o.session),
wave, plan: planPath, plan_sha256: planSha256, sessions: orders.map(o => o.session),
status: 'awaiting-sessions', created_at: now.toISOString(),
};
writeFileSync(join(dir, 'wave.json'), JSON.stringify(state, null, 2) + '\n');
@ -183,7 +188,20 @@ export function classifyStatus(status) {
return ENDED_BAD.has(status) ? 'ended-bad' : 'running';
}
export function waveStatus({ strategy, wave, planPath, sessionDir }) {
// A run's state file must belong to this plan, in this version.
function samePlan(file, rec, planPath, planSha256, what) {
if (rec.plan === planPath && rec.plan_sha256 === planSha256) return;
const short = (h) => (typeof h === 'string' ? h.slice(0, 12) : 'none');
throw new Error(`${file} ${what} ${rec.plan} (plan sha256 ${short(rec.plan_sha256)}), `
+ `not ${planPath} (plan sha256 ${short(planSha256)})`);
}
export function waveStatus({ strategy, wave, planPath, planSha256, sessionDir }) {
const waveFile = join(sessionDir, 'orders', 'wave.json');
if (existsSync(waveFile)) {
samePlan(waveFile, JSON.parse(readFileSync(waveFile, 'utf-8')), planPath, planSha256,
'was written for another plan or another version of it:');
}
const members = strategy.sessions.filter(s => s.wave === wave);
if (members.length === 0) throw new Error(`no sessions in wave ${wave}`);
const sessions = members.map((s) => {
@ -221,26 +239,32 @@ export function archiveWaveProgress({ strategy, wave, planPath, sessionDir }) {
});
}
function mergedRecord(sessionDir, planPath) {
const file = join(sessionDir, 'orders', 'merged-waves.json');
function mergedRecord(sessionDir, planPath, planSha256) {
const dir = join(sessionDir, 'orders');
const file = join(dir, '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`);
try {
samePlan(file, rec, planPath, planSha256, 'belongs to');
} catch (err) {
throw new Error(`${dir} holds an unfinished multi-session run of ${rec.plan}: ${err.message}. `
+ `Finish that run, or clear it (session-orders.mjs clear --session-dir ${sessionDir}) and re-run.`);
}
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() };
export function recordMergedWave({ wave, planPath, planSha256, sessionDir, now = new Date() }) {
const { file, waves } = mergedRecord(sessionDir, planPath, planSha256);
const rec = { plan: planPath, plan_sha256: planSha256, 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);
export function nextWave({ strategy, planPath, planSha256, sessionDir }) {
const { waves } = mergedRecord(sessionDir, planPath, planSha256);
return strategy.waves.find(w => !waves.includes(w)) ?? null;
}
@ -285,8 +309,10 @@ if (import.meta.url === `file://${process.argv[1]}`) {
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 };
const planBytes = readFileSync(args.plan);
const strategy = parseExecutionStrategy(planBytes.toString('utf-8'));
const planSha256 = createHash('sha256').update(planBytes).digest('hex');
const opts = { strategy, wave: args.wave, planPath: args.plan, planSha256, sessionDir: args.sessionDir };
if (args.cmd === 'status') {
process.stdout.write(JSON.stringify(waveStatus(opts), null, 2) + '\n');
} else if (args.cmd === 'archive') {