[BUG] Retention sweep deletes user-created git-worktree-add worktrees under .claude/worktrees via dead job records (docs say it never removes user worktrees)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code (observed on 2.1.238; verified the same code paths ship in 2.1.241 by inspecting the published
@anthropic-ai/claude-code-darwin-arm64@2.1.241binary)
What's Wrong?
The periodic retention sweep deleted two long-lived worktrees I created myself with git worktree add under <repo>/.claude/worktrees/ — working tree, .git/worktrees/<name> metadata, ~13 GB of installed node_modules/build state — followed by a git worktree prune. It happened while the machine was asleep (macOS dark wake), with no session active and no log or notice anywhere.
The worktrees docs say the sweep "removes worktrees that Claude created for subagents and background sessions" and "never removes worktrees you create with --worktree". These worktrees were created by neither — they were plain git worktree add worktrees that happened to live under .claude/worktrees/ (where I run Claude sessions inside them daily; the docs themselves describe entering existing worktrees under .claude/worktrees/ as a supported workflow).
From the shipped binary, there are two automated removal paths, both invoked from the retention sweep (tengu_retention_sweep):
cleanupStaleAgentWorktrees— enumerates.claude/worktrees/, but name-filtered to Claude-generated names (/^agent-a[0-9a-f]{16}$/,/^wf_…/,/^bridge-…/,/^job-…/,/^bg-…/). My worktrees (char,mew) match none of these, so this path is not the one that fired.- The jobs cleanup →
reapJobWorktreeIfSafe(...)with reasonjob_retention_sweep— for every dead background-job record in~/.claude/jobs/*/state.jsonthat carries aworktreePath, on every sweep, it removes the referenced worktree when it is: under.claude/worktrees/, root-dir mtime older thancleanupPeriodDays,git statusclean with no unpushed commits, and not locked. This path has no name filter and no check that Claude created the worktree — it trusts whateverworktreePaththe job record carries. Background sessions I ran from inside my own worktrees produced such records (a surviving~/.claude/jobs/<id>/state.jsonon my machine records"cwd": ".../.claude/worktrees/char"; the record(s) that carriedworktreePathwere deleted by the same sweep that reaped the worktrees, since the jobs cleanup removes aged job folders after reaping).
Two design problems compound here:
- No provenance check:
reapJobWorktreeIfSafedeletes worktrees the harness never created, contradicting the documented contract. - Staleness = root directory mtime: normal use (commits, checkouts, edits in subdirectories) never bumps a worktree's root dir mtime, so an actively used worktree "ages" past the cutoff regardless. One of my deleted worktrees had a commit made in it 45 hours before it was reaped as 30-days-stale; its root mtime was last bumped a month earlier by a root-level directory creation, and it crossed the 30-day line 64 minutes before the sweep that deleted it.
Forensic evidence (all worktrees under .claude/worktrees/ in the same repo, same moment):
| worktree | state at sweep | outcome |
|---|---|---|
| char, mew (user-created, clean, pushed, root-mtime > 30d, unlocked) | eligible | deleted + pruned, same second |
| bulba, sq, pika, agent-…, 4 others (each with 1–20 dirty paths) | status --porcelain non-empty | kept |
Deletion completed 2026-08-23 15:07:22 during a Power Nap dark wake; the unified log shows a burst of short-lived git processes ending at that exact second, and no user/session activity. cleanupPeriodDays is unset everywhere (default 30).
What Should Happen?
- The sweep should only remove worktrees Claude Code actually created (e.g. verify the name pattern and/or a creation marker/registry entry before
git worktree remove), matching the documented "worktrees that Claude created … never removes worktrees you create" contract. A job record pointing at a worktree Claude didn't create should release the record, not the worktree. - Staleness should consider real activity (last commit time,
HEADreflog, index mtime), not the root directory's mtime, which active use doesn't refresh. - Destructive removals of multi-GB directories deserve a durable log line and ideally a notice; this one was reconstructable only from binary strings and filesystem mtimes.
Error Messages/Logs
# No user-visible output. Relevant internal log strings from the shipped binary:
# reapJobWorktreeIfSafe: kept <path> — locked by a live Claude Code process, or with a reason we did not write (<reason>)
# cleanupStaleAgentWorktrees: removed N stale worktree(s)
# Reap condition (jobs cleanup, decompiled from 2.1.238; identical in 2.1.241):
# if (jobState.worktreePath && jobIsDead(jobState) && cutoff)
# await reapJobWorktreeIfSafe({worktreePath, worktreeBranch, originCwd, hookBased, cutoff})
# reapJobWorktreeIfSafe gates: path under .claude/worktrees + root-dir mtimeMs < cutoff (cleanupPeriodDays)
# + status --porcelain empty + no unpushed commits + not locked → removeAgentWorktree(..., "job_retention_sweep")
Steps to Reproduce
- In a git repo, create your own worktree where Claude keeps its worktrees, and push it so it's clean:
``bash``
git worktree add .claude/worktrees/mytree -b mytree
git -C .claude/worktrees/mytree push -u origin mytree
- Run a Claude session from inside
.claude/worktrees/mytreeand start a background job from it (so a~/.claude/jobs/<id>/state.jsonrecords the worktree), then exit so the job is dead. - Age the worktree's root dir past the cutoff — either wait 30 days without adding/removing entries directly in the worktree root (normal commits/edits don't bump it), or simulate:
touch -t 202501010000 .claude/worktrees/mytree(and setcleanupPeriodDayslow). - Run
claudein the repo (or leave an idle session running) so the periodic retention sweep executes. .claude/worktrees/mytreeand.git/worktrees/mytreeare silently removed andgit worktree pruneruns.
Workaround that held up in code inspection and practice: git worktree lock --reason "permanent - do not auto-clean" .claude/worktrees/<name> — both sweep paths keep any worktree locked with a reason Claude Code didn't write (the docs are accurate on this point).
Claude Model
Not sure / Multiple models
Is this a regression?
I don't know
Last Working Version
n/a
Claude Code Version
2.1.238 (Claude Code) — same removal logic verified present in 2.1.241
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
iTerm2
Additional Information
Related but distinct open issues in the same subsystem: #74719 (GC deleting dirty/unpushed agent-* worktrees), #77268 (worktree recycling destroying live sibling worktrees), #76144 (gitdir written as .git making healthy worktrees prunable), #46444 (older auto-cleanup data loss). None of them covers the sweep deleting worktrees the user created with git worktree add via job-record reaping, or the root-dir-mtime staleness proxy.