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

@ -354,10 +354,13 @@ Determine how to execute this plan:
- All sessions are in different waves (no parallelism, but still separate sessions)
For single-session: continue to Phase 3.
For multi-session: if `.claude/trekplan-sessions/{slug}/orders/wave.json`
For multi-session: if
`$(git rev-parse --show-toplevel)/.claude/trekplan-sessions/{slug}/orders/wave.json`
exists, a wave is awaiting its sessions — skip Phase 2.55 (its clean-tree and
stale-worktree checks would destroy the live worktrees) and jump to Phase 2.6,
which collects the wave. Otherwise run Phase 2.55, then Phase 2.6.
which collects the wave. Otherwise run Phase 2.55, then Phase 2.6. The path is
anchored at the git toplevel, never the cwd: Phase 2.6 Step 0 writes it there,
and a run from a subdirectory must still see it.
Report:
```
@ -475,6 +478,20 @@ that were manually edited or have bugs.
### Check 4 — Stale worktree cleanup
First refuse to clean while a wave is awaiting its sessions — those worktrees
and branches are live or finished session work, not stale:
```bash
WAVE_JSON="$(git rev-parse --show-toplevel)/.claude/trekplan-sessions/{slug}/orders/wave.json"
if [ -f "$WAVE_JSON" ]; then
echo "Error: $WAVE_JSON exists - a wave is awaiting its sessions. Stale-worktree cleanup refused." >&2
exit 1
fi
```
If it exits 1, stop and report it: nothing is removed. Phase 2.5 should have
routed this run to Phase 2.6 (which collects the wave); re-run the same command.
Run `git worktree list`. If any worktrees with paths containing
`trekplan-sessions/{slug}/worktrees/` exist from a previous failed run:
@ -548,8 +565,16 @@ and `ORIGINAL_BRANCH` for the later steps. All paths must be absolute.
- `WAVE_IN_FLIGHT` → read the wave number `W` from `$ORDERS_DIR/wave.json` and
jump to **Step 3** (collect). Phase 2.55 was skipped for this run (Phase 2.5).
- `NO_WAVE` → prepare the first wave of the Execution Strategy: `W` = the lowest
wave number. Continue to Step 1.
- `NO_WAVE` → prepare the lowest wave not yet merged:
```bash
node "$VOYAGE_ROOT/lib/util/session-orders.mjs" next-wave \
--plan "{plan-path}" --session-dir "$SESSION_DIR"
```
A number is `W`: continue to Step 1. `none` means earlier runs merged every
wave: go to Step 4. The record it reads, `$ORDERS_DIR/merged-waves.json`, is
written by Step 3 after each merged wave, survives Step 5, and is cleared only
after a `completed` Phase 8 report (see Phase 8), so a re-run after a later
wave failed never prepares an already-merged wave again.
### Step 1 — Create worktrees for wave W
@ -672,13 +697,28 @@ node "$VOYAGE_ROOT/lib/util/session-orders.mjs" status \
```
The JSON names each session's progress file (the file `--session N` writes,
Phase 3) and its `status`: `completed`, `failed`, `stopped`, `pending` (no
progress file yet) or `unreadable`.
Phase 3), its raw `status`, and its `state`, exactly one of:
`completed`; `running` — no progress file yet, `pending`, `in-progress`,
`in_progress`, or any status not named here (an unknown status is never treated
as ended); `ended-bad` — `failed`, `stopped`, `partial`, `blocked` or
`unreadable`. `running` and `ended_bad` list the sessions in each state. Check
in this order:
- **Any `pending`:** the wave is still running. Report which sessions are
pending, point at their order files (`$ORDERS_DIR/wave-{W}-session-{N}.md`),
and stop. Do not merge, do not clean up.
- **Any `failed`, `stopped` or `unreadable`:**
- **Any `running` (checked FIRST):** a session is still open. Report which
sessions are still running, with their raw status, point at their order files
(`$ORDERS_DIR/wave-{W}-session-{N}.md`), and **stop** — even when another
session in the wave already ended badly. Touch no worktree, no branch and not
`wave.json`: no merge, no archive, no cleanup.
- No session is running. Before any worktree is removed (the failure branch's
Step 5 and the merge below both remove them), archive every session's
progress file:
```bash
node "$VOYAGE_ROOT/lib/util/session-orders.mjs" archive \
--plan "{plan-path}" --wave "{W}" --session-dir "$SESSION_DIR"
```
It copies each one to `$ORDERS_DIR/wave-{W}-session-{N}.progress.json`. Step 5
never deletes these copies; Step 6 and Phase 8 read them.
- **Any `ended-bad`:**
```
Wave {W} FAILED: Session {N} ended {status}.
Stopping — later waves depend on this wave.
@ -725,9 +765,16 @@ Aborting further merges. Sessions already merged in this wave are preserved.
Mark remaining sessions as "merge-failed". Jump to Step 5 (cleanup), then Phase 8.
**Remove worktrees for the merged wave:**
**Record the merged wave, then remove its worktrees:**
After successful merge of all sessions in the wave:
After successful merge of all sessions in the wave, record it (Step 0 reads the
record, so a later run never prepares this wave again):
```bash
node "$VOYAGE_ROOT/lib/util/session-orders.mjs" record-merged \
--plan "{plan-path}" --wave "{W}" --session-dir "$SESSION_DIR"
```
Then:
```bash
for each session N in the wave:
GIT_OPTIONAL_LOCKS=0 git worktree remove "$WORKTREE_DIR/session-{N}" --force
@ -739,9 +786,9 @@ rm -f "$ORDERS_DIR/wave.json"
Report: `Wave {W}: {N} sessions merged, worktrees cleaned up.`
**Next wave:** if the Execution Strategy has a later wave, set `W` to it and go
back to Step 1 — this run prepares that wave's orders and stops again. If wave
W was the last, continue to Step 4.
**Next wave:** run Step 0's `next-wave` command. A number is the next `W`: go
back to Step 1 — this run prepares that wave's orders and stops again. `none`
(every wave merged): continue to Step 4.
### Step 4 — Run master verification
@ -752,8 +799,10 @@ section commands to verify the integrated result.
This step MUST execute whenever this phase ends the run with a failure, a merge
conflict, or after the last wave — never while a wave is awaiting its sessions
(Step 2 and a `pending` Step 3 stop without it). It is the worktree equivalent
of a `finally` block.
(Step 2, and a Step 3 that finds a session `running`, stop without it). It is
the worktree equivalent of a `finally` block. From `$ORDERS_DIR` it deletes
only `wave.json`: the orders, the archived progress copies and the merged-waves
record stay until Phase 8 clears them.
```bash
cd "$REPO_ROOT"
@ -787,7 +836,9 @@ Warning: failed to remove worktree {path}. Manual cleanup:
### Step 6 — Aggregate results
Collect every session's progress file into an aggregated report. Jump to Phase 8.
Read every archived copy, `$ORDERS_DIR/wave-*-session-*.progress.json`, into an
aggregated report — never the worktree copies: Step 3 and Step 5 removed those
worktrees, earlier waves' in earlier runs. Jump to Phase 8.
### --session N mode
@ -1394,14 +1445,17 @@ Report the recovery spec as a session order and stop the run as `partial`:
```
Recovery order: session {N}, depth {depth}
Open a Claude Code session yourself in {WORKTREE_PATH} and give it:
Open a Claude Code session yourself in {REPO_ROOT} (the main checkout; the
session's worktree was removed when its wave merged) and give it:
/trekexecute --session {N} {recovery spec path}
When it is done, run this same /trekexecute command again: it re-runs the
Phase 7.5 audit.
That session runs the missing steps and its own Phase 7.5 audit.
```
On that later run, re-run the Phase 7.5 audit one more time. If it still
drifts at `recovery_depth=2`:
Nothing routes a later run of this command back to the recovery: the recovery
session's own audit is its check. It runs as a child, so if it still drifts it
stops `partial` and escalates to the user (the preconditions above) instead of
dispatching again. If Phase 7.5 drifts here while `recovery_depth` is already 2,
report instead:
```
RECOVERY EXHAUSTED: session {N} drifted after 2 recovery attempts.
@ -1648,6 +1702,21 @@ To resume: /trekexecute --resume {path}
The `trekexecute_summary` key makes it grep-able in a session's output.
**Multi-session orders directory (Phase 2.6).** On the wave path, per-session
results come from the archived copies `$ORDERS_DIR/wave-*-session-*.progress.json`
(Step 6). Only AFTER the report above is written, and only when its result is
`completed`, clear the plan's orders directory — orders, archived copies and the
merged-waves record — so a later run of a plan with the same slug starts clean:
```bash
node "$VOYAGE_ROOT/lib/util/session-orders.mjs" clear --session-dir "$SESSION_DIR"
```
It refuses while `wave.json` exists. Any other result keeps the directory: the
next run resumes at the lowest wave not yet merged (Step 0). Say so in the
report, and that running the `clear` command above starts the plan over from
its first wave instead.
## Phase 9 — Stats tracking
Append one record to `${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl`:
@ -1806,7 +1875,10 @@ code-path.
(Phase 8), always remove all worktrees and session branches created during
this execution, even if the run failed or was stopped. Leaked worktrees
consume disk space and block future runs. If automated cleanup fails,
report the manual cleanup commands in the final report.
report the manual cleanup commands in the final report. The one exception:
never while a wave awaits its sessions — a run that stops at Phase 2.6
Step 2, or at a Step 3 that finds a session `running`, writes no Phase 8
report and leaves every worktree and branch in place.
13. **Merge sequentially, abort on conflict.** After a parallel wave completes,
merge each session's branch into the main branch one at a time with

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 = [