fix: make worktree cleanup fail closed (#3385)

* fix: make worktree cleanup fail closed

* chore: add changeset for worktree cleanup safety

* fix: address worktree cleanup review findings

* docs: label remove-workspace failure block

* fix: initialize worktree manifest before dispatch
This commit is contained in:
Tom Boucher
2026-05-10 19:48:28 -04:00
committed by GitHub
parent a51fc86a18
commit 570dddd1cd
13 changed files with 750 additions and 73 deletions

View File

@@ -519,6 +519,11 @@ increases monotonically across waves. `{status}` is `complete` (success),
EXPECTED_BASE=$(git rev-parse HEAD)
DISPATCH_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
EXPECTED_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "${USE_WORKTREES_FOR_PLAN:-true}" != "false" ] && [ -z "${WAVE_WORKTREE_MANIFEST:-}" ]; then
WAVE_WORKTREE_MANIFEST=$(mktemp "${TMPDIR:-/tmp}/gsd-worktree-wave-XXXXXX.json")
printf '{"worktrees":[]}\n' > "$WAVE_WORKTREE_MANIFEST"
export WAVE_WORKTREE_MANIFEST
fi
```
**Sequential dispatch for parallel execution (waves with 2+ agents):**
@@ -636,6 +641,8 @@ increases monotonically across waves. `{status}` is `complete` (success),
)
```
Immediately after each worktree `Agent()` spawn returns metadata, atomically append `{agent_id, worktree_path, branch, expected_base}` to `WAVE_WORKTREE_MANIFEST`. If any field is missing, stop and ask for recovery instead of scanning all agent worktrees.
> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above to spawn executor agent(s), stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.
**Sequential mode** (`USE_WORKTREES_FOR_PLAN` is `false` — either project-level `USE_WORKTREES=false`, or per-plan submodule intersection forced it false in step 2.5):
@@ -727,35 +734,37 @@ increases monotonically across waves. `{status}` is `complete` (success),
When executor agents ran in worktree isolation, their commits land on temporary branches in separate working trees. After the wave completes, merge these changes back and clean up:
**Manifest source of truth (#3384):** Cleanup consumes the `WAVE_WORKTREE_MANIFEST` created and populated during executor dispatch in step 3. Do not recreate or truncate it here.
Prefer the bounded helper, which validates branch identity, expected base, deletion
diffs, merge result, and worktree removal before deleting the temporary branch.
If the helper reports a blocked cleanup, resolve the reported manifest entry and
rerun the same command. Do not fall back to broad worktree discovery.
```bash
# List worktrees created by this wave's agents.
# Inclusion-based filter (#2774): match ONLY agent-spawned worktrees under
# `.claude/worktrees/agent-` (the namespace Claude Code's `isolation="worktree"`
# uses). The previous exclusion filter (`grep -v "$(pwd)$"`) destroyed the parent
# workspace's `.git` whenever the workspace itself was a worktree (multi-workspace
# setups, and the cross-drive Windows case where `git worktree list` reports the
# registry path on a different drive than `$(pwd)`).
# Read line-by-line so worktree paths containing whitespace are preserved (#2774).
[ -n "${WAVE_WORKTREE_MANIFEST:-}" ] && [ -f "$WAVE_WORKTREE_MANIFEST" ] || {
echo "BLOCKED: missing WAVE_WORKTREE_MANIFEST; refusing broad worktree cleanup (#3384)." >&2
exit 1
}
if command -v gsd-sdk >/dev/null 2>&1; then
gsd-sdk query worktree.cleanup-wave --manifest "$WAVE_WORKTREE_MANIFEST" || exit 1
else
echo "WARN: gsd-sdk unavailable; using manifest-scoped shell fallback (#3384)." >&2
WT_PATHS_FILE=$(mktemp "${TMPDIR:-/tmp}/gsd-worktree-paths-XXXXXX")
node -e 'const fs=require("fs");const p=process.env.WAVE_WORKTREE_MANIFEST;try{if(!p)throw new Error("WAVE_WORKTREE_MANIFEST is unset");if(!fs.existsSync(p))throw new Error("manifest does not exist");const s=fs.readFileSync(p,"utf8");if(!s.trim())throw new Error("manifest is empty");const j=JSON.parse(s);for(const w of j.worktrees||[])if(w.worktree_path)console.log(w.worktree_path)}catch(e){console.error(`ERROR: cannot read worktree manifest ${p||"(unset)"}: ${e.message}`);process.exit(1)}' > "$WT_PATHS_FILE" || { echo "BLOCKED: cannot read WAVE_WORKTREE_MANIFEST; refusing cleanup (#3384)." >&2; exit 1; }
while IFS= read -r WT; do
[ -z "$WT" ] && continue
# Get the branch name for this worktree
WT_BRANCH=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null)
if [ -n "$WT_BRANCH" ] && [ "$WT_BRANCH" != "HEAD" ]; then
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
# --- Orchestrator file protection (#1756) ---
# Snapshot orchestrator-owned files BEFORE merge. If the worktree
# branch outlived a milestone transition, its versions of STATE.md
# and ROADMAP.md are stale. Main always wins for these files.
STATE_BACKUP=$(mktemp)
ROADMAP_BACKUP=$(mktemp)
[ -f .planning/STATE.md ] && cp .planning/STATE.md "$STATE_BACKUP" || true
[ -f .planning/ROADMAP.md ] && cp .planning/ROADMAP.md "$ROADMAP_BACKUP" || true
# Snapshot list of files on main BEFORE merge to detect resurrections
PRE_MERGE_FILES=$(git ls-files .planning/)
# Pre-merge deletion check: warn if the worktree branch deletes tracked files
DELETIONS=$(git diff --diff-filter=D --name-only HEAD..."$WT_BRANCH" 2>/dev/null || true)
if [ -n "$DELETIONS" ]; then
echo "BLOCKED: Worktree branch $WT_BRANCH contains file deletions: $DELETIONS"
@@ -764,7 +773,6 @@ increases monotonically across waves. `{status}` is `complete` (success),
continue
fi
# Merge the worktree branch into the current branch (--no-ff ensures a merge commit so HEAD~1 is reliable)
git merge "$WT_BRANCH" --no-ff --no-edit -m "chore: merge executor worktree ($WT_BRANCH)" 2>&1 || {
echo "⚠ Merge conflict from worktree $WT_BRANCH — resolve manually"
echo " STATE.md backup: $STATE_BACKUP"
@@ -773,10 +781,6 @@ increases monotonically across waves. `{status}` is `complete` (success),
break
}
# Post-merge deletion audit: detect bulk file deletions in merge commit (#2384)
# --diff-filter=D HEAD~1 HEAD shows files deleted by the merge commit itself.
# Exclude .planning/ — orchestrator-owned deletions there are expected (resurrections
# are handled below). Require ALLOW_BULK_DELETE=1 to bypass for intentional large refactors.
MERGE_DEL_COUNT=$(git diff --diff-filter=D --name-only HEAD~1 HEAD 2>/dev/null | grep -vc '^\.planning/' || true)
if [ "$MERGE_DEL_COUNT" -gt 5 ] && [ "${ALLOW_BULK_DELETE:-0}" != "1" ]; then
MERGE_DELETIONS=$(git diff --diff-filter=D --name-only HEAD~1 HEAD 2>/dev/null | grep -v '^\.planning/' || true)
@@ -788,7 +792,6 @@ increases monotonically across waves. `{status}` is `complete` (success),
continue
fi
# Restore orchestrator-owned files (main always wins)
if [ -s "$STATE_BACKUP" ]; then
cp "$STATE_BACKUP" .planning/STATE.md
fi
@@ -797,25 +800,17 @@ increases monotonically across waves. `{status}` is `complete` (success),
fi
rm -f "$STATE_BACKUP" "$ROADMAP_BACKUP"
# Detect files deleted on main but re-added by worktree merge
# (e.g., archived phase directories that were intentionally removed)
# A "resurrected" file must have a deletion event in main's ancestry —
# brand-new files (e.g. SUMMARY.md just created by the executor) have no
# such history and must NOT be removed (#2501).
# Detect files deleted on main but re-added by worktree merge (#2501).
DELETED_FILES=$(git diff --diff-filter=A --name-only HEAD~1 -- .planning/ 2>/dev/null || true)
for RESURRECTED in $DELETED_FILES; do
# Only delete if this file was previously tracked on main and then
# deliberately removed (has a deletion event in git history).
WAS_DELETED=$(git log --follow --diff-filter=D --name-only --format="" HEAD~1 -- "$RESURRECTED" 2>/dev/null | grep -c . || true)
if [ "${WAS_DELETED:-0}" -gt 0 ]; then
git rm -f "$RESURRECTED" 2>/dev/null || true
fi
done
# Amend merge commit with restored files if any changed
if ! git diff --quiet .planning/STATE.md .planning/ROADMAP.md 2>/dev/null || \
[ -n "$DELETED_FILES" ]; then
# Only amend the commit with .planning/ files if commit_docs is enabled (#1783)
COMMIT_DOCS=$(gsd-sdk query config-get commit_docs 2>/dev/null || echo "true")
if [ "$COMMIT_DOCS" != "false" ]; then
git add .planning/STATE.md .planning/ROADMAP.md 2>/dev/null || true
@@ -824,10 +819,6 @@ increases monotonically across waves. `{status}` is `complete` (success),
fi
# Safety net: rescue uncommitted SUMMARY.md before worktree removal (#2070, #2838).
# Filesystem-level (find + cp) bypasses git's --exclude-standard filter, which silently
# drops .planning/SUMMARY.md when projects gitignore .planning/ — the rescue's prior
# `git ls-files --exclude-standard` form returned empty in that case and the SUMMARY
# was lost on `git worktree remove --force`.
while IFS= read -r SUMMARY; do
[ -z "$SUMMARY" ] && continue
REL_PATH="${SUMMARY#$WT/}"
@@ -838,13 +829,17 @@ increases monotonically across waves. `{status}` is `complete` (success),
fi
done < <(find "$WT/.planning" -name "*SUMMARY.md" 2>/dev/null)
# Remove the worktree
if ! git worktree remove "$WT" --force; then
REMOVE_OK=false
if git worktree remove "$WT" --force; then
REMOVE_OK=true
else
WT_NAME=$(basename "$WT")
if [ -f ".git/worktrees/${WT_NAME}/locked" ]; then
echo "⚠ Worktree $WT is locked — attempting to unlock and retry"
git worktree unlock "$WT" 2>/dev/null || true
if ! git worktree remove "$WT" --force; then
if git worktree remove "$WT" --force; then
REMOVE_OK=true
else
echo "⚠ Residual worktree at $WT — manual cleanup required after session exits:"
echo " git worktree unlock \"$WT\" && git worktree remove \"$WT\" --force && git branch -D \"$WT_BRANCH\""
fi
@@ -853,22 +848,25 @@ increases monotonically across waves. `{status}` is `complete` (success),
fi
fi
# Delete the temporary branch
git branch -D "$WT_BRANCH" 2>/dev/null || true
if [ "$REMOVE_OK" = "true" ]; then
git branch -D "$WT_BRANCH" 2>/dev/null || true
else
echo "⚠ Keeping branch $WT_BRANCH because worktree removal failed (#3384)"
fi
fi
done < <(git worktree list --porcelain | grep "^worktree " | grep "\.claude/worktrees/agent-" | sed 's/^worktree //')
done < "$WT_PATHS_FILE"
fi
```
**Cleanup-tail snippet (use after any wave whose merges did not flow through the templated path above):**
If the orchestrator deviated from the standard wave merge path (e.g., custom inter-worktree base-update merges with `merge: bring …` style messages), run this snippet after the custom merges are complete. It discovers and removes any residual `worktree-agent-*` worktrees. Safe to run when no residuals exist — it is a no-op in that case.
If the orchestrator deviated from the standard wave merge path (e.g., custom inter-worktree base-update merges with `merge: bring …` style messages), run this snippet after the custom merges are complete. It reads only `WAVE_WORKTREE_MANIFEST`; do not discover unrelated `worktree-agent-*` worktrees.
```bash
# Cleanup-tail: remove residual agent worktrees after a cross-wave-dependency deviation.
# Inclusion-based filter (#2774): match ONLY agent-spawned worktrees under
# `.claude/worktrees/agent-`. Do NOT use exclusion filters (grep -v "$(pwd)$") —
# they destroy the parent workspace's .git in multi-workspace or cross-drive setups.
# Read line-by-line so worktree paths containing whitespace are preserved (#2774).
# Uses only the current wave manifest to avoid touching unrelated active agents (#3384).
WT_PATHS_FILE=$(mktemp "${TMPDIR:-/tmp}/gsd-worktree-paths-XXXXXX")
node -e 'const fs=require("fs");const p=process.env.WAVE_WORKTREE_MANIFEST;try{if(!p)throw new Error("WAVE_WORKTREE_MANIFEST is unset");if(!fs.existsSync(p))throw new Error("manifest does not exist");const s=fs.readFileSync(p,"utf8");if(!s.trim())throw new Error("manifest is empty");const j=JSON.parse(s);for(const w of j.worktrees||[])if(w.worktree_path)console.log(w.worktree_path)}catch(e){console.error(`ERROR: cannot read worktree manifest ${p||"(unset)"}: ${e.message}`);process.exit(1)}' > "$WT_PATHS_FILE" || { echo "BLOCKED: cannot read WAVE_WORKTREE_MANIFEST; refusing cleanup (#3384)." >&2; exit 1; }
while IFS= read -r WT; do
[ -z "$WT" ] && continue
WT_BRANCH=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null)
@@ -886,7 +884,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
else
git branch -D "$WT_BRANCH" 2>/dev/null || true
fi
done < <(git worktree list --porcelain | grep "^worktree " | grep "\.claude/worktrees/agent-" | sed 's/^worktree //')
done < "$WT_PATHS_FILE"
git worktree prune
```