Repository navigation
fix(#4941): reap-orphans names .claude/worktrees residue git has forgotten, and reports what it scanned - #5233
behruznassre wants to merge 40 commits into
Conversation
…has forgotten, and reports what it scanned
reapOrphanWorktrees discovered candidates only from .git/worktrees/, so a
harness worktree whose teardown did not complete — no .git file, admin
entry already pruned — was invisible, and the sweep answered
{ok:true, reaped:0, entries:[]} with the directory still on disk.
A second discovery source reads <repo>/.claude/worktrees/ and NAMES each
child that is a real directory (never a symlink), has no .git entry, is
not in `git worktree list`, and is older than the existing mtime guard —
as a `skipped` / `unregistered_residue` row, which also reaches the
existing "N orphan(s) skipped" warning. It never deletes: a missing .git
proves nothing about ownership or cleanliness (a user's own directory, a
dirty worktree whose metadata was removed, a directory another session is
about to `git worktree add` into all look the same), so disposal stays an
operator decision. The discovery root must resolve inside the checkout.
The CLI output gains `scan`, naming which sources were actually read, so
reaped:0 is distinguishable from "did not look". The admin-dir path and
its fail-closed bail-outs are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Full local suite on the PR head (Node 24.19.0, macOS, Both failures reproduce identically on a clean
|
trek-e
left a comment
There was a problem hiding this comment.
Review — fix(#4941): reap-orphans names .claude/worktrees residue git has forgotten, and reports what it scanned
Head reviewed: c87a758
Summary
Adds a report-only second discovery source (findUnregisteredResidue) to reapOrphanWorktrees and a scan field to worktree.reap-orphans output. Residue is named as skipped / unregistered_residue; nothing is deleted. Root cause addressed: candidates were derived only from .git/worktrees/, and an absent admin dir returned an all-clear.
Classification & gate compliance
- Track: bug fix; #4941 is open with
confirmed-bug.Fixes #4941present. Fix template used. Not a draft. Title conventional, branchfix/4941-...valid. - Changeset present, type
Fixed,pr: 5233. Body lacks the required bold lead (finding m1). - Docs gate:
Fixedis exempt. CHANGELOG.md untouched. No INVENTORY surface touched. - Single concern. One merge commit of
nextin history. - CI (fresh, head above): 44 check-runs, none failing or pending; 0 action_required runs; Stryker jobs SKIPPED. behind_by 0. mergeStateStatus BLOCKED / REVIEW_REQUIRED (review pending).
Root-cause verdict
Root-cause fix. The reaper's only discovery source was the admin dir, which is gone for the target class. The new source plus scan also removes the "could not look" = "nothing there" conflation. Report-only is consistent with the issue's stated minimum.
Functional checklist (issue #4941)
- Backstop names the directory: met (no-admin-dir, beside-live, CLI rows).
reaped: 0distinguishable from did-not-look: met (scan,scan: nullon throw).- Misleading
rollbackPartialWorktreecomment corrected: met. - Safety: lstat (symlink never a candidate),
.gitprobe ENOENT-specific, discovery root realpath-contained, git calls bounded, no deletion. Fail-closed rows verified by reading.
Blast radius
codegraph_impact on reapOrphanWorktrees: 4 affected symbols (itself, cmdWorktreeReapOrphans, two test files). Low risk. New export reapOrphanWorktreesWithScan; existing signature unchanged; CLI JSON change is additive.
Co-change completeness
Generated conformance-tier lists updated for the new tier file. No docs/INVENTORY/CONTEXT.md change required for a Fixed change. Workflows that call the command (quick.md, quick-batch.md, executor-isolation-dispatch.md) discard stderr and ignore content; no edits needed.
CodeGraph Evidence
codegraph_impacttargetreapOrphanWorktrees: 4 affected symbols, tests + cmd wrapper only.codegraph_callerstargetcmdWorktreeReapOrphans: test file only (CLI dispatch is dynamic).codegraph_nodetargetreapOrphanWorktrees(src/worktree-safety.cts:2485): confirms base signature(repoRoot, deps) => ReapResult[]retained by the wrapper.
Memtrace unavailable: monthly query quota exhausted (20972/20933, resets 2026-10-20)
Findings
Minor (judgment):
- m1
.changeset/plucky-eagles-chatter.md:5- body must open with the bold lead per.changeset/README.md:29(**<user-visible change>** — <symptom>.). Fix: bold the first clause. - m2
tests/worktree-safety-reap.test.cjs(#4941 block) - mtime guard has noguardMs-1 / guardMs / guardMs+1rows (only "now" and STALE). Add them usingreapMtimeGuardMs+nowMsdeps. - m3 same file -
absent,unreadable,not_main_checkoutstatuses and theresidue_unreadablereason (non-ENOENT readdir/lstat) are part of the documentedscancontract and untested; Stryker was skipped on this head so there is no mutation evidence. - m4
src/worktree-safety.cts(cmdWorktreeReapOrphans warning) - stderr says "run with DEBUG=1 for details" but no DEBUG path exists in this module for these rows, and the three workflow callers discard stderr. Residue is reported forever with no disposal path. Maintainer decision (opt-in removal flag is the PR's own proposed follow-up); at minimum drop or implement the DEBUG hint.
Verdict
Request changes (minor findings m1-m3 to fix; m4 for maintainer decision). No blockers; CI green at head c87a758, behind_by 0 (no rebase needed).
…can status Review findings m1-m4: - changeset body opens with the bold lead per .changeset/README.md - residue mtime guard covered at guardMs-1 / guardMs / guardMs+1 - absent, unreadable and not_main_checkout scan statuses and the non-ENOENT residue_unreadable rows (child and .git probe) now tested; each fails under a mutation of the compiled module - the skipped-count stderr line no longer points at a DEBUG=1 path that does not exist; it points at the JSON "entries" that carry the rows Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e residue scan Codex review: with a live sibling keeping .git/worktrees/ present, a repo whose remote has no origin/HEAD failed the reap closed and also skipped the report-only residue scan, which does not depend on the default branch. The three default_branch_unresolved exits now run the residue sweep; the reap itself stays fail-closed, and only an unresolvable git dir skips the scan. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for the review. Addressed in
Self-found (Codex whole-PR review), fixed in Verified on the new head (macOS, Node 24.19.0, |
trek-e
left a comment
There was a problem hiding this comment.
Automated maintainer re-review of head b7a2ae62b1. Zero-tolerance policy: every finding at any severity must be resolved before approval.
Review — fix(#4941): reap-orphans names .claude/worktrees residue git has forgotten, and reports what it scanned
Head reviewed: b7a2ae6 (fresh gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BLOCKED, reviewDecision=CHANGES_REQUESTED). Compare API vs origin/next: ahead 7, behind 0. 44 check-runs: 39 SUCCESS, 5 SKIPPED, 0 failing/pending.
Note: PR is Bug-Fix track, issue #4941 labels bug, confirmed-bug. PR carries only review: changes requested label.
Untrusted-data note: shared scratchpad file names collided with a concurrent reviewer once; all data below was re-fetched into unique files.
Summary
Report-only second discovery source (findUnregisteredResidue) for reapOrphanWorktrees plus a scan field on worktree.reap-orphans output. No deletion. Prior review (c87a758) found no blockers; m1-m3 fixed, m4 partly. Substance remains sound, but the PR departs from the Agent Brief (reap) and adds a second porcelain parser. mergeStateStatus BLOCKED is solely the standing CHANGES_REQUESTED review (behind_by 0, mergeable, CI all green); no conflict, no rebase needed.
Prior findings status (trek-e 2026-10-07, head c87a758)
| # | Finding | Status | Evidence |
|---|---|---|---|
| m1 | changeset body bold lead | RESOLVED | .changeset/plucky-eagles-chatter.md:5 starts **...** + —, ends (#4941), type Fixed, pr 5233 |
| m2 | mtime guard guardMs-1/guardMs/guardMs+1 | RESOLVED | tests/worktree-safety-reap.test.cjs "a residue aged guardMs..." loop, [-1,0,1], injected nowMs/reapMtimeGuardMs/mtimeSafe, no wall clock; src guard nowMs - mtime < guardMs matches |
| m3 | untested statuses absent/unreadable/not_main_checkout/residue_unreadable | RESOLVED | rows for absent, unreadable (ENOTDIR), not_main_checkout (linked worktree), child + .git EACCES lstat, age-unknown, worktree_list_failed, outside_checkout, symlink child, bail. Stryker still SKIPPED on head: no mutation evidence (see F4) |
| m4 | stderr "DEBUG=1" hint false; residue reported forever, no disposal path | PARTIAL | hint fixed (see "entries" in the JSON output, pinned by test). Disposal/visibility decision NOT made: author declined opt-in removal; callers quick.md, quick-batch.md, executor-isolation-dispatch.md still discard stderr and ignore content, so residue is invisible in automated runs (F1) |
Acceptance checklist (issue #4941 "Expected" + Agent Brief, verbatim intent)
- Second discovery source reading
.claude/worktrees/, child not ingit worktree listand no.git: MET (src findUnregisteredResidue; tests no-admin, beside-live). - Preserve admin-dir path and lock/PID/ancestry guards unchanged: MET (admin loop untouched; only early
return resultsreplaced bysweepResidue/bail). - "treat ... as reapable residue", guarded delete, no rm -rf: NOT MET by design. PR names only, deletes nothing. Issue "Expected" allows "names the directory", but the Agent Brief says reapable and the measured cost (21 orphans / 531 MB, each with node_modules) is not reduced. Needs maintainer ruling in chat (F1).
reaped: 0distinguishable from did-not-look: MET (scan{admin_dir,residue_dir};scan:nullon throw; tests).- Failing-first regression test for no-.git/no-admin-entry residue: MET in form (PR body reports per-mutation red; not executed here, no local test runs permitted).
- Correct rollbackPartialWorktree doc comment: MET (src ~2168-2176), wording awkward (see N1).
Findings
Blocker: none.
Major
- F1 [judgment] src/worktree-safety.cts findUnregisteredResidue / .changeset: Agent Brief and issue title require the backstop to reap; PR is report-only and the three auto-run callers discard stderr and ignore stdout content, so the residue is neither disposed nor surfaced anywhere an operator sees it; 531 MB-class growth persists. This was prior m4 "maintainer decision" and is still undecided. Fix: maintainer confirms report-only in chat, or add the opt-in removal verb / surface entries to callers. Do not treat as resolved by the author's follow-up offer.
- F2 [judgment] src/worktree-safety.cts findUnregisteredResidue (git worktree list block, ~L2810-2818 of head): hand-parses
git worktree list --porcelain(line.startsWith('worktree ')) and issues a secondworktree listcall, while the same function already builds this index at step 3 (canonicalToListed, ~L2551-2567) andparseWorktreeEntries/parseWorktreePorcelainexist as the module's parser (CONTEXT.md:268). One owner per concept: this is a third parser beside two existing ones; also a new parser with no fast-check property (CLAUDE.md "Parsers ... must include at least one fast-check"). Note step 5worktree pruneruns between the two listings, so the stale step-3 index cannot be reused as-is; fix by routing throughparseWorktreeEntrieson the fresh listing (removes the hand parser) or add a discriminating fast-check property.
Minor
- F3 [judgment] findUnregisteredResidue returns rows in
readdirSyncorder (filesystem dependent);entriesJSON is nondeterministic across macOS/Linux/Windows. Fix:names.sort(). - F4 [judgment] No mutation evidence: Stryker SKIPPED on head (prior m3 note); author reports manual mutation reds only. RULESET: Stryker 80% threshold. Surface for maintainer; not executed here.
- F5 [judgment]
admin_dir/residue_dirstatus vocabulary and the new reasonsresidue_unreadable,residue_age_unknown,worktree_list_failed,unregistered_residueare documented only in code comments; CLI JSON contract has no doc/CONTEXT.md entry (CONTEXT.md:268 does not list reapOrphanWorktrees at all).Fixedtype is docs-exempt; add one CONTEXT.md sentence if the maintainer treatsscanas new vocabulary. - F6 [judgment]
not_main_checkoutis also returned for a main checkout using--separate-git-dir/ non-.gitbasename (srcpath.basename(gitDirPath) !== '.git'); status label is then misleading. Fix: document or resolve top viarev-parse --show-toplevel.
Nit
- N1 [judgment] rollbackPartialWorktree comment sentence "Anything else ... is not reached by that sweep. The result is intentionally ignored and a throw is" has a line break mid-clause (~L2171-2175); reflow.
Security / robustness (checked, no finding)
- No deletion anywhere in the new path; cannot touch a live worktree: candidates require lstat-directory (symlink/junction excluded),
.gitENOENT, not in freshworktree list(realpath-normalized both sides), mtime guard, discovery root realpath contained in checkout top (isContainedIn). Failures fail to namedskippedrows, never a clean verdict. Output rows are data only (JSON); stderr prints counts only (no injection surface).execGitis the bounded default (DEFAULT_GIT_TIMEOUT_MS); no argv growth; fs reads are single readdir/lstat per child. Path scope is<top>/.claude/worktreesonly. - TOCTOU irrelevant for report-only.
Test quality
Behavioral (real git repos, faulty-deps injection), asserts on-disk survival (fs.existsSync, file contents) not only JSON, boundary triple present, no wall-clock (nowMs injected; deadOwnerDeps stubs mtimeSafe), no source-grep. Controls assert specific verdicts. Gaps: no fast-check (F2); residue_dir unreadable via realpath failure branch and worktree list partial-registration race untested; Windows junction rows only verified by CI matrix (green).
Blast radius
codegraph_impact reapOrphanWorktrees: 4 affected symbols (itself, cmdWorktreeReapOrphans, tests/worktree-safety-reap.test.cjs, tests/worktree-safety.test.cjs). codegraph_callers cmdWorktreeReapOrphans: test file only (CLI dispatch dynamic). Export surface: additive reapOrphanWorktreesWithScan; reapOrphanWorktrees signature unchanged; CLI JSON gains scan (additive). Risk low. codegraph index is main-checkout (not PR head): lower bound. Memtrace quota exhausted, not run.
Co-change gaps
- Changeset present (Fixed, pr 5233), CHANGELOG untouched, no docs/ required (Fixed), no references/workflows files added (INVENTORY n/a), no shipped workflow growth (no Emitted-Drift-Ack-Growth). Conformance-tier generated lists updated for tests/worktree-safety-reap.test.cjs (both lists). CONTEXT.md: see F5.
- PR format: branch fix/4941-reap-unregistered-residue OK, title conventional,
Fixes #4941, not draft, Fix template used. History has 3Merge branch 'next'commits (c87a758, 629f9f7, b7a2ae6); no conventional-title violation on authored commits.
Behind-by / rebase
behind_by 0 vs origin/next; MERGEABLE; BLOCKED = CHANGES_REQUESTED review decision only (no failing/pending check, no action_required runs). No rebase required.
Verdict
Changes requested. Prior m1-m3 resolved, m4 partial. Open: F1 (Major, maintainer ruling: report-only vs reap / visibility), F2 (Major, parallel porcelain parser + no property test), F3-F6 minor, N1 nit. CI green at b7a2ae6.
Memtrace Evidence
Memtrace quota was exhausted for this review run, so the graph-backed checks were NOT executed; this is stated rather than reported as passed:
get_impact: not run (quota). Blast radius above comes from codegraph where present.get_symbol_context: not run (quota). Callers/callees were read via codegraph where present.recall_decision: not run (quota). The recorded-decision gate was NOT evaluated; this is not a finding that no decision governs the change.find_code_review_issues: not run (quota). The deterministic AST/YAML/cross-module pass did not execute; findings above are judgment-sourced unless marked deterministic.
Findings above Nit are open, so no further CI is being spent on this head. Address them; the fix push re-triggers CI once.
Review round on PR open-gsd#5233: - name each unregistered_residue path on stderr; quick, quick-batch and executor-isolation-dispatch now keep stderr and discard the JSON they never read, so the residue reaches the operator (F1, option c) - route both worktree listings through parseWorktreeEntries instead of hand-parsed porcelain (F2) - sort residue names so entries are filesystem-independent (F3) - a --separate-git-dir main checkout is asked of git instead of being labeled not_main_checkout; git failure there is top_unresolved (F6) - document the verb's scan vocabulary in CONTEXT.md (F5); reflow the rollbackPartialWorktree comment (N1) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codex review (three minors): - residue paths are JSON-quoted on stderr; a directory name with a newline or escape sequence could forge a [gsd] line or drive the terminal (the JSON keeps the exact name) - the `<top>/.git` shortcut also requires the cwd inside `<top>`, so a --separate-git-dir that is itself named `.git` scans the checkout, not the metadata directory's parent - a residue location that could not be read is reported on stderr, and the skipped-orphans hint names the command, since the workflow callers discard the JSON Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… the git dir Codex round 2: the `<top>/.git` fast path still picked the wrong checkout for separate metadata in the checkout's parent (/project/.git beside /project/checkout) and missed its residue. The git dir's location says nothing reliable about the checkout's, so the shortcut is removed: the residue scan always asks git for the common dir and the top (the path both review rounds already covered). The admin-dir argv pin now includes those two calls. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sweep The startup orphan sweep's call line changes from `2>/dev/null` to `>/dev/null` identically in all three callers so the residue paths on stderr reach the operator. Row 48 read the quick-batch.md copy as quick-batch phase work and then charged the quick.md copy. Recognize exactly that line, old or new form, as a mechanical sweep, alongside the existing open-gsd#3730/open-gsd#2529/open-gsd#4834/open-gsd#4780 carve-outs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Addressed the 2026-10-09 review. Head is now F1 (Major), residue neither disposed of nor shown to anyone: kept report-only. It now reaches the operator.
F2 (Major), third porcelain parser: both F3: residue names are sorted, so F4, no mutation evidence: the Stryker job had nothing to run because F5: CONTEXT.md's Worktree Safety Policy Module entry now documents F6, N1: the comment is reflowed. Codex reviewed the whole PR twice, and both rounds' findings are fixed here:
The row-48 carve-out came from the full suite after the second round, so no Codex round has seen it. Gates on this head: |
trek-e
left a comment
There was a problem hiding this comment.
Review of head 7ba53cf77f47 — changes requested
Summary. Adds report-only discovery of .claude/worktrees/ residue that git has forgotten (findUnregisteredResidue) and a scan field on worktree.reap-orphans. This round adds residue paths on stderr (JSON-quoted), changes three workflow callers from 2>/dev/null to >/dev/null, routes both porcelain reads through parseWorktreeEntries, sorts residue names, updates CONTEXT.md, locates the checkout through git, adds top_unresolved, and adds a row-48 carve-out.
Prior findings
| # | Status | Evidence |
|---|---|---|
| F1 | Not resolved | Visibility is real; disposal is still absent, and the new stderr text introduces a hazard (M2). See Major 1. |
| F2 | Resolved | Both porcelain reads go through parseWorktreeEntries(stdout.replace(/\r\n/g,'\n')) (src ~2589, ~2828); no hand parser left. |
| F3 | Resolved (cannot fail on macOS) | fs.readdirSync(residueDir).sort(). |
| F4 | Resolved; claim verified | COVERED in scripts/mutation-matrix.cjs lists 14 modules, none of them worktree-safety; the Stryker jobs are SKIPPED. |
| F5 | Resolved | CONTEXT.md:268 documents reapOrphanWorktreesWithScan, the residue rows and the admin_dir / residue_dir vocabulary. |
| F6 | Resolved | --git-common-dir and --show-toplevel, top_unresolved, rows for --separate-git-dir, .git-named metadata elsewhere and metadata in the parent. |
| N1 | Resolved | Comment reflowed. |
Classification and gates. #4941 carries bug + confirmed-bug; bug-fix track. Fixes #4941, fix template, conventional title, branch fix/4941-reap-unregistered-residue. Changeset .changeset/plucky-eagles-chatter.md is type Fixed, pr: 5233, bold lead, ends (#4941). CHANGELOG.md untouched; Fixed is docs-exempt. No references/ or workflows/ files added. No Emitted-Drift-Ack-Growth trailer is needed: the three shipped workflow edits each shrink by one byte (2>/dev/null → >/dev/null) and no emitted-drift check is red.
Acceptance criteria, #4941
- Issue "Expected": "the backstop names the directory — or, at minimum, does not answer
ok: true, reaped: 0when it did not look." — Met. - Agent Brief 1: "Give
reapOrphanWorktreesa second discovery source … treat any child directory that is not present ingit worktree listoutput and has no.gitfile as reapable residue" — Not met (nothing is reaped). - Agent Brief 2: preserve the
.git/worktrees/admin path and its guards unchanged — Met (admin loop untouched). - Agent Brief 3: "Do not add an
rm -rffallback … usefs.rmSync(path, {recursive: true})… or an equivalent guarded delete." — Not met (disposal removed entirely). - Agent Brief 4: make
reaped: 0distinguishable from "did not look" — Met (scan,scan: nullon throw). - Agent Brief 5: failing-first regression test — Met in form. I did not re-execute it (local
node --testis not permitted). - Agent Brief 6: update the misleading
rollbackPartialWorktreecomment — Met.
Findings
Major 1 — Agent Brief items 1 and 3 are unmet: residue is reported but never disposed of [judgment; maintainer ruled: guarded removal is required]
src/worktree-safety.cts:2906 and .changeset/plucky-eagles-chatter.md ("Nothing is deleted"). The measured cost in the issue (21 orphans, 531 MB, each with node_modules) is unchanged: the operator is told about the directory on every run and has no supported way to dispose of it. Please add the guarded removal the Agent Brief describes: fs.rmSync(path, {recursive: true}) or an equivalent that does not traverse reparse points or junctions, behind the same discovery guards (lstat-directory with symlinks and junctions excluded, no .git entry, absent from a fresh git worktree list, the mtime guard, discovery-root containment through isContainedIn). Keep the report-only rows for anything that fails a guard, and extend the tests (on-disk survival for guarded cases, removal for eligible ones, plus the mtime boundary at guardMs-1 / guardMs / guardMs+1).
Major 2 — the new stderr text tells the reader to "delete" with no safe method [fact for the text; severity is judgment]
src/worktree-safety.cts:2906: "delete once nothing in it is needed". These directories typically hold a full node_modules. The issue and agents/gsd-code-fixer.md forbid rm -rf because on Windows it follows reparse points. The three automatic callers (quick.md:159, quick-batch.md:117, executor-isolation-dispatch.md:59) now route that sentence into an LLM agent's transcript, which is the party most likely to act on it with rm -rf. Once Major 1 is addressed, replace the imperative with the actual verb or outcome; do not leave an unqualified "delete".
Minor 1 — attacker-controlled directory names reach agent context [judgment, prompt-injection surface]
A committed .claude/worktrees/<text>/file in a cloned repo is, after the 5-minute REAP_MTIME_GUARD_MS (src:2481), now printed to stderr by three auto-run workflows that previously discarded stderr. JSON quoting stops newline and ESC forging but not instruction-like text, and the printed list has no cap on count or length (residue.map(...), src:2908). Cap the number of paths (first N plus "and M more") and consider printing only the repo-relative name.
Minor 2 — JSON.stringify leaves control and bidi characters raw [fact]
It does not escape U+007F, the C1 controls U+0080–U+009F (checked: c2 9b survives), or bidi overrides such as U+202E (e2 80 ae survives). The test covers only \n and ESC. 8-bit CSI is honored by some terminals, and an RLO can spoof a path shown to an operator. Escape [\u007f-\u009f\u202a-\u202e\u2066-\u2069\u2028\u2029] after JSON.stringify and extend the test.
Minor 3 — a tautological warning outside a git repo [fact by code reading; not executed because the built module is absent in my worktree]
bail('git_dir_unresolved') yields residue_dir: 'not_scanned', which is not in the exclusion list at src:2912, so running the verb outside a git repo prints .claude/worktrees was not scanned (not_scanned; admin dir: git_dir_unresolved) on every call (silent before). Exclude git_dir_unresolved from the warning or reword it.
Minor 4 — unclassifiable residue is invisible to automated runs [fact]
residue_unreadable, residue_age_unknown and worktree_list_failed are counted in the generic "N orphan(s) skipped" line (src:2916-2919) but never named on stderr, while the JSON is now discarded by the callers. Name them (quoted and capped) like unregistered_residue.
Minor 5 — the row-48 carve-out also admits a new old-form line [fact]
tests/gsd-quick-batch-quick-regression.test.cjs:122: the exact-line regex ^gsd_run query worktree\.reap-orphans (?:2)?>\/dev\/null \|\| true$ applies to added and removed lines, so a brand-new old-form line anywhere in quick.md also passes row 48. Restrict it to a - old-form / + new-form pair (as isWorkstreamForwardingSweepLine does with added and removed bodies), and add the #4941 carve-out to the function docstring (lines 75-94 list only #2529 and #4834). No external review has seen this carve-out until now.
Nit 1. On Windows, JSON.stringify doubles backslashes in the printed path (C:\\Users\\…), so copy-paste yields wrong paths.
Nit 2. The "the three workflow callers" row asserts on shipped prose with includes / regex; consistent with existing rows and exempt from the lint rule, but source-grep in character.
Nit 3. The sort row cannot fail on macOS (the author acknowledges this); fs.readdirSync is called directly, so there is no injection seam, unlike readDirSafe.
Security and robustness. No deletion exists in the current path; candidates require lstat-directory, .git ENOENT, absence from a fresh worktree list (realpath-normalized), the mtime guard and realpath containment. Git calls are bounded (DEFAULT_GIT_TIMEOUT_MS). No argv growth. Every unreadable branch fails closed with a named row. The remaining concerns are the output surface (Major 2, Minors 1 and 2); they become more serious once Major 1 adds real deletion, so the guards above matter.
Not verified. I did not execute any test; the per-row "went red under mutation" claims are the author's.
CI at this head. All required lanes SUCCESS: Ubuntu full and shards, macOS and Windows conformance, smoke, lint-tests, docs-lint, changeset-lint, security, coverage gate, Required tests. Six CANCELLED check runs at 19:00:44–19:00:52Z (PR mergeability / mergeability, validate-target, Pull request template format, validate-title, Issue link required, default-flip-documentation) each have a same-named SUCCESS run completed 19:00:50–19:01:08Z. SKIPPED: Stryker ×2, emitted-baseline artifact, test (inert CI), auto-merge. No failing, pending or action_required runs. Not the reason for this verdict.
CodeGraph Evidence
Memtrace unavailable: monthly quota exhausted, so get_impact, get_symbol_context, recall_decision and find_code_review_issues were not run. The recorded-decision check was not evaluated. The index is of the main checkout, not this PR head, so it is a lower bound.
codegraph_impact reapOrphanWorktrees: 4 symbols (reapOrphanWorktreesatsrc/worktree-safety.cts:2485,cmdWorktreeReapOrphansat :2742,tests/worktree-safety-reap.test.cjs,tests/worktree-safety.test.cjs).codegraph_callers reapOrphanWorktrees:cmdWorktreeReapOrphans(src/worktree-safety.cts:2742) and the two test files. The signature is unchanged and the new exportreapOrphanWorktreesWithScanis additive.- Finding: the code blast radius is small. The meaningful exposure is in what the three shipped workflows now put in agent transcripts (Major 2, Minors 1 and 2).
Verdict
Changes requested. Blocker 0, Major 2, Minor 5, Nit 3.
trek-e
left a comment
There was a problem hiding this comment.
Review of head 7ba53cf77f47 — changes requested
Summary. This is the full pass on the same head; the earlier review (01:51Z) is not edited. All three Skill-pass sub-agents and the isolated pass agree with it and add one Major it did not have: a sweep run from a linked worktree is silent, which is the all-clear the PR itself says must never happen. Two earlier Minors (control and bidi characters, uncapped output) are rated Major by the isolated pass; both ratings are kept. Blocker 0, Major 5, Minor 8, Nit 5.
Classification and gates. #4941 carries bug + confirmed-bug; bug-fix track. Fixes #4941, fix template, conventional title, branch fix/4941-reap-unregistered-residue. Changeset .changeset/plucky-eagles-chatter.md: type Fixed, pr: 5233, bold lead, ends (#4941); CHANGELOG.md untouched. The three shipped workflow edits each shrink by one byte, so no Emitted-Drift-Ack-Growth trailer is needed; node scripts/benchmark-compact-content.cjs --check reports the baseline up to date. No references/ or workflows/ file added.
Acceptance, issue #4941 (verbatim)
- Expected: "the backstop names the directory — or, at minimum, does not answer
ok: true, reaped: 0when it did not look." — Met for the registered-checkout case; not met from a linked worktree (Major 5). - Agent Brief: "Give
reapOrphanWorktreesa second discovery source … treat any child directory that is not present ingit worktree listoutput and has no.gitfile as reapable residue" — Not met: nothing is reaped. - "Preserve the existing
.git/worktrees/-admin-dir path and its lock/PID/ancestry guards unchanged; this is additive" — Met. - "Do not add an
rm -rffallback … usefs.rmSync(path, {recursive: true})… or an equivalent guarded delete." — Not met: disposal removed entirely. - "make
reaped: 0distinguishable from 'did not look'" — Partial: thescanfield does it, butadmin_dir: 'unlisted'still merges "missing" and "unreadable" (Minor 7). - Failing-first regression test, and the
rollbackPartialWorktreecomment — Met in form.
Findings
Major 1 — residue is named but never disposed of [judgment; the maintainer ruled that guarded removal is required, carried] src/worktree-safety.cts (findUnregisteredResidue, report-only by design) and the changeset ("Nothing is deleted"). The issue's measured cost (21 orphans, 531 MB, each with node_modules) is unchanged and the residue is reported on every sweep forever. Add the guarded removal the Brief describes: fs.rmSync(path, {recursive: true}) or an equivalent that does not traverse reparse points or junctions, behind the discovery guards (lstat-directory with symlinks and junctions excluded, no .git entry, absent from a fresh git worktree list, the mtime guard, containment through isContainedIn); keep the report-only rows for anything failing a guard and extend the tests (on-disk survival for guarded cases, removal for eligible ones, the mtime boundary at guardMs-1/guardMs/guardMs+1).
Major 2 — the new stderr text says "delete" with no safe method, into an LLM transcript [fact for the text, carried] cmdWorktreeReapOrphans prints "…not removed; delete once nothing in it is needed:". The three automatic callers (quick.md:159, quick-batch.md:117, executor-isolation-dispatch.md:59) now route it into an agent transcript, while agents/gsd-code-fixer.md forbids rm -rf because it follows reparse points on Windows, and the CLI test pins the sentence verbatim. Replace the imperative with the actual verb or outcome once Major 1 lands and update the pinned string.
Major 3 — JSON.stringify is not terminal- or transcript-safe [verified by node and by the isolated pass's probe against a scratch repo; earlier rated Minor] It escapes U+0000–U+001F, the quote and the backslash only. A directory name with U+009B (an 8-bit CSI introducer), U+007F, U+202E (RLO) or U+2028/U+2029 reaches stderr raw, so a displayed path can be reordered or a terminal driven; the source comment ("a raw newline or escape sequence would forge lines or drive the terminal") claims a guarantee the code does not give, and the test covers only ESC and LF. Escape [\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029] after JSON.stringify, or print a sanitized, index-numbered basename, and add a row per class plus a fast-check property over arbitrary names (no raw control or bidi character; one [gsd] line per path).
Major 4 — attacker-controlled names reach the transcript, unbounded, on every run [verified by the isolated pass; earlier rated Minor] A cloned repo can commit .claude/worktrees/<instruction-like text>/file; after the 5-minute REAP_MTIME_GUARD_MS it is printed verbatim inside quotes, directly after the "delete" sentence (the isolated pass's probe printed IGNORE PREVIOUS INSTRUCTIONS and run rm -rf ~ then report done that way), with the full absolute path (home directory and username), with no cap on row count or name length (240-character names printed in full), and again at every /gsd-quick, /gsd-quick-batch and execute-phase start because the residue is never removed. Quoting does not neutralize natural-language injection. Print a count and a fixed instruction, or a capped list of sanitized repo-relative names ("first N and M more"), and test limit-1/limit/limit+1 for the cap.
Major 5 — a sweep from a linked worktree is silent: the all-clear the PR forbids [verified by reading the exclusion list; Spec and isolated passes agree] cmdWorktreeReapOrphans warns "not scanned" unless residue_dir is in ['scanned', 'absent', 'not_main_checkout']. The workflows run from linked worktrees routinely (this repo's own harness sessions do); there the scan never runs, stderr says nothing, and the output is reaped: 0, entries: [] — scan.residue_dir carries the fact only in JSON, which the three callers send to /dev/null. The PR's own principle is that reaped: 0 must not read as an all-clear when nothing was looked at. Either scan the main checkout's residue from a linked worktree (resolve it through the common dir) or emit the not-scanned line for this status, and add a CLI-level row (the existing row asserts only the JSON field).
Minor 1 — a tautological warning outside a git repo [verified by reading, carried] bail('git_dir_unresolved') yields residue_dir: 'not_scanned', not in the exclusion list, so the verb prints ".claude/worktrees was not scanned (not_scanned; admin dir: git_dir_unresolved)" on every call outside a repository (silent before).
Minor 2 — unclassifiable residue is invisible to automated runs [verified by reading, carried] residue_unreadable, residue_age_unknown and worktree_list_failed are counted in the generic "N orphan(s) skipped" line but never named on stderr while the JSON is discarded; the generic line also tells the reader to run gsd-tools query worktree.reap-orphans, which the callers cannot act on.
Minor 3 — the row-48 carve-out admits a new old-form line [verified, carried] tests/gsd-quick-batch-quick-regression.test.cjs:122: the exact-line regex applies to added and removed bodies, so a brand-new 2>/dev/null line anywhere in quick.md also passes; pair removed-old with added-new as isWorkstreamForwardingSweepLine does, and add #4941 to the docstring (lines 75-94).
Minor 4 — removing 2>/dev/null widens stderr to everything gsd_run emits [isolated pass; security pass 3/10] Runtime build notices, Node warnings and the unquoted [gsd] worktree.reap-orphans failed: <err.message> line (which can embed paths) now reach the transcript on each run, though only the residue lines were intended. Route residue to a dedicated channel, or at minimum say so in the changeset.
Minor 5 — the issue's second symptom is untouched [Spec and isolated passes; judgment on scope] The issue observes worktree.cleanup-wave returning branch_mismatch with stderr: "" for this residue; the diff leaves the wave-cleanup region unchanged, so an operator with the residue in a manifest still gets a misleading empty-stderr block.
Minor 6 — the tests cannot detect Majors 3-5 [verified from the diff] The quoting test uses one hostile name with ESC and LF only and skips on win32; no fast-check property for the escaping and bounded-output contract; no cap rows; "the three workflow callers keep stderr" greps the shipped prose for 2>/dev/null, asserting the text of the fix rather than that stderr reaches anything.
Minor 7 — admin_dir: 'unlisted' merges "missing" and "unreadable" [Spec pass; the code comment admits it] The Brief asks reaped: 0 to be distinguishable from "did not look"; the two causes still read the same.
Minor 8 — Windows path comparison [Standards pass, judgment] registered and canonical are compared as raw realpathSync.native strings; drive-letter case and \\?\ prefixes are not normalized on either side, and the canonicalOrResolved fallback can disagree with realpathSync.native when realpath fails for only one side; nothing exercises this on win32.
Nit 1 [verified] On Windows JSON.stringify doubles backslashes in the printed path (C:\\Users\\…), so copy-paste yields the wrong path.
Nit 2 [judgment] The sort row cannot fail on macOS (fs.readdirSync is called directly, no injection seam).
Nit 3 [judgment] The callers row asserts on shipped .md prose with includes/regex; outside local/no-source-grep but source-grep in character (a behavioral check would run the shipped line against a stub that writes to stderr).
Nit 4 [verified by reading] parseWorktreeEntries trims the path, so a registered worktree whose path has leading or trailing whitespace will not match its realpath and could be named as residue; the refactor now routes both passes through it.
Nit 5 [judgment] ReapScan.admin_dir/residue_dir are string, not unions (an enum-locked test is impossible); bail is defined before gitDirPath is assigned (temporal coupling); two near-identical exports (reapOrphanWorktrees and the WithScan wrapper); the new scan JSON field has no reference-doc entry.
Considered, not findings: no deletion path exists, so traversal and TOCTOU are moot (lstat excludes symlink and junction children, the .git probe is ENOENT-specific, discovery is realpath-contained through isContainedIn); the mtime guard being spoofable only changes whether a line prints; all new git calls are bounded argv arrays; the issue's wording lets "names the directory" be the minimum, but the maintainer ruled that guarded removal is required, and I do not re-rule it here.
Passes run
| Pass | Result |
|---|---|
/diagnose |
run; the isolated pass's transpiled-module probe against a scratch repo with hostile names, and the stderr read |
/root-cause-analysis |
run; the early return conflating "nothing there" with "could not look" is fixed, but the linked-worktree case reintroduces it and disposal is still absent |
/qa-test-architect |
run; Minors 3 and 6, Nits 2 and 3 |
/skills-from-the-artificer (bugfix-review) |
run; block below |
/code-review |
run as two parallel sub-agents: Standards (Minors 6 and 8, Nits 3 and 5) and Spec (Major 5, Minors 5 and 7, acceptance) |
/security-review |
run: identification sub-agent found no additional issue at 80% (path disclosure 3/10, widened stderr 3/10); the injection and control-character findings above are from the isolated pass and my node check |
| Isolated adversarial pass | run by a fresh agent with no prior context; found Majors 3, 4, 5 and Minors 4, 5, Nit 4 |
Not run (Memtrace is unavailable): the Deterministic Review Pass (detect_changes, find_cross_module_issues, find_code_review_issues, find_yaml_rule_matches, review_github_pr), preflight_check, risk tiering (find_central_symbols, find_bridge_symbols), the recorded-decision check (recall_decision, governing_rules) and the co-change gate. None is reported clean. I did not execute any test (local node --test is not permitted); Stryker is SKIPPED and worktree-safety is not in the COVERED matrix, so no mutation evidence exists for src/worktree-safety.cts.
Laws that apply
Subject: PR #5233 adds report-only discovery of unregistered .claude/worktrees/ residue and a scan field to worktree reap-orphans, and lets its stderr reach three auto-run workflows' agent transcripts.
- kerckhoffs-principle — fired: the safety of the output channel rests on directory names being benign; a committed
.claude/worktrees/<text>/makes the name the attacker's input, and quoting does not hold once the mechanism is public (cmdWorktreeReapOrphans). Action: Majors 3 and 4. - leaky-abstractions — fired:
JSON.stringifyis a JSON escaper, not a terminal or LLM-transcript escaper, and the PR leans on it as one. Action: Major 3. - hyrums-law — fired: three callers that discarded stderr now ingest it, so every message in the verb (including the unquoted
failed:line and the generic skipped count) becomes an interface. Action: Minors 2 and 4. - postels-law — fired: input (directory names) is accepted as-is and normalized only at the printer by an escaper that is too narrow; normalization belongs at one edge with a bounded, sanitized form. Action: Majors 3 and 4.
Considered and cleared: knuths-optimization-principle (no optimization), kernighans-law (the residue function is long but linear).
CI. 33 checks SUCCESS and 5 SKIPPED (Stryker ×2, Publish emitted-baseline artifact, test (inert CI), auto-merge); six CANCELLED runs from 19:00:44-52Z each have a newer same-name SUCCESS, so none survives newest-wins; nothing failing, pending or awaiting approval. The branch is BEHIND next by one disjoint commit (a rebase is a CI condition, not a finding). I am not approving: the Majors need a push.
CodeGraph Evidence
Memtrace unavailable: monthly quota exhausted and the server disconnected, so get_impact, get_symbol_context, recall_decision and find_code_review_issues were not run, and the recorded-decision check was not evaluated.
codegraph_impact reapOrphanWorktrees(earlier pass on this head): 4 symbols (reapOrphanWorktreesatsrc/worktree-safety.cts:2485,cmdWorktreeReapOrphansat :2742,tests/worktree-safety-reap.test.cjs,tests/worktree-safety.test.cjs);codegraph_callers:cmdWorktreeReapOrphansand the two test files. The signature is unchanged andreapOrphanWorktreesWithScanis additive. The index is the main checkout, so branch-new symbols are absent.- Finding: the code blast radius is small; the meaningful exposure is what three shipped workflows now put into agent transcripts (Majors 2-4) and the silent linked-worktree case (Major 5).
Verdict
Changes requested. Blocker 0, Major 5, Minor 8, Nit 5.
…d keep the transcript safe Answers the 2026-10-10 review of open-gsd#5233; the maintainer ruled that guarded removal is required. - Major 1: a `.claude/worktrees/agent-*` child is REMOVED (fs.rmSync, recursive — it unlinks a nested symlink or junction, never follows it) when it is a real directory with no `.git` entry, absent from `git worktree list`, older than the mtime guard, holds no git-tracked file, and resolves inside the checkout; any other child is reported with the guard that kept it and left on disk. - Majors 2-4: stderr (read by an agent: the callers keep it) names removed directories only, escaped by formatDiagnosticToken, cut to 64 code points, at most five; kept ones are counted by reason, never named; the failure message is escaped too. - Major 5: a sweep from a linked worktree says on stderr that it did not look; outside a git repository nothing is said (Minor 1). - Minors: wave cleanup blocks a directory with no `.git` entry as `worktree_unregistered` instead of `branch_mismatch` with an empty stderr; `admin_dir` tells `absent` from `unreadable`; Windows path comparison folds case and the `\\?\` prefix; the row-48 carve-out admits only the paired 2>/dev/null -> >/dev/null swap; the shipped caller lines are run, not grepped; scan statuses are typed unions; parseWorktreeEntries keeps edge whitespace in a path; CLI-TOOLS.md documents the sweep. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a verified path Codex whole-PR review (round 1 on this head) reproduced five ways the guarded removal could delete something that is not harness residue. Each now has a guard and a row whose would-be victim survives: - `.claude` and `.claude/worktrees` must be real directories (not links; `residue_dir: aliased`), re-checked right before each removal, and the removed path is built from that verified chain (parent-alias swap; an alias to tracked content inside the checkout); - a harness branch must exist for the child (`agent-<id>` or `worktree-agent-<id>`) — positive evidence it was this repo's worktree; - no repository may lie inside it: no `.git` at any depth, no bare layout (a bare repo, a nested repo with uncommitted work); - the tracked-file check runs per child with a literal, case-insensitive pathspec (a case-only rename; a backslash in a POSIX name); - wave cleanup resolves a relative entry path against plan.repoRoot; - docs say which children get a row and which do not. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ode_modules, remove only the path resolved at removal Codex whole-PR review, round 2, reproduced three more deletion paths: - the top came from `--show-toplevel`, which the subprocess seam trims, so a checkout whose path ends in a space swept a same-named sibling repository; it now comes from `--show-cdup` (`../` steps survive trimming); - the repository walk skipped node_modules, so a package checked out from a local repository (with uncommitted work) was deleted; node_modules is walked, under a larger bound; - a `.claude/worktrees` swapped for a link after the final checks redirected the path-based removal; the child is now resolved at removal time, must sit directly in the verified `.claude/worktrees`, and that resolved path is the one removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks — this round answers the 05:06Z review (it supersedes the 01:51Z one). Head: Major 1 — guarded removal. Done, as ruled. A
Removal is A Codex whole-PR pass on my first cut of the removal found five ways it could delete something that is not residue. All five were reproduced in its sandbox, and each now has a guard and a row whose would-be victim survives on disk:
A second Codex round on the result reproduced three more paths, now fixed with rows. (1) The checkout top came from The harness-branch marker is what turns "no Major 2 — "delete" into a transcript. The imperative is gone. A removed directory is reported as removed ( Major 3 — terminal/transcript safety. Names go through Major 4 — attacker-reachable names, unbounded, every run. Only removed directories are named. They are capped at five ( Major 5 — silent from a linked worktree. Stderr now says Minor 1 — outside a git repository ( Nits — 1: only names are printed now, not full paths, so no doubled backslashes. 2: the residue dir is listed through the injectable One existing row changed for a reason worth naming. Verification. Mutation. |
…t-injection scan does not flag The security job's scan flagged the literal phrase in a test fixture; the row only needs a name that addresses a reader, which is never printed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
trek-e
left a comment
There was a problem hiding this comment.
Review of head b3792288e2850b58883498b80b3963e38b6e83f4 - minor findings
Summary. Guarded removal of .claude/worktrees/agent-* residue, a scan field, stderr kept in the three callers, and worktree_unregistered in wave cleanup. Every finding of the 2026-10-10T05:06Z review (Majors 1-5, Minors 1-8, Nits 1-5) is addressed in the head; I read the removal path (verified directory chain, lstat, -z registration list, icase/literal/NFC tracked check, repository walk with case-folded .git, rename to an unguessable name then re-verify) and found no hole. CI on this head: all checks SUCCESS, Stryker/inert/auto-merge SKIPPED, nothing pending. mergeStateStatus=BEHIND next by 14 commits, merge-tree is clean.
Classification and gates. #4941 confirmed-bug; Fixes #4941; fix template; conventional title; changeset Fixed with pr: 5233; docs/CLI-TOOLS.md and CONTEXT.md updated; no Emitted-Drift-Ack-Growth needed (the workflow edits shrink by one byte).
Findings
- Minor 1 -
RESIDUE_WALK_LIMIT/residue_unverified(src/worktree-safety.ctsrepositoryInside) has no test: no limit-1/limit/limit+1 rows and no unreadable-subdirectory row for a fail-closed deletion guard. Make the bound injectable and add the rows, each asserting the directory survives. - Minor 2 -
.changeset/plucky-eagles-chatter.md:5is a long multi-clause paragraph;.changeset/README.mdasks for a bold lead plus one explaining sentence. The guard list is already in docs/CLI-TOOLS.md. - Nit 1 - say in the changeset that
>/dev/nullalso lets runtime-build notices and Node warnings through. - Nit 2 - the
worktree_unregisteredmessage promises thatworktree.reap-orphansremoves such directories; it does so only when they pass the guards. - Nit 3 -
/^agent-/accepts a bareagent-;residueNameList/pathCompareKeyare exported only for tests.
Zero-tolerance applies, so these need a push. Please also update the branch from next.
Verdict. Changes requested (Minor 2, Nit 3).
Memtrace Evidence
The Memtrace MCP server was unavailable for this review (it failed to connect: memtrace not found in PATH), so no graph-backed check ran. None of the four checks below is a clean result.
get_impact: not run. Blast radius comes from reading the diff and grepping callers.get_symbol_context: not run. Callers and callees of the changed symbols were checked by grep.recall_decision: not run. The recorded-decision gate did not execute, so this does not show that no decision governs the change. ADRs andCONTEXT.mdpredicates were checked by grep instead.find_code_review_issues: not run. The deterministic review pass did not run. A manual diff review stood in for it, so the findings above are judgment, not graph facts.
… an id after agent- Review 2026-10-14: - Minor 1: the walk bound is injectable (`deps.residueWalkLimit`), with limit-1/limit/limit+1 rows for a repository on the walk's last read, a clean residue one past and at the bound, and an unreadable subdirectory. Every keep row asserts the directory survives; mutating `>` to `>=` or the unreadable catch to `continue` turns them red. - Minor 2 / Nit 1: the changeset is a bold lead plus one sentence, and says `>/dev/null` lets runtime-build notices and Node warnings through. - Nit 2: the `worktree_unregistered` message says removal happens only when the residue guards pass. - Nit 3: a bare `agent-` is not harness-named; the two test-only exports say why they are exported. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…x, not an inherited GIT_INDEX_FILE Codex review (whole PR): both `ls-files` checks inherited GIT_INDEX_FILE, so inside a git hook or a partial commit an alternate (here empty) index hid a tracked directory under .claude/worktrees and its uncommitted edits were removed as residue. The variable is now unset for those two calls. The new row fails on the previous head (`reaped/unregistered_residue`) and passes now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…'s own (git_mismatch) Codex review round 2 reproduced the same class one layer up: with an inherited GIT_DIR naming another repository, or GIT_WORK_TREE / core.worktree naming a nested directory, git answered about another repository while the filesystem sweep stayed on this checkout. That repository's branch and empty index removed this checkout's tracked, edited work as residue. The residue scan now requires the top's own `.git` (directory, or a gitfile's `gitdir:`) to lead to the git dir git is using, and otherwise reports `residue_dir: git_mismatch` and removes nothing. Three rows (GIT_DIR, GIT_WORK_TREE, core.worktree) fail on the previous head and pass now; with the check disabled in the compiled module all three go red again. A `--is-inside-work-tree` check was tried and dropped: no row needed it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks. This round answers the 2026-10-14 review. Head: Minor 1: walk bound untested. The bound is injectable (
Mutating the compiled module ( Minor 2 / Nit 1. The changeset is now a bold lead plus one sentence. It says the callers' Nit 2. The Nit 3.
This matches the existing Codex (whole PR, two rounds) found two data-loss paths in the tracked-content guard. Each was reproduced before the fix:
Round 2's fix ( Verification.
CI on |
trek-e
left a comment
There was a problem hiding this comment.
Merge conflict with next — blocks review and merge.
git merge-tree --write-tree origin/next <PR head> against next @ f7efbf110a reports conflicts in:
tests/gsd-quick-batch-quick-regression.test.cjs
Please bring the branch current with next and resolve the conflict(s). The substantive review resumes once the PR is mergeable. This notice is about the conflict only; it does not retire any open finding from earlier reviews.
…istered-residue # Conflicts: # tests/gsd-quick-batch-quick-regression.test.cjs
|
Merged The conflict: Gate on the merged head: |
trek-e
left a comment
There was a problem hiding this comment.
Review — fix(#4941): reap-orphans names .claude/worktrees residue git has forgotten, and reports what it scanned
Head reviewed: 5c789d2 (merge of next on top of f956af3 / 3797af0). Fresh gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BLOCKED, reviewDecision=CHANGES_REQUESTED, not draft.
Summary
Re-review. The earlier substantive CHANGES_REQUESTED review (2026-10-14T02:37Z, head b379228) had Minor 1, Minor 2, Nit 1, Nit 2, Nit 3. I verified each against the code and tests at this head (not the author's comment). All five are resolved. The two Codex data-loss paths the author reported (inherited GIT_INDEX_FILE; inherited GIT_DIR / GIT_WORK_TREE / core.worktree) are fixed in the code and each has a row that asserts the tracked file survives on disk. I attacked the removal path adversarially (details below) and found one remaining Minor (a stranded .gsd-reap-* directory is never reclaimed) and no data-loss hole.
Classification & gate compliance
- Track: bug fix; #4941 labels
confirmed-bug;Fixes #4941; Fix template sections present; not a draft; titlefix(#4941): ...; branchfix/4941-reap-unregistered-residue. (deterministic) - Changeset
.changeset/plucky-eagles-chatter.md: typeFixed,pr: 5233, bold lead + one explaining sentence, ends with(#4941); it now says>/dev/nullalso lets runtime-build notices and Node warnings through. (Minor 2 / Nit 1 resolved.) - Docs:
Fixedis exempt, butdocs/CLI-TOOLS.md("Orphan sweep") andCONTEXT.md(Worktree Safety Policy Module) are both updated and describe the guards,scanvalues and stderr contract. Noreferences/orworkflows/file added, so no INVENTORY/manifest change needed (three workflow edits are one-line2>/dev/null->>/dev/null). - Generated conformance-tier lists gain
tests/worktree-safety-reap.test.cjs(new test file registered). - PR label
needs-review: merge-conflictis still on the PR although the head is MERGEABLE (maintainer-managed label; noted only). - One concern per PR: yes (reaper discovery + its stderr visibility + the wave-cleanup
worktree_unregisteredreason, all #4941).
Must-Have Acceptance Checklist (issue #4941 "Expected")
- [PASS] The backstop names the directory instead of answering
ok:true, reaped:0blind: no-admin-dir repro row removes the residue and reportsscan {admin_dir:'absent', residue_dir:'scanned'}; CLI rows cover the same. - [PASS]
reaped: 0is distinguishable from "did not look":scanfield (admin_dir,residue_dir),scan: nullon a throw, and a stderr line when.claude/worktreeswas not swept. - [PASS] Second discovery source reads
.claude/worktrees/from the filesystem, child not ingit worktree list, no.gitentry (plus the additional guards). - [PASS] Wave cleanup no longer reports
branch_mismatchwith empty stderr for such a directory:worktree_unregisteredwith an accurate message (Nit 2 resolved: it now says reap-orphans removes it only when it passes the guards, and the wave-cleanup row asserts the wording). - [PASS] Stale doc comment on
rollbackPartialWorktreecorrected.
Root-cause / functional verdict
Root cause: the reaper's only candidate source was the git admin directory, which git worktree prune deletes for exactly this residue class, so the sweep returned an all-clear it had not earned. The fix adds the missing source and reports what each source read. Root-cause fix.
Earlier findings, verified at this head:
- Minor 1 (walk bound untested):
deps.residueWalkLimitis injectable (src/worktree-safety.ctsfindUnregisteredResidue/repositoryInside); rowslimit-1->residue_unverified,limitandlimit+1->residue_contains_repository, a clean residue one past the bound kept and one at the bound removed (pinsseen > limit), and an unreadable (mode 000) subdirectory ->residue_unverified, each asserting the directory survives on disk. Resolved. - Minor 2 / Nit 1: changeset reworded. Resolved.
- Nit 2: message wording. Resolved.
- Nit 3:
HARNESS_WORKTREE_NAME_RE = /^agent-./su; a bareagent-(even with aworktree-agent-branch) isresidue_not_harness_named(row).residueNameList/pathCompareKeyare still exported for tests but carry a comment giving the reason and matching the existingexported for testsprecedent insrc/uat.cts/src/frontmatter.cts. Resolved. - Codex path 1, inherited
GIT_INDEX_FILE: bothls-filescalls passenv: { GIT_INDEX_FILE: undefined };execGitspreads...process.envthen...opts.env, andspawnSyncdrops anundefinedenv value, so the variable is unset. Row setsGIT_INDEX_FILEto a non-existent index with a tracked, edited directory and assertsresidue_trackedand the edit intact. - Codex path 2, inherited
GIT_DIR/GIT_WORK_TREE/core.worktree:ownGitDir(top)(the top's own.gitdirectory or gitfilegitdir:) must equal the git dir git is using, elseresidue_dir: git_mismatchand nothing is read or removed. Three rows assertgit_mismatch, noreaped, and the uncommitted file intact.
Adversarial pass over the delete path (all fail closed): candidate must be a non-empty real directory, no .git entry (any case, any depth, bare layout), not in NUL-delimited worktree list, older than the 5-minute guard, agent-<id> named with an agent-<id> / worktree-agent-<id> branch, untracked by a literal case-insensitive pathspec plus an NFC/case-folded name set built from one ls-files -z, .claude and .claude/worktrees real directories re-verified right before removal, removal path realpath-resolved and required to be a direct child of the verified directory, then renamed to an unguessable name, re-verified, and only then rmSynced (rename back on failure). A symlink/junction swap before, during or after the checks is covered by three rows. rmSync unlinks nested links rather than following them. Git calls are bounded by the seam's default 10 s timeout; argv is an array and the pathspec is prefixed with fixed magic, so a hostile directory name cannot become an option or pathspec magic. Stderr names only removed directories, escaped with formatDiagnosticToken, cut to 64 code points, at most five.
Blast radius
No get_impact available (Memtrace down). CodeGraph: execGit (59 callers) and retryRenameSync (28 callers) are only consumed, not changed. Changed behaviour is local to reapOrphanWorktrees / reapOrphanWorktreesWithScan (new exports: reapOrphanWorktreesWithScan, residueNameList, pathCompareKey; existing reapOrphanWorktrees signature unchanged and now also runs the residue sweep on every early-exit path, including default_branch_unresolved, by design since the sweep does not use the default branch), cmdWorktreeReapOrphans (JSON gains scan; additive; stderr wording changed), executeWorktreeWaveCleanupPlan (new worktree_unregistered block reason before the branch read), and parseWorktreeEntries (trailing/leading path whitespace no longer trimmed, CR stripped; the reap path now reuses it). The three workflow callers change 2>/dev/null to >/dev/null, and tests/gsd-quick-batch-quick-regression.test.cjs gains a paired-swap-only carve-out with a six-row control test.
Co-change completeness
Source, tests, CONTEXT.md, docs/CLI-TOOLS.md, three workflow callers, changeset, and the conformance-tier lists are all present. Nothing missing found. gsd-core/bin/lib/worktree-safety.cjs is not tracked (generated from src/worktree-safety.cts), so no compiled artifact belongs in the diff.
Test quality
- Fail-first: the issue-repro row (
reaped: 0and directory still on disk on base;reapedrow plus directory gone on head), theGIT_INDEX_FILE/GIT_DIR/GIT_WORK_TREE/core.worktreerows (fail on the earlier heads, per the author; reasoned here: with the guard removed the foreign empty index yields no tracked name and the directory is deleted). - Every keep-row asserts the file on disk, not only the JSON. Controls assert the specific reason.
- Boundary rows at limit-1 / limit / limit+1 present. fast-check property on
residueNameList(cap, cut, escape). Clocks injected (STALE_MTIME); no wall-clock assertions; no source-grep (reads are fixture files). Windows-incompatible fixtures are skipped explicitly. lint:ciand the full suite were run by the author per the comment; CI is the executor (below).
Memtrace Evidence
get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Blast radius above is from CodeGraph (codegraph_exploreonexecGit,retryRenameSync) and manual diff reading.get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Callers/callees checked via CodeGraph andgit grepon the head ref.recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). This does not show that no decision governs the change; ADR-1239 mention ofreap-orphansand theCONTEXT.mdmodule entry were read instead.find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The deterministic pass did not run; the findings below are manual judgment.
Findings
Minor 1 (judgment) — a stranded .gsd-reap-<hex> directory is never reclaimed. src/worktree-safety.cts findUnregisteredResidue, the rename/remove tail (rename to doomed at ~l.427-451). If the process dies between retryRenameSync(target, doomed) and removeDir(doomed), or the rename-back after a failed removal also fails ("left under its .gsd-reap name"), the directory, with its node_modules, is left under .claude/worktrees/.gsd-reap-<hex>. Every later sweep sees it as a candidate (no .git, not listed, old, non-empty) but HARNESS_WORKTREE_NAME_RE rejects the name, so it is reported as residue_not_harness_named on every run and never removed: the exact "present, invisible-to-the-reaper, grows forever" class this PR fixes, now created by the PR itself. No data loss. Fix: treat a child named .gsd-reap-<16 hex> as a harness-owned candidate (still subject to the mtime, tracked and repository guards; skip the branch requirement because the original name is gone) and remove it, plus a row that plants one and asserts removal, and one that plants a tracked/repository-bearing one and asserts survival.
No Blocker or Major findings.
Verdict
Changes requested (Minor 1; zero tolerance). CI on this head is not complete: see below. Substance otherwise clean and every earlier finding is resolved.
CI (head 5c789d2, latest-per-name)
19 success, 4 skipped, 11 in_progress, 0 failing, 0 action_required runs for this head sha. Pending (in_progress): conformance test (macos-latest, 24), conformance test (windows-latest, 24, shard 1/3), (shard 2/3), (shard 3/3), lint-tests, QA loop walk (smell ratchet), security, test (ubuntu-latest, 24, shard 1/3), (shard 2/3), (shard 3/3), test (ubuntu-latest, 24). Not approvable until these complete green.
…d mid-removal Review 5560069125 (Minor 1): if the process died between the rename to `.gsd-reap-<hex>` and the removal, or a failed removal could not be renamed back, the directory was rejected as residue_not_harness_named on every later sweep and never reclaimed. A child named `.gsd-reap-<16 hex>` is the sweep's own, so it needs no harness name or branch; the mtime, tracked-file and repository guards still apply. Rows: a stranded one is removed; tracked and repository-bearing ones are kept; four near-miss names are kept. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…back fails Codex review of a491a39: - When a removal failed and renaming the child back failed too, the skipped row named the vacated original path, not the `.gsd-reap-*` one still holding the files. Both rollback rows now name the path that holds them. - A `.gsd-reap-<16 hex>` name is not proof that the sweep created the directory. The code and docs now say the name is reserved for the sweep under `.claude/worktrees`, instead of claiming verified ownership. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for the re-review (5560069125). Pushed Minor 1, fixed: a stranded
New rows in
Two things Codex raised while reviewing the whole PR:
Validation:
|
trek-e
left a comment
There was a problem hiding this comment.
Review — fix(#4941): reap-orphans names .claude/worktrees residue git has forgotten, and reports what it scanned
Head reviewed: 39b0a2a (fresh gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED, not draft).
Summary
Re-review. Prior on-record review (trek-e, 2026-10-17T21:01Z, head 5c789d2) had one finding, Minor 1 (a stranded .gsd-reap-<hex> directory is never reclaimed). The delta since that review is two commits (a491a39cf, 39b0a2a5e; compare 5c789d245...39b0a2a5e: CONTEXT.md, docs/CLI-TOOLS.md, src/worktree-safety.cts, tests/worktree-safety-reap.test.cjs). Minor 1 is resolved in code and tests. The delta introduces three new findings (1 Minor, 2 Nit, all judgment) and the head is now behind next.
Classification & gate compliance
- Track: bug fix. Issue #4941 carries
confirmed-bug;Fixes #4941present (closingIssuesReferences lists #4941); Fix template; not a draft; titlefix(#4941): ...; branchfix/4941-reap-unregistered-residue. (deterministic) - Changeset
.changeset/plucky-eagles-chatter.mdunchanged since the last review (typeFixed,pr: 5233, bold lead). The two new commits add no user-facing surface beyond what the changeset already covers. - Docs:
docs/CLI-TOOLS.mdandCONTEXT.mdwere updated in the delta to describe the reserved.gsd-reap-<16 hex>rule. (deterministic) - No
references/orworkflows/file added; no INVENTORY change needed. - BEHIND:
origin/next= 274f8ca is 8 commits ahead of the head.git merge-treeof head withorigin/nextis clean (no conflicted files). Files changed on both sides:docs/CLI-TOOLS.md,scripts/lib/platform-conformance-tier.generated.cjs(both merge cleanly). The PR labelneeds-review: merge-conflictis stale (maintainer-managed). Fix:git fetch origin && git rebase origin/nextor mergenext, then re-run CI. - One concern per PR: yes.
Must-Have Acceptance Checklist (issue #4941 "Expected")
- [PASS] Backstop names the directory instead of answering
ok:true, reaped:0blind (no-admin-dir repro row, CLI rows). - [PASS]
reaped: 0distinguishable from "did not look" (scanfield;scan: nullon throw; stderr line when.claude/worktreeswas not swept). - [PASS] Second discovery source reads
.claude/worktrees/, child not ingit worktree list, no.gitentry, removed through guardedfs.rmSync(path, {recursive: true}). - [PASS] Wave cleanup reports
worktree_unregisteredwith accurate stderr rather thanbranch_mismatch. - [PASS]
rollbackPartialWorktreecomment corrected. - [PASS] Existing admin-dir path and its lock/PID/ancestry guards unchanged (additive).
Root-cause / functional verdict
Root cause (one sentence): the reaper derived its whole candidate set from git's admin directory, which git worktree prune deletes for exactly this residue class, and collapsed "nothing there" with "could not look". Root-cause fix.
Prior finding, verified at this head:
- Minor 1 (stranded
.gsd-reap-*never reclaimed): RESOLVED.STRANDED_REAP_NAME_RE = /^\.gsd-reap-[0-9a-f]{16}$/u(src/worktree-safety.cts:2562) skips only the harness-name and harness-branch checks (:2965-2973); mtime guard, non-empty, tracked, repository walk, chain re-verification and rename-and-reverify (:2953-3032) all still apply. Tests: reaped with no branch (fails on the previous head withresidue_not_harness_named), tracked and repository-bearing stranded dirs kept with on-disk assertions, four near-miss names (15 hex, 17 hex, uppercase, no leading dot) kept.
Delta correctness: restore() (:3020) returns p on a successful rename back and doomed when it fails, so both rollback rows name the path that holds the files. The new POSIX row recreates the original path non-empty during the failed removal, forcing ENOTEMPTY on the rename back, and asserts the row names a .gsd-reap-<16 hex> path that still holds node_modules.
Blast radius
CodeGraph: findUnregisteredResidue is called only by sweepResidue (src/worktree-safety.cts:2587), which sits under reapOrphanWorktreesWithScan. The delta changes only the name gate and the two rollback rows inside that function; no export or signature changes. The reaped-dir set widens by one name class (.gsd-reap-<16 hex>), reachable from the three automatic callers (quick.md, quick-batch.md, executor-isolation-dispatch.md).
Co-change completeness
Source, tests, CONTEXT.md, docs/CLI-TOOLS.md present for the delta. Nothing missing found.
Test quality
- Fail-first: stranded-reclaim row fails on
5c789d245(residue_not_harness_named); rollback-path row fails ona491a39cfper the author and by reading (restorepreviously ignored the rename-back failure and reportedp). - On-disk assertions present for all keep-rows; controls assert the specific reason.
- Gaps: see Nit 1 below.
- CI is the executor; no local test run by this reviewer.
Memtrace Evidence
get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Blast radius above is from CodeGraph (codegraph node findUnregisteredResidue, callers trail) and manual diff reading.get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Callers/callees were read via CodeGraphcodegraph nodeon a detached worktree of the PR head.recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). This does not show that no decision governs the change; theCONTEXT.mdWorktree Safety Policy entry and the prior review's ADR-1239 note were relied on instead.find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The deterministic pass did not run; the findings below are manual judgment.
Findings
Minor 1 (judgment) — the stranded-name reclaim lets one concurrent sweep pick up another sweep's in-flight deletion. src/worktree-safety.cts:2965-2973 and :3005-3032. Sweep A renames agent-x to .gsd-reap-A (:3007) and runs removeDir(doomed) (:3028), which for a directory holding node_modules takes time. A directory rename does not change the directory's own mtime, so the mtime guard (:2958) does not protect it. Before this delta the name was rejected as residue_not_harness_named, so a second sweep ignored it. Now a second sweep B (parallel /gsd:quick-batch agents, or two sessions on one checkout) sees .gsd-reap-A as a stranded candidate with no branch requirement, passes every guard, renames it to .gsd-reap-B and removes it concurrently with A. A's rmSync then fails (ENOENT mid-walk); A's restore() rename back also fails, so A reports {path: .gsd-reap-A, status: skipped, reason: residue_remove_failed} for a directory that no longer exists, and A's stderr/reaped count are wrong. No data loss (the content is residue either way), but it is a false row introduced by this delta and untested. Fix: when removeDir(doomed) throws, re-check fs.existsSync(doomed) and treat an absent path as reaped (or skip stranded candidates whose ctime is inside the guard window), and add a row that makes removeDir delete the directory and then throw, asserting the row is not residue_remove_failed naming a missing path.
Nit 1 (judgment) — new claims in the delta have no row. tests/worktree-safety-reap.test.cjs. (a) The PR comment and docs state a stranded directory is "still subject to the mtime guard"; no row pins a young .gsd-reap-<hex> as unreported/unremoved (the existing fresh-residue row uses an agent- name). (b) The !landed branch changed to report restore()'s path (:3023-3026, residue_unreadable) and has no row asserting the reported path when the rename back fails there. Add one row each.
Nit 2 (judgment, maintainer decision) — name match is not provenance. src/worktree-safety.cts:2562. A user-created, untracked, repository-free directory named exactly .gsd-reap-<16 hex> under .claude/worktrees, older than five minutes, is deleted. The author documents the name as reserved for the sweep and offered a .git journal to prove ownership. Collision requires a hand-picked 16-hex name, so risk is low, but it is the only removal path with no harness name or branch evidence. Decide: accept the reserved-namespace contract as documented, or require the journal.
Verdict
Changes requested (zero tolerance: Minor 1, Nit 1, Nit 2; head also BEHIND next by 8, clean merge). The prior Minor 1 is resolved and no Blocker or Major finding exists.
CI (head 39b0a2a, latest-per-name)
37 check runs collapsed to latest-per-name: 32 success, 5 skipped, 0 failing, 0 pending, 0 action_required runs for this head sha. Note that this head has not been run against current next (BEHIND by 8).
…y gone is reaped A concurrent sweep can take this sweep's in-flight .gsd-reap-<hex> as a stranded directory (a rename keeps the directory's mtime) and remove it first, so this sweep's removal fails mid-walk. It reported residue_remove_failed for a path that no longer existed; a removal whose reserved path is gone (ENOENT) is now reported reaped. Adds rows for that, for a young stranded .gsd-reap-<hex> left alone by the mtime guard, and for the path reported when a rename that did not land cannot be renamed back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e guard window alone A sweep's rename to .gsd-reap-<hex> keeps the directory's mtime, so a second sweep running at the same time took the first one's in-flight removal as stranded and removed it under it. The previous commit reported the first sweep's then-failing removal as reaped when its path was gone, but gone does not prove deleted: the second sweep can hold the directory and restore it, so the row could say reaped while the files survived (Codex review). That rule is dropped. Instead a reserved name whose ctime (which the rename sets) is inside the guard window is left for a later sweep, so two sweeps never hold one directory; an unreadable ctime is reported as residue_age_unknown. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for review 5563200267. Here is how each finding is handled at Minor 1 (one sweep reclaiming another's in-flight removal). I took your second option: a reserved
First I tried your other option, which reports Rows in
The guardMs−1, unreadable-ctime and real-rename rows fail on the previous head. Limits:
Nit 1.
Both rows are in the same file. Nit 2 (reserved name vs. provenance). No change: the reserved-namespace contract stands as documented. If you would rather require proof, I'll add the Behind Codex whole-PR review, two rounds. Round 1 found the unsound ENOENT rule above. Round 2, on the ctime guard, found nothing blocking. It ran in-memory probes over the guard's boundaries, overlapping sweeps (with the second removal succeeding or restoring) and an unreadable ctime. Validation.
|
Fix PR
Linked Issue
Fixes #4941
What was broken
worktree.reap-orphansdiscovered candidates only from.git/worktrees/. The most common Claude Code residue — a.claude/worktrees/agent-*directory whose harness teardown did not complete, so it has no.gitfile, and whose admin entrygit worktree prunehas since deleted — was invisible to it by construction. The sweep answered{"ok": true, "reaped": 0, "entries": []}with the directory,node_modulesand all, still on disk. An absent admin directory and an empty one also reported identically.Reproduced on current
nextwith the issue's script, plus a live harness worktree beside the residue: stillreaped: 0, so the gap is not only the no-admin-dir case — residue is missed whenever its own admin entry is gone.What this fix does
A second, additive discovery source,
findUnregisteredResidue, reads<main checkout>/.claude/worktrees/and removes a child only on positive evidence that it is this repository's harness teardown residue, through a path verified to stay inside the checkout:.git(a directory, or a gitfile'sgitdir:) leads to the git dir git is using, so an inheritedGIT_DIR/GIT_WORK_TREEor acore.worktreecannot substitute another repository's registrations, branches or index (residue_dir: git_mismatchotherwise — nothing is removed);.claudeand.claude/worktreesare real directories under the checkout's canonical top, not links (residue_dir: aliasedotherwise — nothing is read); re-checked right before each removal, and the removed path is built from that verified chain;lstat) with no.gitentry (ENOENT), absent fromgit worktree list, and older than the lock path'sREAP_MTIME_GUARD_MS;agent-<id>with a non-empty id and a harness branch exists for it —agent-<id>orworktree-agent-<id>(residue_no_harness_branchotherwise);residue_tracked; fails closed), read from the checkout's own index (an inheritedGIT_INDEX_FILEis unset for these calls);git worktree list --porcelain -z), so a path holding a newline is read whole;.git(any case) at any depth and no bare layout; links are not followed,node_modulesis walked too, the walk is bounded (residue_contains_repository/residue_unverified).A candidate that fails a check is reported with
status: "skipped"and that reason and left on disk; a child that is not a candidate (a link, one with a.gitentry, one git lists, a young or empty one) gets no row. Removal renames the directory to an unguessable.gsd-reap-*name inside the verified directory, re-verifies it there, and only then runsfs.rmSync(path, { recursive: true }), which unlinks a nested symlink or junction rather than following it. A removed directory is{status: "reaped", reason: "unregistered_residue"}and counts inreaped; a failed removal isresidue_remove_failed, and when the rename back fails too, the row names the.gsd-reap-*path that still holds the files. The.gsd-reap-<16 hex>name is reserved for the sweep: a directory left under it (the process died between the rename and the removal) is reclaimed on a later sweep without a harness name or branch, still subject to every other guard, and only once its ctime (which the sweep's rename sets, unlike its mtime) is past the five-minute guard, so a concurrent sweep never takes another's removal in flight. Rows come back in name order. Only a main checkout sweeps; from a linked worktree the scan reportsnot_main_checkout, and stderr says so.Why removal is safe enough now
The maintainer ruled that guarded removal is required (review of 2026-10-10). Every data-loss path found against a deleting draft has a guard and a row whose would-be victim survives: a user's own directory (not
agent--named, no harness branch), committed content (the tracked check, case-insensitive and literal), another repository inside (the repository walk), a symlinked or swapped.claude/worktrees(the verified real-directory chain), and a worktree being created (the mtime guard, and the empty-directory skip:git worktree addrefuses a non-empty target — measured). Twelve of these came from three Codex whole-PR passes, which reproduced each one in a sandbox before the fix; the last four (a removal-time parent swap,.GITcase aliasing, a newline in the checkout path, an NFC/NFD rename) have not had a fourth pass.What reaches the transcript
The three automatic callers (
quick.md,quick-batch.md,execute-phase/steps/executor-isolation-dispatch.md) used to run the sweep as2>/dev/null; they now run it as>/dev/null. The JSON they never read is dropped, and stderr, which lands in the agent transcript, is kept. So stderr is written for a reader that a directory name must not be able to address:io.formatDiagnosticToken, which escapes control, invisible and bidi characters and U+2028/U+2029 as\uXXXX; names are cut to 64 code points, and at most five are shown (… and N more);failed: …) is escaped too;.claude/worktreesthat was not swept is said (not_main_checkout,unreadable,aliased,top_unresolved,git_mismatch); outside a git repository nothing is said;N orphan(s) skippedline counts only the admin-dir path's rows.Every runtime-build notice or Node warning from that one call can now reach the transcript as well.
scanThe CLI output gains
scan, which names what was actually read, soreaped: 0is distinguishable from "did not look":admin_dir:scanned/absent/unreadable/git_dir_unresolved/default_branch_unresolvedresidue_dir:scanned/absent/unreadable/aliased/not_main_checkout/top_unresolved/git_mismatch/not_scannedWhether the cwd is in the main checkout, and where that checkout's top is, are asked of git (
--git-common-dir,--show-toplevel) rather than read off the git dir's location, which--separate-git-dircan put anywhere under any name. When the reaper throws,scanisnull. The residue scan runs where the admin-dir scan used to return early for a missing.git/worktrees/, and after the admin loop; the existing lock / PID / ancestry path and its fail-closed bail-outs are unchanged. Both are typed unions and documented indocs/CLI-TOOLS.mdand CONTEXT.md.The issue's second symptom
Wave cleanup ran
git -C <residue> rev-parse --abbrev-ref HEAD; with no.gitthere, git walked up and answered the enclosing repository's branch, and the entry blocked asbranch_mismatchwith an empty stderr. An entry whose directory exists with no.gitentry now blocks asworktree_unregistered, with a stderr saying why. The #4415 absent-directory path is unchanged.The
rollbackPartialWorktreedoc comment, which called the reaper "a genuine directory-scan backstop", now says what it covers and what it does not.Root cause
reapOrphanWorktreesderived its whole candidate set from git's admin directory, and the residue class is defined by its admin entry being gone.if (!entries) return results;then collapsed "nothing there" and "could not look" into the same all-clear.Testing
How I verified the fix
The issue's reproduction (no admin dir; and again with a live harness worktree beside the residue) against a build of this branch: the residue is removed, the live worktree is untouched,
reaped: 1,scansays both locations were read, and stderr names the removedagent-t1.Every new row is fail-first against the previous head
7ba53cf77(report-only), compiled module and all. Two Codex whole-PR rounds this update, each with sandboxed reproductions: round 1 found five deletion paths against the first removal cut (a swapped or aliased.claude/worktrees, bare and nested repositories, a case-only rename, a backslash name) and a relative-path bug in wave cleanup; round 2 found three more (a whitespace-ending checkout top, a repository undernode_modules, a late parent swap). Every one has a row whose would-be victim survives; each fails on the commit before its fix. Round 2's fixes were not given a third Codex pass. A residual window remains between resolving the target and unlinking it, usable only by a process that can already write the checkout.npm run lint:ci— exit 0tests/worktree-safety-reap.test.cjs90/90,tests/worktree-safety.test.cjs439/439, the other wave-cleanup files (489) — green6ed523c72: 45,770 tests, 45,667 pass, 3 fail, none in this PR's code:runtime-launcher-parityK2 andhost-integration(orchestrator-worktree argv shape) fail identically on a cleannextcheckout on this Mac;verification-statusfeat(completion): thread convention through the completion-path membership seam (epic #612) #4142 fails only from this machine's~/.gsd/defaults.jsonstderr warning (passes with HOME sandboxed). The branch includes a merge ofnextat8940d2a13..gsd-reap-<16 hex>name changed inside the guard window, by its ctime, is left for a later sweep, so a concurrent sweep never takes another's removal in flight (rows at guardMs-1/guardMs/guardMs+1 by ctime, unreadable ctime, a real rename, and anagent-<id>residue that a fresh ctime does not hold back); rows for a young reserved name and for the path reported when a rename that did not land cannot be renamed back. Two Codex whole-PR rounds: round 1 showed an earlier cut ("gone after a failed removal means reaped") could reportreapedwhile another sweep still held the files, so it was replaced by the ctime guard; round 2 found nothing blocking. Rename-advances-ctime verified on APFS; not verified on Windows NTFS.Regression test added?
tests/worktree-safety-reap.test.cjs's#4941 regressionblock checks the disk in every row: removal for eligible residue (including beside a live worktree, under--separate-git-dir, with an unresolvable default branch, and for either harness-branch spelling); survival for each guard (notagent-*, no harness branch, tracked — including a case-only rename and a backslash name —, a bare or nested repository, failedls-files, failed listing, failed removal, young, empty, age unknown, still registered, non-ENOENTlstat, symlinked child, a linked.claude/worktreesinside or outside the checkout, a swap to an outside link mid-sweep and after the final checks, a repository undernode_modules, a checkout whose path ends in a space beside a same-named sibling, linked worktree); the mtime boundary atguardMs-1/guardMs/guardMs+1; and a junction inside a residue whose target survives. The CLI rows cover the removed-name line, the kept-by-reason line (an instruction-shaped name is never printed), every escaped character class (plus a fast-check property over arbitrary names), the five-name cap (4/5/6) and the 64-code-point cut (63/64/65), the linked-worktree and unreadable not-swept lines, silence outside a repository, and the escaped failure message. The three callers' shipped lines are run under bash against a stub, not grepped. Wave cleanup of a residue entry blocks asworktree_unregistered, for absolute and repoRoot-relative manifest paths.tests/gsd-quick-batch-quick-regression.test.cjsrow 48's carve-out admits only the paired2>/dev/null→>/dev/nullswap, with direct rows. Existing rows changed: the reaper-throws row expects the escaped message; the admin-dir-probe row now counts the residue scan's own read; the#2596SUMMARY-parity and#4758relative-path rows' stand-in worktrees get the.gitfile a real worktree has.Platforms tested
The new junction rows make
worktree-safety-reap.test.cjsa conformance-tier file — both generated tier lists are regenerated here — so the junction handling runs on real Windows in CI, which I could not do locally.Runtimes tested
Checklist
Fixes #NNN— PR will be auto-closed if missingconfirmed-buglabelnpm test).changeset/fragment added if this is a user-facing fix (npm run changeset -- --type Fixed --pr <NNN> --body "...") — orno-changeloglabel appliedBreaking changes
worktree.reap-orphansnow deletes.claude/worktrees/agent-*directories that pass every guard above, and its output gains ascanfield and the residue rows; existing fields are unchanged. The three workflow callers now keep the command's stderr. Wave cleanup reportsworktree_unregisteredwhere it reportedbranch_mismatchfor a directory with no.gitentry.parseWorktreeEntrieskeeps leading/trailing whitespace in a listed path (it used to trim it), stripping only a CR.🤖 Generated with Claude Code