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

@ -353,14 +353,28 @@ Determine how to execute this plan:
- Plan has `## Execution Strategy` with 2+ sessions
- All sessions are in different waves (no parallelism, but still separate sessions)
For single-session: continue to Phase 3.
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. 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.
For single-session (including every `--session N` run inside a session's
worktree): continue to Phase 3.
For multi-session, first locate the main checkout and the wave marker:
```bash
GIT_DIR_ABS="$(cd "$(git rev-parse --git-dir)" && pwd -P)"
GIT_COMMON_ABS="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
MAIN_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
if [ "$GIT_DIR_ABS" != "$GIT_COMMON_ABS" ]; then
echo "Error: this is a linked worktree. Run this from the main checkout $MAIN_ROOT." >&2
exit 1
fi
if [ -f "$MAIN_ROOT/.claude/trekplan-sessions/{slug}/orders/wave.json" ]; then echo "WAVE_IN_FLIGHT"; else echo "NO_WAVE"; fi
```
Exit 1 means this is a session's (or another) linked worktree: stop and report
the message — the multi-session path runs only from the main checkout, whose
root the first entry of `git worktree list` names (`--show-toplevel` would name
the linked worktree and miss the wave). `WAVE_IN_FLIGHT`: a wave is awaiting
its sessions — skip Phase 2.55 (its clean-tree and stale-worktree checks would
touch the live worktrees) and jump to Phase 2.6, which collects the wave.
`NO_WAVE`: run Phase 2.55, then Phase 2.6.
Report:
```
@ -374,7 +388,11 @@ catastrophic data loss that occurs when parallel sessions share a working direct
### Check 1 — Clean working tree
Run `git status --porcelain`. If there are ANY uncommitted or untracked changes:
Run `git status --porcelain -- ':(top,exclude).claude/trekplan-sessions/'`
(the whole repo, from any subdirectory, minus Voyage's own session state: orders,
archived progress and kept session worktrees are never merged, and counting them
would stop every re-run after a failed wave). If there are ANY uncommitted or
untracked changes:
```
Error: working tree is not clean. Parallel execution requires a clean git state.
@ -478,38 +496,50 @@ 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:
Clean up what an earlier run of this plan left under
`trekplan-sessions/{slug}/` — never with force. It refuses from a linked
worktree and while a wave is awaiting its sessions (those worktrees and branches
are live or finished session work, not stale). `git worktree remove` without
force refuses a worktree with modified or untracked files, and `git branch -d`
refuses an unmerged branch: both are kept, reported, and never retried with
force.
```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
GIT_DIR_ABS="$(cd "$(git rev-parse --git-dir)" && pwd -P)"
GIT_COMMON_ABS="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
MAIN_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
if [ "$GIT_DIR_ABS" != "$GIT_COMMON_ABS" ]; then
echo "Error: this is a linked worktree. Run this from the main checkout $MAIN_ROOT." >&2
exit 1
fi
SESSIONS="$MAIN_ROOT/.claude/trekplan-sessions/{slug}"
if [ -f "$SESSIONS/orders/wave.json" ]; then
echo "Error: $SESSIONS/orders/wave.json exists - a wave is awaiting its sessions. Stale-worktree cleanup refused." >&2
exit 1
fi
cd "$MAIN_ROOT" || exit 1
for wt in "$SESSIONS/worktrees"/session-*; do
[ -d "$wt" ] || continue
GIT_OPTIONAL_LOCKS=0 git worktree remove "$wt" 2>/dev/null || echo "KEPT worktree $wt"
done
GIT_OPTIONAL_LOCKS=0 git worktree prune
git for-each-ref --format='%(refname:short)' "refs/heads/trek/{slug}/" | while read -r b; do
GIT_OPTIONAL_LOCKS=0 git branch -d "$b" >/dev/null 2>&1 || echo "KEPT branch $b"
done
KEPT="$(ls -d "$SESSIONS/worktrees"/session-* 2>/dev/null; git for-each-ref --format='%(refname:short)' "refs/heads/trek/{slug}/")"
if [ -n "$KEPT" ]; then
echo "Earlier session work kept (changes or unmerged commits):" >&2
echo "$KEPT" >&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:
```bash
git worktree remove --force {stale-path} 2>/dev/null
git worktree prune
```
Also check for stale branches:
```bash
git branch --list "trek/{slug}/*" | while read b; do
git branch -D "$b" 2>/dev/null
done
```
Report: `Cleaned {N} stale worktrees and {N} branches from previous run.`
If cleanup fails, report the manual commands and stop.
Exit 0: report `Cleaned stale worktrees and merged branches from a previous run.`
Exit 1: stop and report its message. For every kept item say what the user can
do: a worktree — inspect it (`git -C <path> status --short`), commit or move
what matters, then `git worktree remove <path>`; a branch — inspect it
(`git log HEAD..<branch>`), merge it by hand, or delete it themselves once its
commits are not needed. Then re-run.
After all 4 checks pass:
```
@ -536,7 +566,24 @@ conditions, and repository destruction. Each order names its own worktree.
### Step 0 — Resolve paths and detect a wave in flight
```bash
REPO_ROOT="$(git rev-parse --show-toplevel)"
# The MAIN checkout, never a linked worktree (Phase 2.5): in a session's
# worktree --show-toplevel names that worktree.
GIT_DIR_ABS="$(cd "$(git rev-parse --git-dir)" && pwd -P)"
GIT_COMMON_ABS="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
REPO_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
if [ "$GIT_DIR_ABS" != "$GIT_COMMON_ABS" ]; then
echo "Error: this is a linked worktree. Run this from the main checkout $REPO_ROOT." >&2
exit 1
fi
# The plan path relative to the main checkout: every session-orders.mjs call
# runs from $REPO_ROOT with it, and each session finds the plan there in its
# own worktree.
PLAN_REL="$(python3 -c 'import os.path,sys; print(os.path.relpath(os.path.realpath(sys.argv[1]), os.path.realpath(sys.argv[2])))' "{plan-path}" "$REPO_ROOT")"
case "$PLAN_REL" in
""|..*)
echo "Error: the plan {plan-path} is not inside the main checkout $REPO_ROOT." >&2
exit 1 ;;
esac
SESSION_DIR="$REPO_ROOT/.claude/trekplan-sessions/{slug}"
WORKTREE_DIR="$SESSION_DIR/worktrees"
ORDERS_DIR="$SESSION_DIR/orders"
@ -560,21 +607,29 @@ fi
if [ -f "$ORDERS_DIR/wave.json" ]; then echo "WAVE_IN_FLIGHT"; else echo "NO_WAVE"; fi
```
Record `REPO_ROOT`, `SESSION_DIR`, `WORKTREE_DIR`, `ORDERS_DIR`, `VOYAGE_ROOT`
and `ORIGINAL_BRANCH` for the later steps. All paths must be absolute.
Record `REPO_ROOT`, `PLAN_REL`, `SESSION_DIR`, `WORKTREE_DIR`, `ORDERS_DIR`,
`VOYAGE_ROOT` and `ORIGINAL_BRANCH` for the later steps. All paths except
`PLAN_REL` 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 lowest wave not yet merged:
```bash
node "$VOYAGE_ROOT/lib/util/session-orders.mjs" next-wave \
--plan "{plan-path}" --session-dir "$SESSION_DIR"
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" next-wave --plan "$PLAN_REL" --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.
wave: go to Step 4. The record it reads, `$ORDERS_DIR/merged-waves.json`
(plan path + sha256 of the plan's content), is written by Step 3 after each
merged wave, survives Step 5, and is cleared by Step 6 only when every wave is
merged and Step 4 passed, so a re-run after a later wave failed never prepares
an already-merged wave again.
If `next-wave` exits 1, the record belongs to another plan or another version
of this one (`{slug}` is the plan's file name, so plans can share
`$SESSION_DIR`). Stop, touch nothing, and tell the user exactly what its
message says: an unfinished multi-session run of that plan holds
`$ORDERS_DIR`. Finish that run (re-run it with its own plan), or clear it and
re-run this one:
`(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" clear --session-dir "$SESSION_DIR")`.
### Step 1 — Create worktrees for wave W
@ -584,7 +639,28 @@ mkdir -p "$WORKTREE_DIR" "$ORDERS_DIR"
**2a. Create worktrees for this wave's sessions:**
For each session N in this wave:
First make sure no session of this wave left a branch or a worktree behind in
an earlier attempt — it may hold that session's work, so it is neither reused
nor deleted:
```bash
EXISTING=""
for N in {wave W session numbers}; do
if git rev-parse -q --verify "refs/heads/trek/{slug}/session-$N" >/dev/null || [ -e "$WORKTREE_DIR/session-$N" ]; then
EXISTING="$EXISTING trek/{slug}/session-$N"
fi
done
if [ -n "$EXISTING" ]; then
echo "Error: left by an earlier attempt:$EXISTING (branch or $WORKTREE_DIR/session-N)." >&2
exit 1
fi
```
If it exits 1, stop and report (no Step 5, no Phase 8; nothing was created or
deleted): name each branch and worktree, and what the user can do — inspect it
(`git log HEAD..<branch>`, `git -C <worktree> status --short`), merge it by
hand, or delete it themselves once its work is not needed — then re-run.
Then, for each session N in this wave:
```bash
BRANCH_NAME="trek/{slug}/session-{N}"
WORKTREE_PATH="$WORKTREE_DIR/session-{N}"
@ -594,13 +670,9 @@ GIT_OPTIONAL_LOCKS=0 git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" HEAD
`GIT_OPTIONAL_LOCKS=0` disables `.git/index.lock` background polling races
during parallel worktree operations (research/02 R2; GH #47721).
If `git worktree add` fails (e.g., branch exists from a crashed run):
```bash
GIT_OPTIONAL_LOCKS=0 git branch -D "$BRANCH_NAME" 2>/dev/null
GIT_OPTIONAL_LOCKS=0 git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" HEAD
```
If it still fails, report the error, mark this session as failed, and skip it.
If `git worktree add` fails, report its error and stop the wave: never delete a
branch to make room. Jump to Step 5 (it removes the worktrees this wave just
created, without force), then Phase 8.
Report:
```
@ -663,16 +735,16 @@ Source: brief Constraint 2.
### Step 2 — Write the session orders for wave W, then stop
```bash
node "$VOYAGE_ROOT/lib/util/session-orders.mjs" write \
--plan "{plan-path}" --wave "{W}" --session-dir "$SESSION_DIR"
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" write --plan "$PLAN_REL" --wave "{W}" --session-dir "$SESSION_DIR")
```
`{plan-path}` is repo-relative (the tool refuses an absolute path): each
session resolves it inside its own worktree, which has the same repo content
from HEAD. The tool writes `$ORDERS_DIR/wave-{W}-session-{N}.md` per session
and `$ORDERS_DIR/wave.json` (`status: awaiting-sessions`), and prints, per
session, the directory to open and the starter line
(`/trekexecute --session {N} {plan-path}`). A session's per-session `cwd:`
`$PLAN_REL` is relative to the main checkout (Step 0; the tool refuses an
absolute path): each session resolves it inside its own worktree, which has the
same repo content from HEAD. The tool writes `$ORDERS_DIR/wave-{W}-session-{N}.md`
per session (naming its worktree, directory and progress file) and
`$ORDERS_DIR/wave.json` (`status: awaiting-sessions`, the plan path and the
sha256 of its content), and prints, per session, the directory to open and the
starter line (`/trekexecute --session {N} $PLAN_REL`). A session's per-session `cwd:`
field in the Execution Strategy moves the directory it opens in to that
subdirectory of its worktree (plugin-in-monorepo layouts, brief Constraint 1,
Option B); default `.` is the worktree root.
@ -692,10 +764,13 @@ the orders are the hand-over.
### Step 3 — Collect wave W (a later run)
```bash
node "$VOYAGE_ROOT/lib/util/session-orders.mjs" status \
--plan "{plan-path}" --wave "{W}" --session-dir "$SESSION_DIR"
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" status --plan "$PLAN_REL" --wave "{W}" --session-dir "$SESSION_DIR")
```
If it exits 1, `wave.json` was written for another plan or another version of
this one: stop and report its message, touching nothing. Re-run with the plan
(version) that wrote the wave, or abandon the wave (the way out below).
The JSON names each session's progress file (the file `--session N` writes,
Phase 3), its raw `status`, and its `state`, exactly one of:
`completed`; `running` — no progress file yet, `pending`, `in-progress`,
@ -709,12 +784,16 @@ in this order:
(`$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.
**Way out of a stuck wave** (a session the user decided not to run): the user
deletes `$ORDERS_DIR/wave.json` and re-runs. That run takes the fresh path,
and Phase 2.55 Check 4 removes the wave's clean worktrees and merged branches
without force and stops on everything that holds work, listing it to merge by
hand or delete.
- 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"
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" archive --plan "$PLAN_REL" --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.
@ -724,67 +803,98 @@ in this order:
Stopping — later waves depend on this wave.
See its order: {ORDERS_DIR}/wave-{W}-session-{N}.md
```
Do NOT merge. Do NOT prepare later waves. Jump to Step 5 (cleanup), then Phase 8.
Do NOT merge. Do NOT prepare later waves. Jump to Step 5 (cleanup; it keeps
every unmerged branch and every worktree git refuses to remove), then Phase 8.
- **`all_completed: true`:** if `gates_mode` is true, pause for operator
confirmation before merge-back. Then continue with the merge.
**Merge session branches back (SEQUENTIAL, one at a time):**
Return to the main worktree:
Return to the main worktree and make sure it is clean. While the sessions ran
the user may have kept working here, and Phase 2.55 (its Check 1) did not run
for this collect:
```bash
cd "$REPO_ROOT"
cd "$REPO_ROOT" || exit 1
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: uncommitted changes in $REPO_ROOT. Commit or stash your changes in $REPO_ROOT, then re-run." >&2
exit 1
fi
```
If it exits 1, stop and report its message: no merge, no cleanup, and keep
`wave.json`, so the re-run collects this wave again.
For each session N in the wave (in order), push BEFORE merge so the wave
branch survives even if cleanup races ahead (research/02 R3 — converts an
unrecoverable failure to a recoverable one). Push failure is non-fatal
(no remote, offline, etc.):
```bash
GIT_OPTIONAL_LOCKS=0 git push origin "trek/{slug}/session-{N}" 2>/dev/null || true
GIT_OPTIONAL_LOCKS=0 git merge --no-ff "trek/{slug}/session-{N}" \
-m "merge: trekplan session {N} — {session-title}"
if GIT_OPTIONAL_LOCKS=0 git merge --no-ff "trek/{slug}/session-{N}" \
-m "merge: trekplan session {N} — {session-title}"; then
echo "MERGED session {N}"
elif git rev-parse -q --verify MERGE_HEAD >/dev/null; then
git diff --name-only --diff-filter=U
git merge --abort
echo "MERGE_CONFLICT session {N}"
exit 1
else
echo "MERGE_REFUSED session {N}"
exit 1
fi
```
If the merge succeeds (exit code 0): continue to next session.
- `MERGED`: continue to the next session.
- `MERGE_REFUSED`: git refused to start the merge (no `MERGE_HEAD`; its message
names the local changes or untracked files in the way). Stop and report
`commit, stash or move your changes in {REPO_ROOT}, then re-run`: no Step 5, and
keep `wave.json`. Sessions merged before it in this wave stay merged; the
re-run merges them again as no-ops.
- `MERGE_CONFLICT`: a real conflict, already aborted. Report:
```
Wave {W} MERGE CONFLICT: Session {N} branch conflicts with merged state.
Conflicting files:
{the files the block printed}
If the merge fails (conflict):
```bash
CONFLICTS="$(git diff --name-only --diff-filter=U)"
git merge --abort
```
Report:
```
Wave {W} MERGE CONFLICT: Session {N} branch conflicts with merged state.
Conflicting files:
{CONFLICTS}
Session {N} order: {ORDERS_DIR}/wave-{W}-session-{N}.md
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.
Session {N} order: {ORDERS_DIR}/wave-{W}-session-{N}.md
Aborting further merges. Sessions already merged in this wave are preserved.
```
Mark remaining sessions as "merge-failed". Jump to Step 5 (it keeps every
unmerged branch), then Phase 8.
**Record the merged wave, then remove its worktrees:**
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"
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" record-merged --plan "$PLAN_REL" --wave "{W}" --session-dir "$SESSION_DIR")
```
Then:
Then drop the wave marker and remove each session's worktree and merged branch,
never with force. A progress file still in a worktree is deleted only when it is
untracked and byte-identical to its archived copy (the archive is the record);
anything else in a worktree makes git refuse, and that worktree — and the branch
checked out in it — is kept and reported:
```bash
for each session N in the wave:
GIT_OPTIONAL_LOCKS=0 git worktree remove "$WORKTREE_DIR/session-{N}" --force
GIT_OPTIONAL_LOCKS=0 git branch -d "trek/{slug}/session-{N}"
cd "$REPO_ROOT" || exit 1
rm -f "$ORDERS_DIR/wave.json"
for N in {wave W session numbers}; do
WT="$WORKTREE_DIR/session-$N"
ARCHIVED="$ORDERS_DIR/wave-{W}-session-$N.progress.json"
P="$(sed -n 's/^- progress file: //p' "$ORDERS_DIR/wave-{W}-session-$N.md")"
if [ -n "$P" ] && [ -f "$P" ] && cmp -s "$ARCHIVED" "$P" \
&& ! git -C "$WT" ls-files --error-unmatch "$P" >/dev/null 2>&1; then
rm -f "$P"
fi
GIT_OPTIONAL_LOCKS=0 git worktree remove "$WT" 2>/dev/null || echo "KEPT worktree $WT"
GIT_OPTIONAL_LOCKS=0 git branch -d "trek/{slug}/session-$N" >/dev/null 2>&1 || echo "KEPT branch trek/{slug}/session-$N"
done
GIT_OPTIONAL_LOCKS=0 git worktree prune
rm -f "$ORDERS_DIR/wave.json"
```
Report: `Wave {W}: {N} sessions merged, worktrees cleaned up.`
Report: `Wave {W}: {N} sessions merged.` plus every `KEPT` line, each with what
the user can do (inspect it with `git -C <path> status --short`, commit or move
what matters, then `git worktree remove <path>` and `git branch -d <branch>`).
**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`
@ -799,46 +909,63 @@ 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 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.
(Step 2, and a Step 3 that finds a session `running` or a dirty or refused
merge, stop without it). It is the worktree equivalent of a `finally` block, and
it never forces: `git worktree remove` without force refuses a worktree with
modified or untracked files, `git branch -d` refuses an unmerged branch, and
whatever git refuses is kept and reported, never retried with force. From
`$ORDERS_DIR` it deletes only `wave.json`: the orders, the archived progress
copies and the merged-waves record stay until Step 6 clears them.
```bash
cd "$REPO_ROOT"
cd "$REPO_ROOT" || exit 1
# Remove any remaining worktrees (GIT_OPTIONAL_LOCKS=0 to avoid lock-file races)
# Remove worktrees git agrees are clean (GIT_OPTIONAL_LOCKS=0 to avoid lock-file races)
for wt in "$WORKTREE_DIR"/session-*; do
[ -d "$wt" ] && GIT_OPTIONAL_LOCKS=0 git worktree remove "$wt" --force 2>/dev/null
[ -d "$wt" ] || continue
GIT_OPTIONAL_LOCKS=0 git worktree remove "$wt" 2>/dev/null || echo "KEPT worktree $wt"
done
GIT_OPTIONAL_LOCKS=0 git worktree prune
# Remove session branches
GIT_OPTIONAL_LOCKS=0 git branch --list "trek/{slug}/*" | while read branch; do
GIT_OPTIONAL_LOCKS=0 git branch -D "$branch" 2>/dev/null
# Delete merged session branches; every unmerged one is kept
git for-each-ref --format='%(refname:short)' "refs/heads/trek/{slug}/" | while read -r b; do
GIT_OPTIONAL_LOCKS=0 git branch -d "$b" >/dev/null 2>&1 || echo "KEPT branch $b"
done
# Clean up empty directories and the wave marker
# Clean up the wave marker and an empty worktree directory
rm -f "$ORDERS_DIR/wave.json"
rmdir "$WORKTREE_DIR" 2>/dev/null
rmdir "$WORKTREE_DIR" 2>/dev/null || true
```
Report:
```
Cleanup: {N} worktrees removed, {N} branches deleted.
Cleanup: worktrees removed and merged branches deleted.
Kept (they hold work): {every KEPT line}
```
If cleanup fails for any worktree, report but do not fail:
```
Warning: failed to remove worktree {path}. Manual cleanup:
git worktree remove {path} --force && git worktree prune
```
For every kept item, the report says what the user can do: a worktree —
inspect it (`git -C <path> status --short`), commit or move what matters, then
`git worktree remove <path>`; a branch — inspect it (`git log HEAD..<branch>`),
merge it by hand (`git merge --no-ff <branch>`), or delete it themselves once
its commits are not needed. A re-run stops at Phase 2.55 Check 4 until they are
dealt with.
### Step 6 — Aggregate results
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.
aggregated report — never the worktree copies, which Steps 3 and 5 remove with
their worktrees (earlier waves' in earlier runs).
Then clear the orders directory — orders, archived copies and the merged-waves
record — only when `next-wave` said `none` (every wave merged) AND
Step 4 passed, so a later plan with the same `{slug}` starts clean:
```bash
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" clear --session-dir "$SESSION_DIR")
```
On any other outcome keep it: the next run resumes at the lowest wave not yet
merged (Step 0), or re-runs Step 4 when every wave is merged. Jump to Phase 8.
### --session N mode
@ -1445,8 +1572,8 @@ 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 {REPO_ROOT} (the main checkout; the
session's worktree was removed when its wave merged) and give it:
Open a Claude Code session yourself in {REPO_ROOT} (the main checkout, which
holds the session's merged work) and give it:
/trekexecute --session {N} {recovery spec path}
That session runs the missing steps and its own Phase 7.5 audit.
```
@ -1702,20 +1829,13 @@ 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.
**Multi-session path (Phase 2.6).** Per-session results come from Step 6's
aggregate of the archived copies `$ORDERS_DIR/wave-*-session-*.progress.json`.
The report lists every worktree and `trek/{slug}/*` branch Steps 3 and 5 kept,
with what the user can do (Step 5). If Step 6 kept the orders directory (not
every wave merged, or Step 4 did not pass), say so: the next run resumes at the
lowest wave not yet merged, and the `clear` command in Step 0 starts the plan
over from its first wave instead.
## Phase 9 — Stats tracking
@ -1871,14 +1991,18 @@ code-path.
in the same working directory. This rule has no exceptions.
Sequential (single-session) execution does not require worktrees.
12. **Worktree cleanup is unconditional.** Before producing the final report
(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. 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.
12. **Worktree cleanup always runs, and never with force.** Before producing
the final report (Phase 8), remove the worktrees and merged session
branches created during this execution, even if the run failed or was
stopped — but only with `git worktree remove` without force and
`git branch -d`. A worktree git refuses to remove or a branch it refuses to
delete holds work: keep it, never retry with force, and list it in the
final report with what the user can do (Phase 2.6 Step 5). Every unmerged
`trek/{slug}/*` branch survives every failure path. The one exception to
running cleanup: 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` or a
dirty or refused merge, 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

@ -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') {

View file

@ -662,7 +662,8 @@ test('Step 3 after a merged wave: records it, drops only archived untracked prog
});
test('every session-orders.mjs call runs from the main checkout with the main-relative plan path', () => {
const calls = EXEC.replace(/\\\n\s*/g, ' ').split('\n').filter(l => l.includes('lib/util/session-orders.mjs"'));
const calls = EXEC.replace(/\\\n\s*/g, ' ').split('\n')
.filter(l => /lib\/util\/session-orders\.mjs" (write|status|archive|record-merged|next-wave|clear)\b/.test(l));
assert.ok(calls.length >= 7, `expected the write/status/archive/record-merged/next-wave/clear calls, saw ${calls.length}`);
for (const l of calls) {
assert.match(l, /\(cd "\$REPO_ROOT" && node "\$VOYAGE_ROOT\/lib\/util\/session-orders\.mjs"/, l);