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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue