# PR #22836 evidence captures: fork Claude threads after project root changes

**Environment:** Linux 6.1.102 x86_64 (fleet box `fs-20`), Node v26.12.1, Claude Code CLI 2.1.303, `@anthropic-ai/claude-agent-sdk` 0.3.276, model `claude-sonnet-4-6` through the CLIProxyAPI hub (real turns, **no recorded fixtures in the driver rows**). The hub rejected `claude-haiku-4-5` with "unknown provider".
**Before:** merge-base `8d3b061121fe65669d5d43d011b48cd3027a4108`, in a fresh worktree. **After:** PR head `6c15210198de997acd26c2cee02a749ddcce57f6`.

**Driver:** `apps/server/scripts/evidence/pr-22836-fork-moved-root.ts`, committed in this PR. The same file ran at both SHAs. It starts a real `apps/server/src/bin.ts start` from the checkout with `--base-dir <out>/t3-home` and `T3CODE_HOME=<out>/t3-home`, so the ambient `~/.t3` never wins. It also sets `CLAUDE_CONFIG_DIR=<out>/claude-home`, seeded only with the host settings `env` keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, and `NODE_EXTRA_CA_CERTS`. The server process and the adapter share that `CLAUDE_CONFIG_DIR`, so `usesHostConfig=true`, which is the **in-process** fork path (R1, the default-home case). The before stack trace confirms it: `Effect.tryPromise` → SDK `sdk.mjs` → `ClaudeAdapterV2.ts:1173`.
The driver talks to the server over the real HTTP `/api/projects/mutate` endpoint (`project.create`, `project.update{workspaceRoot}`) and the WS RPCs `orchestration.launchThread`, `orchestration.dispatchCommand` (`thread.fork`, `message.dispatch`, `thread.merge_back`), and `orchestration.getThreadProjection`.
Flow per scenario: create a project at dir A (a git repo) → source thread runs one turn → *(moved only: `cp -r A B`, then `project.update workspaceRoot=B`)* → fork twice from the source run, with one turn in each fork → merge fork a back, then a source turn → merge fork b back, then a source turn.

## Row summary

| Row | Scenario | Before `8d3b061121` | After `6c15210198` | Evidence |
|---|---|---|---|---|
| **R1** | Fork after workspaceRoot moved A→B, in-process (host-config) path | ❌ Both forks: `thread.fork` is accepted, then the first fork turn **fails**. The SDK throws `Session d4863166-… not found in project directory for …/moved-b` (8× in server.log). The fork's provider thread is stuck `active` with `nativeId=null`, the fork context transfer is stuck `pending` (`resolution=null`), and the turn item `provider-thread-load-failure` reads "Provider turn failed to start". No fork transcript is created. | ✅ Both forks: run `completed`, transfer `consumed/native_fork`, new native ids `ef957d10…` / `5e2615b0…`. Replies: "fork a ok." / "fork b ok." | live driver |
| **R2** | Same, with a custom `CLAUDE_CONFIG_DIR` (worker path) | ❌ The head's test `forks a session from its original directory after the workspace root changes` **fails**: `Session 11111111-1111-4111-8111-111111111111 not found in project directory for /workspace` (1 failed, 206 skipped) | ✅ 1 passed, 206 skipped | vitest, real SDK `forkSession` against a temp Claude home |
| **R3** | Control: fork with cwd unchanged | ✅ 2 forks completed, `consumed/native_fork` | ✅ identical shape | live driver, plus at head `ThreadFork.integration` (recorded SDK fixture replay) |
| **R7** | Sibling forks, then merge-back into the source | Control ✅ both merges accepted → `consumed/fork_delta_context`. **Moved ❌** both merges **rejected**: `OrchestrationV2DispatchCommandError: Merge-back source run run:thread:<fork>:ordinal:1 is failed; only provider-finished runs are supported.` | Control ✅. **Moved ✅** both merges accepted → `consumed/fork_delta_context`, and the source turns after them completed | live driver, plus at head `ThreadMergeBack.integration` (recorded SDK fixture replay) |
| **R11** | Continue the source thread after the move | ✅ source turns 2 and 3 completed ("source turn 2 ok.", "source turn 3 ok.") | ✅ same | live driver |
| R4 | Fork at an earlier turn (`upToMessageId` at a prior boundary) | unverified | unverified | Not driven live. At head, `ThreadFork.integration` covers it with a recorded fixture: `thread_fork_native_prior_turn_source_rollback`, which passed |
| R5 | Whole-thread fork with `upToMessageId` undefined | unverified | unverified | No caller path found to drive it in the time budget. The driver always forks from a run |
| R6 | `runtimePolicy.cwd === null` | unverified | unverified | No behaviour change by construction (the `dir` key was already omitted). Not driven |
| R8 | Fixture-only edits (`*_continue`, `*_siblings`) | n/a | n/a | No test consumes them |
| R9 | Same session id in two slugs (symlink workaround) | unverified | unverified | Not driven; needs a manual symlink probe |
| R10 | Subagent launch lookup after a move | unverified | unverified | Out of scope per the issue, and unchanged code |

### Head-only test runs (recorded SDK fixtures)
- `vp test run src/orchestration-v2/testkit/ThreadFork.integration.test.ts src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts` at `6c15210198`: **Test Files 2 passed, Tests 6 passed**. These are **recorded SDK fixture** replays: they prove the outbound `session.fork` frame shape (no `dir`), not live provider behaviour.

## Persisted state per thread (read via `orchestration.getThreadProjection`, full JSON in `projection-*.json`)

### final-before (`8d3b061121`)

| scenario | thread | runs | provider-thread status / nativeId | fork transfer | merge-back transfers | assistant |
|---|---|---|---|---|---|---|
| control | source `53612f20` | completed,completed,completed | idle / `e671b4a8-f48f-4d53-9a5e-e72bfc1d6dea` | - | consumed/fork_delta_context, consumed/fork_delta_context | source ok. · source turn 2 ok. · source turn 3 ok. |
| control | fork-a `59d55047` | completed | idle / `58014fb6-ae9d-48c1-91df-15db5bcc7f86` | consumed/native_fork | - | fork a ok. |
| control | fork-b `2f7e1199` | completed | idle / `dcfbc339-26a1-44d7-808d-e936088427e4` | consumed/native_fork | - | fork b ok. |
| control | merge-back `59d55047`→source | | | | accepted | source turn completed |
| control | merge-back `2f7e1199`→source | | | | accepted | source turn completed |
| moved | source `6f7ad23e` | completed,completed,completed | idle / `d4863166-62ef-4bbb-a5b6-c21014414f24` | - | - | source ok. · source turn 2 ok. · source turn 3 ok. |
| moved | fork-a `d05f8f07` | failed | active / `None` | pending/None | - | (none) |
| moved | fork-b `24e2506d` | failed | active / `None` | pending/None | - | (none) |
| moved | merge-back `d05f8f07`→source | | | | rejected: OrchestrationV2DispatchCommandError: Merge-back source run run:thread:d05f8f07-63e3-4159-8597-5ca210383076:ordinal:1 is failed; only provider-finished | source turn completed |
| moved | merge-back `24e2506d`→source | | | | rejected: OrchestrationV2DispatchCommandError: Merge-back source run run:thread:24e2506d-de3a-4f7f-ac12-27973a674af5:ordinal:1 is failed; only provider-finished | source turn completed |

Transcripts under isolated CLAUDE_CONFIG_DIR/projects:

- `-tmp-ev-22836-final-before-workspaces-control-a`: `58014fb6-ae9d-48c1-91df-15db5bcc7f86.jsonl`, `dcfbc339-26a1-44d7-808d-e936088427e4.jsonl`, `e671b4a8-f48f-4d53-9a5e-e72bfc1d6dea.jsonl`
- `-tmp-ev-22836-final-before-workspaces-moved-a`: `d4863166-62ef-4bbb-a5b6-c21014414f24.jsonl`

### final-after (`6c15210198`)

| scenario | thread | runs | provider-thread status / nativeId | fork transfer | merge-back transfers | assistant |
|---|---|---|---|---|---|---|
| control | source `bfc38dc1` | completed,completed,completed | idle / `44000fe0-b7c8-4b51-a5f2-4947fbd48951` | - | consumed/fork_delta_context, consumed/fork_delta_context | source ok. · source turn 2 ok. · source turn 3 ok. |
| control | fork-a `9fbb92c2` | completed | idle / `fee69bb8-d389-4430-8d98-71459589e9cc` | consumed/native_fork | - | fork a ok. |
| control | fork-b `0d4f997f` | completed | idle / `69746269-3623-481a-aed9-2d7aa94142f4` | consumed/native_fork | - | fork b ok. |
| control | merge-back `9fbb92c2`→source | | | | accepted | source turn completed |
| control | merge-back `0d4f997f`→source | | | | accepted | source turn completed |
| moved | source `112feabe` | completed,completed,completed | idle / `8e7d4991-bd0d-48e9-970e-9ea1437a89b1` | - | consumed/fork_delta_context, consumed/fork_delta_context | source ok. · source turn 2 ok. · source turn 3 ok. |
| moved | fork-a `83a22389` | completed | idle / `ef957d10-c543-44e4-849c-10dd9277eaec` | consumed/native_fork | - | fork a ok. |
| moved | fork-b `c8813777` | completed | idle / `5e2615b0-faf5-4fb0-bbe8-19dd2e71a8ec` | consumed/native_fork | - | fork b ok. |
| moved | merge-back `83a22389`→source | | | | accepted | source turn completed |
| moved | merge-back `c8813777`→source | | | | accepted | source turn completed |

