[BUG] isolation: "worktree" produces worktrees pinned to a stale base SHA
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Description
Title
isolation: "worktree" agents are pinned to a stale base SHA after the primary branch advances within a single session
Summary
When the Agent tool is invoked with isolation: "worktree" repeatedly within a single Claude Code session, every spawned worktree is created with HEAD pinned to the same base SHA — typically the SHA that the primary branch (main / master) had at session start. As the parent's primary branch advances (e.g., from merging the agents' outputs back), newly-spawned agent worktrees still inherit the stale original base, not current main.
In one observed session, main advanced approximately 170 commits over the course of work; ~60 worktree-agent-* branches all pointed at the SHA main had before the session began. Agents launched late in the session were therefore reading and modifying source files that were ~170 commits behind current main.
The worktreePath metadata returned to the orchestrator in each agent's completion notification is correct (it identifies the actual on-disk worktree). The bug isn't where the worktree is — it's what's in the worktree.
Component / Surface
- Tool:
Agent - Parameter:
isolation: "worktree" - Affected version: 2.1.116 (likely earlier, untested)
- Platforms: confirmed on macOS arm64; almost certainly cross-platform since the bug is in the worktree-management logic, not platform-specific code
Distinct from
- #39886 —
isolation: "worktree"silently fails (CWD inheritance bug) - #33045 — Agent tool isolation has no effect for team agents
The CWD bug puts the agent in the wrong directory entirely; this bug puts the agent in its assigned directory but with stale content. They can co-occur or occur independently.
Impact
In one ~6-hour session designed to ship ~30 small fixes via parallel agent execution:
- ~30 minutes of Opus-tier tokens abandoned when one agent finished implementing against the wrong base (the produced commit could not be cherry-picked; the work didn't address the stated bug, because the bug's symptoms only manifest in code the agent never saw)
- ~15 minutes of Sonnet-tier tokens lost across smaller agents producing cherry-pick conflicts requiring manual resolution
- ~45 minutes of orchestrator time spent diagnosing, reasoning about, and recovering from cross-branch contamination
- Significant cognitive overhead per agent: every completion required manual verification that the commit landed on the right branch with the right base before believing the success report
Cumulative loss: approximately 30–40% of total productive time/spend for that session.
Session lifecycle / restart behavior (untested hypothesis)
We have not directly tested whether killing the Claude Code process and restarting clears the staleness. Based on observable on-disk state, we expect the staleness to persist across CLI restarts because:
- Worktree HEADs are recorded in
<repo>/.git/worktrees/agent-<id>/HEAD— git's own filesystem state, not CC process memory - The
worktree-agent-*branch refs are stored in<repo>/.git/refs/heads/and persist across CC restarts
If the cache that mints stale worktrees is in-process (cleared by restart), restarting Claude Code in the same repo would mint fresh worktrees against current main. If it's on-disk (persists across restarts), a fresh CC process would see and re-use the stale state.
Confirming which is the case would tell maintainers whether the fix needs to drain on-disk worktree state or just clear an in-memory cache. Suggested test included in the reproduction file.
Suggested upstream fixes (in priority order)
- (Highest leverage) Refresh worktree HEAD to current
mainon everyAgentinvocation withisolation: "worktree". Equivalent to runninggit -C <worktree> reset --hard <current_main_sha>immediately before spawning the agent process. Skip the refresh only if the worktree has modified or staged tracked files (git status --porcelain | grep -v '^??'); untracked files are not touched bygit reset --hardand should not block the refresh. - Drain and recreate worktrees on session-level git state changes. If the runtime detects that the primary branch has advanced since worktrees were created, drain the pool and recreate.
- Document the current behavior until either #1 or #2 ships, so users at minimum know this can happen and know to verify worktree base SHA before trusting agent output.
- Provide a
cwd:parameter on theAgenttool that lets the orchestrator pass an explicit path. This sidesteps both this bug and the CWD bug by letting users manage worktrees themselves.
What Should Happen?
Expected behavior
What should happen on each Agent launch with isolation: "worktree"
Each invocation of the Agent tool with isolation: "worktree" should produce a worktree whose HEAD is at, or fresh-from, the current primary branch tip — not from a SHA cached at session start (or wherever it was first created).
Concretely, after a sequence like:
1. Session starts. main = aaaaaaa.
2. Launch agent A → worktree A's HEAD should be aaaaaaa (or fresh from main).
3. Agent A produces commit; orchestrator merges to main. main is now bbbbbbb.
4. Launch agent B → worktree B's HEAD should be bbbbbbb (or fresh from main).
5. Agent B produces commit; orchestrator merges to main. main is now cccccccc.
6. Launch agent C → worktree C's HEAD should be cccccccc (or fresh from main).
Inspecting any agent's worktree at launch time should show its HEAD at, or compatible with, current main:
$ cd <worktreePath>
$ git rev-parse HEAD
cccccccc... # current main, not aaaaaaa from session start
$ git rev-list --count HEAD..main
0 # no missing commits relative to main
Acceptance criteria for a fix
- For any
Agentlaunched withisolation: "worktree"at any point in a session,git -C <worktreePath> rev-parse HEADshould equal the currentmainSHA at launch time (or be a descendant of it). git -C <worktreePath> rev-list --count HEAD..mainshould be 0 at the moment the agent receives control.- The fix must not destroy uncommitted work in worktrees that already had it; refresh should be a no-op if the worktree has modified or staged tracked files (untracked files alone are safe to ignore —
git reset --harddoes not touch them). - The fix should be idempotent; re-running the refresh on an already-fresh worktree should be a no-op (and ideally log so).
What an agent's first observation should reflect
When an agent invoked with isolation: "worktree" runs:
1. pwd → its assigned worktree path
2. git rev-parse HEAD → current main SHA
3. git log -1 --format='%H %s' HEAD → most recent main commit
4. git status --porcelain → empty (or only untracked entries)
It should see fresh code that matches what the orchestrator sees on main. It should not have to verify staleness or perform its own refresh as a defensive measure. It should not produce commits that subsequently fail to cherry-pick to current main due to base divergence.
What the orchestrator should not have to do
- Manually verify each agent's worktree base SHA before trusting its output
- Manually rebase agent branches onto current
mainbefore merging - Add defensive prelude blocks to every agent prompt to detect stale-base conditions
- Maintain a custom
SubagentStarthook to refresh worktree base SHAs as a workaround
Error Messages/Logs
# Error messages / logs / observed evidence
There are no explicit error messages — the bug is silent. The runtime does not crash; the agent does not warn. The agent receives an apparently-valid worktree and operates on it. The damage manifests later, when the agent's commit either (a) cannot be cherry-picked to current `main` due to merge conflicts on the now-divergent base, or (b) "fixes" a bug that doesn't exist on the agent's stale view of the code.
## Symptom 1 — All worktrees pinned to one SHA
`git branch | grep worktree-agent | head` after ~60 agents launched across a single session:
worktree-agent-a062c436 aaaaaaa <commit subject from before session start>
worktree-agent-a0b2abaf aaaaaaa <commit subject from before session start>
worktree-agent-a0eafa5f aaaaaaa <commit subject from before session start>
worktree-agent-a0f81987 aaaaaaa <commit subject from before session start>
worktree-agent-a17d4224 aaaaaaa <commit subject from before session start>
worktree-agent-a240c62a aaaaaaa <commit subject from before session start>
worktree-agent-a3095c47 aaaaaaa <commit subject from before session start> ← agent launched ~3 hours after session start
... (≈55 more, all at aaaaaaa)
Meanwhile, `git log main -3 --oneline` at the same moment:
cccccccc <recent commit landed during this session>
bbbbbbb1 <recent commit landed during this session>
bbbbbbb0 <recent commit landed during this session>
Distance:
$ git rev-list --count aaaaaaa..main
~170
Every late-session agent's worktree is missing those ~170 commits.
## Symptom 2 — Agent's first investigation reveals stale code
Excerpt from one late-session agent's transcript at `<HOME>/.claude/projects/<project>/subagents/agent-<id>.jsonl` (whitespace and identifiers reformatted for clarity):
[Agent operating in worktree at .claude/worktrees/agent-<id>/]
[CWD check passed — agent is correctly inside its assigned worktree]
$ grep -n "_compute_X\|<sentinel-feature>" src/path/to/file.py
539: def _compute_X(node, graph) -> float:
...
579: V = N * math.log2(n) # raw value, no clamp
581: return max(0.0, V)
But on current `main`, the same function has had a clamp added (commit landed weeks before the observed session):
$ grep -n "<sentinel-feature>\|<sentinel-cycle-marker>" src/path/to/file.py
732: # <Cycle 43 Fix>: clamp _compute_X to [0,1]
734: V_clamped = 1.0 / (1.0 + math.exp(-(V - 500) / 100))
The agent then commits its change against the pre-clamp version. The resulting commit:
- Cannot be cleanly cherry-picked to current `main` (the surrounding code has changed)
- Does not address the actual bug it was asked to fix (the bug's symptoms only manifest in the post-clamp version, which the agent never sees)
## Symptom 3 — Confirmation via custom hook logs
After installing a custom `SubagentStart` hook that logs the worktree refresh attempt, the following pattern appears for every newly-launched agent prior to the fix being implemented in the hook:
[timestamp] [INFO] [subagent_start] START
[timestamp] [INFO] [subagent_start] REFRESH check worktree=agent-<short-id> project_root=<repo-root>
[timestamp] [INFO] [subagent_start] REFRESH ok: agent-<short-id> aaaaaaaa -> cccccccc
[timestamp] [INFO] [subagent_start] END exit=0 duration=146ms
The `aaaaaaaa -> cccccccc` transition (showing the worktree's HEAD moved from the stale SHA to current main) confirms that:
- Every newly-launched worktree starts at SHA `aaaaaaaa` (stale)
- The current `main` SHA at the moment of agent launch is `cccccccc` (~170 commits ahead)
- The runtime did not perform this refresh on its own — it had to be added by a user-side hook
Without the hook, no `REFRESH ok` lines appear, and the agent operates against `aaaaaaaa` for the entirety of its run.
## Symptom 4 — Completion notification metadata is correct
The completion notification returned to the orchestrator correctly identifies the worktree path:
<task-notification>
<task-id>...</task-id>
<status>completed</status>
<worktreePath>/Users/<user>/software/<repo>/.claude/worktrees/agent-<short-id></worktreePath>
<worktreeBranch>worktree-agent-<short-id></worktreeBranch>
</task-notification>
The runtime knows which worktree the agent was assigned. It just doesn't ensure that worktree is at current `main` when it hands it over.
## Hook payload schema (relevant subset)
The `SubagentStart` hook receives this payload (observed via diagnostic logging):
keys = ['session_id', 'transcript_path', 'cwd', 'agent_id', 'agent_type', 'hook_event_name']
cwd = '/Users/<user>/software/<repo>/.claude/worktrees/agent-<short-id>' ← assigned worktree path
agent_id = '<full-agent-id>'
project_dir = '' ← NOT POPULATED for SubagentStart
Note: `project_dir` is empty for `SubagentStart` events. Hook authors must derive the project root from `cwd` (walk up from the worktree to the parent of `.claude/worktrees/`).
Steps to Reproduce
Steps to reproduce
Minimal reproduction (within a single session)
- Open a git repo with a non-trivial primary branch.
- Open Claude Code. Note current
mainHEAD:
````
git rev-parse main
# → aaaaaaa
- Launch agent A with
isolation: "worktree"doing trivial work (e.g., add a single file). - Wait for completion. Cherry-pick or fast-forward-merge agent A's commit to
main. Newmaintip:
````
git rev-parse main
# → bbbbbbb
- Launch agent B with
isolation: "worktree"doing trivial work in a different file. Take note ofworktreePathreturned in agent B's completion notification. - Inspect agent B's worktree:
````
cd <worktreePath>
git rev-parse HEAD
- Expected:
bbbbbbb(or equivalent fresh base from currentmain) - Actual:
aaaaaaa(the SHA from before agent A's work was merged)
Repeat for agents C, D, E, ... — every one's worktree HEAD remains aaaaaaa, even after dozens of intervening merges to main.
In-agent reproduction (verify from the agent's own perspective)
Launch a minimal probe agent with isolation: "worktree" and the following prompt:
No edits, no commits. Run these commands and report the output verbatim:
1. pwd
2. git rev-parse HEAD
3. git log -1 --format='%H %s' main
4. git rev-list --count HEAD..main
A correct (post-fix) run will report:
pwd= the worktree pathHEAD= currentmainSHAgit log -1 main= same SHA + commit subjectHEAD..maincount =0
A buggy (current) run will report:
pwd= the worktree path (correct)HEAD= the stale SHA (e.g.,aaaaaaa...)git log -1 main= currentmain(different SHA fromHEAD)HEAD..maincount = some positive integer (e.g., 170+)
Session-restart reproduction (open question)
Used to determine whether the staleness cache is in-process (would clear on restart) or on-disk (would persist):
- Reproduce the staleness as in §Minimal reproduction above. Note worktree HEAD:
git -C <worktreePath> rev-parse HEAD→aaaaaaa. - Exit Claude Code completely. Confirm process is terminated:
````
pgrep -fl claude
# (no output expected)
- Restart Claude Code in the same repo.
- Launch a new probe agent (as in §In-agent reproduction above).
- Observe new agent's worktree HEAD:
- If HEAD is still
aaaaaaa→ staleness persists across restarts (cache is on-disk worktree state). Fix must drain on-disk state. - If HEAD is current
main→ staleness was process-memory-only (cache is in-process). Fix can be in-runtime.
This test takes ~2 minutes and would resolve an immediate diagnostic question for maintainers.
Environment for the observed reproduction
- Claude Code: 2.1.116
- Node: v22.x
- OS: macOS arm64 (Darwin 24.x)
- Git: 2.x (system git)
The bug is almost certainly cross-platform (the worktree-management logic is in the runtime, not in any platform-specific code path), but only macOS arm64 has been confirmed.
Reproducibility offer
Reproducible on demand by launching ~5 sequential agents with isolation: "worktree" against any git repo with non-trivial commit activity during the session. We can provide a sanitized minimal repro script + redacted JSONL transcripts on request.
Claude Model
Not sure / Multiple models
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
2.1.116
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
User-side fix that mitigates the bug
A SubagentStart hook installed in <HOME>/.claude/hooks/dispatchers/subagent_start.py and registered via <HOME>/.claude/settings.json reliably refreshes each agent's worktree to current main SHA before the agent receives control.
This is a defensive workaround, not a substitute for the upstream fix — it requires the user to install custom hook infrastructure and to maintain it. The runtime should do this on its own.
Final working hook (relevant excerpt)
# Triggered by SubagentStart event.
# Refreshes the agent's worktree to current main SHA before the agent runs.
import subprocess
from pathlib import Path
# ...
# The SubagentStart payload includes `cwd` pointing at the agent's
# ASSIGNED worktree (not the main repo). Use it directly as the worktree
# path; derive the project root by walking up to the parent of
# `.claude/worktrees/`.
payload_cwd = payload.get("cwd", "")
worktree_dir: Path | None = None
project_root: str = ""
if payload_cwd and ".claude/worktrees/" in payload_cwd:
worktree_dir = Path(payload_cwd)
for parent in worktree_dir.parents:
if (parent / ".claude" / "worktrees").exists() and parent.name != "worktrees":
project_root = str(parent)
break
if not (worktree_dir and project_root):
log("INFO", f"REFRESH skip: no worktree CWD in payload (cwd={payload_cwd!r})")
elif not (worktree_dir.exists() and (worktree_dir / ".git").exists()):
log("INFO", f"REFRESH skip: worktree path not found at {worktree_dir}")
else:
try:
log("INFO", f"REFRESH check worktree={worktree_dir.name} project_root={project_root}")
dirty_raw = subprocess.check_output(
["git", "-C", str(worktree_dir), "status", "--porcelain"],
timeout=30, text=True,
)
# Only modified/staged tracked files block refresh; untracked ('??')
# files are not touched by `git reset --hard` and are safe to ignore.
tracked_dirty = [
ln for ln in dirty_raw.splitlines()
if ln and not ln.startswith("??")
]
if tracked_dirty:
log("WARN", f"REFRESH skip: worktree {worktree_dir.name} has "
f"{len(tracked_dirty)} modified/staged file(s)")
else:
main_sha = subprocess.check_output(
["git", "-C", project_root, "rev-parse", "main"],
timeout=30, text=True,
).strip()
wt_sha = subprocess.check_output(
["git", "-C", str(worktree_dir), "rev-parse", "HEAD"],
timeout=30, text=True,
).strip()
if wt_sha != main_sha:
subprocess.run(
["git", "-C", str(worktree_dir),
"reset", "--hard", main_sha],
timeout=60, check=True, capture_output=True,
)
log("INFO", f"REFRESH ok: {worktree_dir.name} {wt_sha[:8]} -> {main_sha[:8]}")
else:
log("INFO", f"REFRESH noop: {worktree_dir.name} already at main {main_sha[:8]}")
except (subprocess.TimeoutExpired,
subprocess.CalledProcessError, OSError) as e:
log("WARN", f"REFRESH failed: {e}")
Settings.json registration
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/dispatchers/subagent_start.py",
"timeout": 90
}
]
}
]
The default 5-second timeout is too short — even on a clean repo, git operations + repo-side hooks (pre-commit, etc.) can take 10–60 seconds. We bumped to 90s.
Verification evidence
After installing the hook, a probe agent launched with isolation: "worktree" reported:
pwd: /Users/<user>/software/<repo>/.claude/worktrees/agent-a2621d3d
git rev-parse HEAD: c7b286e22ae402b39942e8a288bfb6b198686cb7 ← current main
Hook log for that agent's launch:
[timestamp] [INFO] START
[timestamp] [INFO] REFRESH check worktree=agent-a2621d3d project_root=/Users/<user>/software/<repo>
[timestamp] [INFO] REFRESH ok: agent-a2621d3d a148ab1a -> c7b286e2
[timestamp] [INFO] END exit=0 duration=146ms
The worktree was at the stale SHA a148ab1a when the runtime created it (per the bug). The hook fired at SubagentStart, detected the divergence, and fast-forwarded to current main before the agent received control. Total overhead: 146ms.
What didn't work (and why)
We iterated through several broken versions before arriving at the working one. Documenting these in case other users hit the same dead ends:
Attempt 1 — Worktree refresh placed inside if db is None: return 0 early-return
The original subagent_start.py returned early if no project session DB existed. We initially placed our worktree refresh code after the early-return gate. The hook fired but the refresh code was never reached because the DB check bailed out first.
Fix: hoist the worktree refresh logic before any DB-related checks. The refresh only needs agent_id and the worktree path — neither of which depends on the DB.
Attempt 2 — Worktree path derived from project_dir
The PayloadParser exposes a project_dir field. We tried to construct the worktree path as:
worktree_dir = Path(project_dir) / ".claude" / "worktrees" / f"agent-{agent_id[:8]}"
But SubagentStart payloads have project_dir = "". The available fields are: session_id, transcript_path, cwd, agent_id, agent_type, hook_event_name. Of these, only cwd provides path information — and it points directly to the agent's assigned worktree.
Fix: use cwd from the payload as the worktree path; derive the project root by walking up parents to the one containing .claude/worktrees/.
Attempt 3 — Doubled path
After switching to use cwd, an early version still appended .claude/worktrees/agent-XXX to the cwd, producing paths like agent-a034bfa0/.claude/worktrees/agent-a034bfa0. This silently failed worktree_dir.exists() and the hook skipped the refresh.
Fix: when cwd is in the payload and already contains .claude/worktrees/, treat it as the worktree path itself (don't append to it).
Attempt 4 — Untracked files counted as "dirty"
git status --porcelain returns ?? path/ lines for untracked files. Our initial dirty-check considered any non-empty output as dirty and skipped the refresh. Every agent worktree had ?? .claude/ (an untracked directory the runtime creates), so the refresh always skipped.
Fix: filter out lines starting with ?? before deciding the worktree is dirty. git reset --hard does not touch untracked files, so they're safe to ignore. Only modified-tracked ( M, M , MM, etc.) lines should block.
Attempt 5 — Default 5s hook timeout
The settings.json "timeout": 5 killed the hook before git could complete. The user's per-repo git hooks (pre-commit, etc.) added latency that pushed the total over 5s.
Fix: bump the registered hook timeout in settings.json to 90s. Internal subprocess timeouts in the Python hook also bumped from 5s to 30s for read operations and 60s for git reset --hard.
What this fix does NOT solve
- The CWD inheritance bug (#39886) — the agent process still inherits the parent shell's CWD, not the worktree's CWD. A complementary defensive prelude in the agent prompt is still required to detect and abort when the agent finds itself in the main repo instead of its worktree.
- Concurrent-agent HEAD contention — when multiple agents launch concurrently and the CWD bug is also present, they share the main repo's working directory and can steal each other's
HEADvia concurrentgit checkout -bcalls. This requires either fixing the CWD bug or serializing agent launches.
- The fundamental cause — the runtime is creating worktrees with a stale base. This hook patches each worktree retroactively. The right fix is in the runtime, where worktrees are created.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