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:
parent
b4a0283c94
commit
a915b9d4b0
3 changed files with 303 additions and 152 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue