claude-code - 💡(How to fix) Fix [BUG] isolation: "worktree" produces worktrees pinned to a stale base SHA [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#51545Fetched 2026-04-22 07:59:26
View on GitHub
Comments
1
Participants
2
Timeline
5
Reactions
0
Author
Timeline (top)
labeled ×4commented ×1

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.

Error Message

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. log("WARN", f"REFRESH skip: worktree {worktree_dir.name} has " log("WARN", f"REFRESH failed: {e}")

Root Cause

  • ~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

Fix Action

Fix / Workaround

  • Manually verify each agent's worktree base SHA before trusting its output
  • Manually rebase agent branches onto current main before merging
  • Add defensive prelude blocks to every agent prompt to detect stale-base conditions
  • Maintain a custom SubagentStart hook to refresh worktree base SHAs as a workaround

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.

Code Example

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).

---

$ 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

---

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)

---

# 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 1All 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 2Agent'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 3Confirmation 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 4Completion 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/`).

---

git rev-parse main
   # → aaaaaaa

---

git rev-parse main
   # → bbbbbbb

---

cd <worktreePath>
   git rev-parse HEAD

---

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

---

pgrep -fl claude
   # (no output expected)

---

# 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}")

---

"SubagentStart": [
  {
    "hooks": [
      {
        "type": "command",
        "command": "python3 ~/.claude/hooks/dispatchers/subagent_start.py",
        "timeout": 90
      }
    ]
  }
]

---

pwd:                /Users/<user>/software/<repo>/.claude/worktrees/agent-a2621d3d
git rev-parse HEAD: c7b286e22ae402b39942e8a288bfb6b198686cb7   ← current main

---

[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

---

worktree_dir = Path(project_dir) / ".claude" / "worktrees" / f"agent-{agent_id[:8]}"
RAW_BUFFERClick to expand / collapse

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet
  • This is a single bug report (please file separate reports for different bugs)
  • 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

  • #39886isolation: "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)

  1. (Highest leverage) Refresh worktree HEAD to current main on every Agent invocation with isolation: "worktree". Equivalent to running git -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 by git reset --hard and should not block the refresh.
  2. 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.
  3. 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.
  4. Provide a cwd: parameter on the Agent tool 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 Agent launched with isolation: "worktree" at any point in a session, git -C <worktreePath> rev-parse HEAD should equal the current main SHA at launch time (or be a descendant of it).
  • git -C <worktreePath> rev-list --count HEAD..main should 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 --hard does 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 main before merging
  • Add defensive prelude blocks to every agent prompt to detect stale-base conditions
  • Maintain a custom SubagentStart hook 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)

  1. Open a git repo with a non-trivial primary branch.
  2. Open Claude Code. Note current main HEAD:
    git rev-parse main
    # → aaaaaaa
  3. Launch agent A with isolation: "worktree" doing trivial work (e.g., add a single file).
  4. Wait for completion. Cherry-pick or fast-forward-merge agent A's commit to main. New main tip:
    git rev-parse main
    # → bbbbbbb
  5. Launch agent B with isolation: "worktree" doing trivial work in a different file. Take note of worktreePath returned in agent B's completion notification.
  6. Inspect agent B's worktree:
    cd <worktreePath>
    git rev-parse HEAD
  7. Expected: bbbbbbb (or equivalent fresh base from current main)
  8. 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 path
  • HEAD = current main SHA
  • git log -1 main = same SHA + commit subject
  • HEAD..main count = 0

A buggy (current) run will report:

  • pwd = the worktree path (correct)
  • HEAD = the stale SHA (e.g., aaaaaaa...)
  • git log -1 main = current main (different SHA from HEAD)
  • HEAD..main count = 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):

  1. Reproduce the staleness as in §Minimal reproduction above. Note worktree HEAD: git -C <worktreePath> rev-parse HEADaaaaaaa.
  2. Exit Claude Code completely. Confirm process is terminated:
    pgrep -fl claude
    # (no output expected)
  3. Restart Claude Code in the same repo.
  4. Launch a new probe agent (as in §In-agent reproduction above).
  5. Observe new agent's worktree HEAD:
    • If HEAD is still aaaaaaastaleness persists across restarts (cache is on-disk worktree state). Fix must drain on-disk state.
    • If HEAD is current mainstaleness 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 HEAD via concurrent git checkout -b calls. 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.

extent analysis

TL;DR

The most likely fix for the issue is to refresh the worktree HEAD to the current main SHA on every Agent invocation with isolation: "worktree".

Guidance

  • The issue is caused by the worktree HEAD being pinned to a stale base SHA after the primary branch advances within a single session.
  • To fix this, the worktree HEAD should be refreshed to the current main SHA before spawning the agent process.
  • The refresh should be skipped if the worktree has modified or staged tracked files.
  • The fix can be implemented by running git -C <worktree> reset --hard <current_main_sha> immediately before spawning the agent process.

Example

import subprocess

# Get the current main SHA
main_sha = subprocess.check_output(["git", "rev-parse", "main"], text=True).strip()

# Refresh the worktree HEAD to the current main SHA
subprocess.run(["git", "-C", "<worktree>", "reset", "--hard", main_sha], check=True)

Notes

  • The fix should be implemented in the runtime, where worktrees are created.
  • The provided hook is a defensive workaround and not a substitute for the upstream fix.
  • The hook should be registered in the settings.json file with a sufficient timeout to allow for git operations to complete.

Recommendation

Apply the workaround by implementing the SubagentStart hook as described in the issue body, until the upstream fix is available. This will ensure that the worktree HEAD is refreshed to the current main SHA before the agent process is spawned.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

claude-code - 💡(How to fix) Fix [BUG] isolation: "worktree" produces worktrees pinned to a stale base SHA [1 comments, 2 participants]