Transcripts under isolated CLAUDE_CONFIG_DIR/projects:

- `-tmp-ev-22836-final-after-workspaces-control-a`: `44000fe0-b7c8-4b51-a5f2-4947fbd48951.jsonl`, `69746269-3623-481a-aed9-2d7aa94142f4.jsonl`, `fee69bb8-d389-4430-8d98-71459589e9cc.jsonl`
- `-tmp-ev-22836-final-after-workspaces-moved-a`: `5e2615b0-faf5-4fb0-bbe8-19dd2e71a8ec.jsonl`, `8e7d4991-bd0d-48e9-970e-9ea1437a89b1.jsonl`, `ef957d10-c543-44e4-849c-10dd9277eaec.jsonl`
- `-tmp-ev-22836-final-after-workspaces-moved-b`: (empty dir)

## Raw excerpts

**Before, moved fork (server.log):**
```
[cause]: UnknownError: An error occurred in Effect.tryPromise
  at queryRunnerError (…/before/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts:614:7)
  at …/ClaudeAdapterV2.ts:1173:38
  [cause]: Error: Session d4863166-62ef-4bbb-a5b6-c21014414f24 not found in project directory for /tmp/ev-22836/final-before/workspaces/moved-b
      at b9 (…/@anthropic-ai/claude-agent-sdk/sdk.mjs:209:7157)
```
Fork run `run:thread:d05f8f07-…:ordinal:1`: `status: "failed"`, `startedAt: null`. Attempt: `providerTurnId: null`, `status: "failed"`. Turn item `…:signal:provider-thread-load-failure`: `status: "failed"`, `title: "Provider turn failed to start"`.

**After, moved forks: where the transcripts landed.** All are under the **original** slug `-tmp-ev-22836-final-after-workspaces-moved-a`:
```
ef957d10-….jsonl  cwd entries {moved-a: 11, moved-b: 5}  assistant ["source ok.", "fork a ok."]
5e2615b0-….jsonl  cwd entries {moved-a: 11, moved-b: 5}  assistant ["source ok.", "fork b ok."]
8e7d4991-….jsonl  cwd entries {moved-a: 18}              assistant ["source ok.", "source turn 2 ok.", "source turn 3 ok."]   (source)
```
The fork copies the source history (cwd A, under slug A). The fork's own turn then resumes the forked id with `cwd = B` (5 entries with `moved-b`) and appends to the same file. Claude created an empty `…-moved-b` project dir.

**R2 vitest (before):**
```
FAIL  src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts > ClaudeAdapterV2 native fork > forks a session from its original directory after the workspace root changes
Error: Session 11111111-1111-4111-8111-111111111111 not found in project directory for /workspace
Test Files  1 failed (1)    Tests  1 failed | 206 skipped (207)
```
**R2 vitest (after):** `Test Files  1 passed (1)    Tests  1 passed | 206 skipped (207)`

## What else changed (before vs after, beyond the targeted rows)
- Control-scenario rows match field for field in shape: run statuses, transfer statuses and strategies, assistant text, and all three transcripts under slug A. Ids and timestamps differ, as expected.
- After, there is an extra empty `projects/-…-moved-b/` dir in the Claude home. It comes from the fork's resumed turn running at cwd B. Before, the fork never ran, so the dir doesn't exist.
- **Observation, identical at both SHAs (not a regression):** after the move, the *source* thread's continued turns still record `cwd = A` (`moved-a`: 18 entries, zero `moved-b`). The source's live Claude session or worktree binding is not re-pointed at B by `project.update`. Only newly forked threads pick up the new root. This is outside this PR's scope; flagged for the issue.
- The PR branch merges 25 commits from main (`fae31aabea` → `6c15210198`). Before is the merge-base `8d3b061121`, which already contains those 25 commits, so the only server delta between the two runs is this PR's diff (9 files, +72/−29 in `apps/server/src`). Production code: one hunk in `ClaudeAdapterV2.ts` `forkThread`.
- A first exploratory run pair (`before-run/`, `after/`, made before cosmetic lint/typecheck edits to the driver) gave the same outcomes for every row.
- Driver note: two pending merge-backs into one thread are rejected ("pending merge-back transfers from multiple forks"), so the driver interleaves merge → source turn, as `ThreadMergeBack.integration` does.

## Isolation
- T3 state lived only under `/tmp/ev-22836/*/t3-home`. The first attempt without `--base-dir` was **refused at startup** by `ServerAlreadyRunningError` (an ambient `T3CODE_HOME=~/.t3`), so nothing was written to `~/.t3/userdata`. The driver now forces both `--base-dir` and `T3CODE_HOME`.
- Claude state lived only under `/tmp/ev-22836/*/claude-home`. No credential dirs were copied.
- Every server was spawned by the driver as a detached group and stopped by its captured PID (`server.pid`). All of them were verified exited.
