Skip to content

Commit ff11626

Browse files
johanzanderclaude
andcommitted
fix: stop the fleet prune from destroying the worktrees it cannot remove
`git worktree remove` is sandbox-denied, and unlike `git worktree add` it fails DESTRUCTIVELY. Removal deletes the working tree first and only then unlinks `.git/worktrees/<name>` -- and that unlink is the denied one: error: failed to delete '.../worktrees/backlogger': Operation not permitted error: failed to delete '.git/worktrees/backlogger': Operation not permitted By then ~393 tracked files are gone. It does not roll back. What is left is a carcass: a registered worktree whose `git status` is a few hundred ` D` lines and nothing else. Both prune loops read that as "uncommitted tracked changes" and correctly refuse to auto-delete it -- so the failure makes the worktree permanently unprunable BY ITSELF. Re-running hits the no-`--force` refusal; `--force` re-hits the denial. `git worktree prune` performs the same unlink, so it cannot clear the wreckage either. 13 carcasses accumulated across three sweeps (#568, #596, #597, #600, #601, #603, #609, #612, #617, #629, #633, #634, #641) before anyone read the diff. The last sweep reported them back as "a real backlog of stranded edits worth reviewing" -- they were its own wreckage from the previous runs, and not one byte of real work was in them. Because the filename set is identical in every worktree, so is APFS's readdir order, so every carcass loses the SAME ~393 paths (`core/`, `frontend/`, `bess_manager/`, `pyproject.toml`, ...). Identical damage across many worktrees is the signature, not a coincidence. - Both prune loops now report `PRUNE` and emit one `!`-prefixed command for the maintainer to run unsandboxed, instead of removing anything themselves. - Both classify a dirty set that is entirely ` D` as `CARCASS`, distinct from real edits. The predicate anchors on `^ D ` (unstaged deletions only), so a STAGED deletion still reads as intentional work. - `local-agent-environment.md` gains the `remove`/`prune` half of the `.git/worktrees` denial, next to the `add` half it already documented. `verify-sandbox.sh:88` predicted this in a parenthetical ("a stray directory when `git worktree remove` then fails"); nothing acted on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FQA38o22RkHZJ4zEHDZS3
1 parent dd47d59 commit ff11626

3 files changed

Lines changed: 96 additions & 15 deletions

File tree

.claude/skills/implement-issue/SKILL.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -250,18 +250,35 @@ git worktree list | awk 'NR>1 {print $1}' | while read -r wt; do
250250
b=$(git -C "$wt" branch --show-current 2>/dev/null)
251251
[ -n "$b" ] || continue # detached: leave alone
252252
echo "$merged" | grep -qx "$b" || continue # not merged: leave alone
253-
if [ -n "$(git -C "$wt" status --porcelain -uno)" ]; then # tracked edits: never auto-delete
254-
echo "KEEP (uncommitted changes): $wt"; continue
253+
dirty=$(git -C "$wt" status --porcelain -uno)
254+
if [ -n "$dirty" ]; then # tracked edits: never auto-delete
255+
if [ -z "$(printf '%s\n' "$dirty" | grep -v '^ D ')" ]; then
256+
echo "CARCASS (failed prune, deletions only): $wt" # see below -- not real edits
257+
else
258+
echo "KEEP (uncommitted changes): $wt"
259+
fi
260+
continue
255261
fi
256-
git worktree remove "$wt" && git branch -D "$b"
262+
echo "PRUNE: $wt ($b)" # report; do NOT remove here
257263
done
258264
git fetch origin --prune
259265
```
260266

261-
Two guards that matter: never remove a worktree with **uncommitted tracked
267+
**Report the `PRUNE` list; do not act on it from here.** `git worktree remove`
268+
is sandbox-denied — it deletes the working tree *first* and then fails on the
269+
`.git/worktrees/<name>` unlink, destroying ~393 tracked files and leaving a
270+
carcass that no later prune can clear (`git worktree prune` is denied too).
271+
Emit one `!`-prefixed command covering every `PRUNE` and `CARCASS` for the
272+
maintainer to paste, exactly as `sweep-prs` Step 3 does. See
273+
`docs/agents/local-agent-environment.md`, "git worktree remove is denied too".
274+
275+
Three guards that matter: never remove a worktree with **uncommitted tracked
262276
changes** — report it and let a human decide (one such worktree held a
263-
375-line module that existed nowhere else) — and never touch a **detached or
264-
locked** worktree, which is usually another agent's live session.
277+
375-line module that existed nowhere else); never touch a **detached or
278+
locked** worktree, which is usually another agent's live session; and never
279+
mistake a **carcass** for either. A dirty set that is *entirely* ` D` lines is
280+
this bug's own wreckage, not someone's work — 13 of them accumulated before
281+
anyone read the diff.
265282

266283
Then `git fetch origin main``using-git-worktrees`' git fallback branches
267284
from the current local `HEAD`, not `origin/main`, so a stale local checkout
@@ -780,8 +797,11 @@ net is upstream, not this section.
780797
something's wrong.
781798

782799
2. Remove the worktree — via `ExitWorktree action=remove discard_changes=true`
783-
if the session is still in it, or `git worktree remove <path>` from the
784-
main repo root for a `.worktrees/`-created one.
800+
if the session is still in it. That is the harness doing it, so it is not
801+
sandboxed and it works. If the session has already left, **hand the
802+
`git worktree remove --force <path>` to the maintainer to paste with `!`**
803+
rather than running it: from a sandboxed Bash it half-deletes the worktree
804+
and then fails (see Step 4).
785805

786806
3. Force-delete the local branch and prune stale remote refs:
787807

.claude/skills/sweep-prs/SKILL.md

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,18 @@ git worktree list | awk 'NR>1 {print $1}' | while read -r wt; do
9696
if echo "$owned" | grep -qF "$wt"; then
9797
echo "SKIP (live session): $b"; continue
9898
fi
99-
if [ -n "$(git -C "$wt" status --porcelain -uno)" ]; then
100-
echo "SKIP (uncommitted changes): $b"; continue
99+
dirty=$(git -C "$wt" status --porcelain -uno)
100+
if [ -n "$dirty" ]; then
101+
# A worktree whose dirty set is ENTIRELY deletions is not someone's work
102+
# in progress -- it is a carcass left by a `git worktree remove` that the
103+
# sandbox killed halfway (see below). Say so, or the sweep reports its own
104+
# wreckage back as a backlog of stranded edits.
105+
if [ -z "$(printf '%s\n' "$dirty" | grep -v '^ D ')" ]; then
106+
echo "CARCASS (failed prune, $(printf '%s\n' "$dirty" | grep -c .) deletions): $b"
107+
else
108+
echo "SKIP (uncommitted changes): $b"
109+
fi
110+
continue
101111
fi
102112
if echo "$merged" | grep -qx "$b"; then
103113
echo "PRUNE: $b"; continue
@@ -124,15 +134,37 @@ done
124134

125135
### 3. Act
126136

127-
**`PRUNE`** — the PR merged:
137+
**`PRUNE`** — the PR merged. **Do not run `git worktree remove` yourself.**
138+
The sandbox denies the `.git/worktrees/<name>` unlink that removal ends with,
139+
and removal deletes the working tree *before* it gets there, so a run from
140+
here does not fail cleanly — it destroys ~393 tracked files and leaves a
141+
carcass that can never be pruned again. `git worktree prune` is denied for the
142+
same reason, so there is no in-sandbox recovery either. This is not a
143+
hypothetical: three sweeps did exactly that to 13 worktrees before it was
144+
diagnosed. See `docs/agents/local-agent-environment.md`, "git worktree remove
145+
is denied too".
146+
147+
Collect every `PRUNE` and `CARCASS` branch instead, and emit **one** command
148+
for the maintainer to paste with a `!` prefix, which runs unsandboxed:
128149

129150
```bash
130-
git worktree remove "$wt" && git branch -D "$b"
151+
# Emit this; do not execute it.
152+
cd /Users/johanzander/GitHub/bess-manager && for wt in <names>; do
153+
b=$(git -C ".claude/worktrees/$wt" symbolic-ref --short HEAD 2>/dev/null)
154+
git worktree remove --force ".claude/worktrees/$wt" && git branch -D "$b"
155+
done; git worktree list | wc -l
131156
```
132157

133-
Force-delete is expected, not a warning sign: squash-merge means the
134-
branch's commits never become reachable from `main`, so `git branch -d`'s
135-
ancestry check always refuses.
158+
`--force` is required for a `CARCASS` (its own damage reads as uncommitted
159+
changes) and harmless for a clean `PRUNE`. Force-deleting the *branch* is
160+
expected too, and is a separate thing: squash-merge means the branch's commits
161+
never become reachable from `main`, so `git branch -d`'s ancestry check always
162+
refuses.
163+
164+
Before listing a `CARCASS`, confirm its branch is genuinely spent — the PR
165+
merged, and any commits past the merged head are already in `origin/main`.
166+
A carcass has no recoverable working-tree content by definition (deletions
167+
only), but the *branch* may still hold commits that never landed.
136168

137169
**`OPEN`** — in report-only mode (the default), print the row from the table
138170
below that this PR matches and take no action. In `--all` mode, or when the

docs/agents/local-agent-environment.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,35 @@ primitive for writes (reads have one, which is why `allowRead` differs), so no
326326
- **Create worktrees with `EnterWorktree`, never `git worktree add` from Bash**
327327
the harness is not sandboxed; the Bash form writes `.git/config` and
328328
`.git/worktrees`, both denied. *(measured)*
329+
- **`git worktree remove` is denied too, and unlike `add` it fails
330+
DESTRUCTIVELY.** Removal deletes the working tree *first*, then unlinks
331+
`.git/worktrees/<name>` — and that unlink is the denied one:
332+
333+
```
334+
error: failed to delete '.../.claude/worktrees/backlogger': Operation not permitted
335+
error: failed to delete '.git/worktrees/backlogger': Operation not permitted
336+
```
337+
338+
By then it has already deleted several hundred tracked files. It does not
339+
roll back. What is left is a **carcass**: a registered worktree whose
340+
`git status` is a few hundred ` D` lines and nothing else. That reads as
341+
"uncommitted changes" to every later prune, so the worktree is now
342+
permanently unprunable *by the failure itself* — re-running the removal
343+
hits the no-`--force` refusal instead, and `--force` re-hits the denial.
344+
345+
Because the filename set is identical in every worktree, so is APFS's
346+
readdir order, so every carcass loses the **same** ~393 paths (`core/`,
347+
`frontend/`, `bess_manager/`, `pyproject.toml`, `Dockerfile`, …). Identical
348+
damage across many worktrees is the signature — do not read it as a
349+
coincidence or as real edits. *(measured — 13 carcasses accumulated over
350+
three sweeps before anyone noticed)*
351+
352+
`git worktree prune` performs the same `.git/worktrees/<name>` unlink and is
353+
denied for the same reason, so it cannot clear the wreckage either. **There
354+
is no in-sandbox path to removing a worktree.** It has to run unsandboxed —
355+
the maintainer pastes it with a `!` prefix, or the harness does it via
356+
`ExitWorktree` (which only ever covers the session's own `EnterWorktree`
357+
worktree, not a pre-existing one).
329358
- **`git checkout -b <branch> origin/<branch>` fails**, because recording the
330359
upstream writes `.git/config` — and it fails *after* creating the branch, so
331360
the branch exists while the command reports an error and leaves you on the

0 commit comments

Comments
 (0)