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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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