# PR #22825 proof audit (pass 1): runtime fixture matrix

After = `ab1166d777` (PR head, merge of main). Before = `ab1166d777^2` (`8d3b061121`, main).
Diff under test: `ProviderSessionManager.detach` (`apps/server/src/orchestration-v2/ProviderSessionManager.ts` ~L2352-2425).

## What the diff actually changes

- Guard: before, the code interrupted running turns on detach only when `supportsMultipleProviderThreads === true` (Codex pooled sessions). After, it does so whenever the session has the thread attached (`attachedThreadIds.has(threadId)`).
- Exclusive sessions (Claude: `supportsMultipleProviderThreadsPerSession: false`, `Adapters/ClaudeAdapterV2.ts:356`) now call `interruptTurn` and **wait up to 30 s (`DETACH_TURN_TERMINAL_TIMEOUT_MS`) for that turn's `turn.terminal`** before `releaseEntry(manual_shutdown)` closes the session. Before, a Claude session was released with the turn still running. The adapter stream then failed with `ProviderAdapterEventStreamError … cause: Error: Workspace changed.`, and the run ended `failed` ("The provider event stream closed unexpectedly…").
- Why that matters: in `Orchestrator.ts` ~L1455-1476, when the previous run failed with a non-validation `failureClass` on the same provider instance, every queued run gets `queueHeld: true`. The handoff's continuation (run 2) is queued, so it never starts. After the fix, run 1 ends `interrupted`, the queue is not held, and run 2 starts in the worktree.
- Codex (multi-thread) behaviour is unchanged: it already interrupted turns and does not wait.

## Whose session does a handoff detach? (correction to the brief)

- `t3_worktree_handoff` (`apps/server/src/mcp/WorktreeMcpService.ts`) acts on the **calling** thread. It dispatches `thread.metadata.update{worktreePath}`. Because `worktreePath` changes, `Orchestrator.ts` ~L3668-3720 emits `provider-session.detached` (reason "Workspace changed.") plus a `provider-session.detach` outbox effect for **that thread's** sessions only. `EffectWorker.ts:121` then calls `ProviderSessionManager.detach`.
- Claude session ids are allocated per thread (`providerSessionIdFor`, `Orchestrator.ts` ~L962-979: `allocate.providerSession({threadId})`). Codex derives one session per instance. **A Claude child never shares its parent's provider session**, so a parent's handoff cannot detach or interrupt a Claude child. (A Codex child would share the pooled session, but detach is still keyed by `threadId`, so only the parent's turns are interrupted.)
- The bug in issue #15136 and the PR's test (`SelectionRestart.integration.test.ts`, "continues a delegated %s child after t3_worktree_handoff") is a **child calling `t3_worktree_handoff` on itself**. The child is the thread whose Claude session gets detached while its run 1 (the turn making the MCP call) is still running.
- Also note: a handoff is always made from inside a running turn, so **every** Claude handoff hits the changed path, delegated or not.

So I reframe R1 and add R1b for the literal "parent hands off" reading.

## Matrix

| Row | Fixture | Detach exercised | Before (`8d3b061121`) | After (`ab1166d777`) | Difference expected? |
|---|---|---|---|---|---|
| **R1** (issue repro) | Parent thread (Codex or Claude) calls `delegate_task` async, target `claudeAgent`, `completionWake` default. The child calls `t3_worktree_status`, then, as its last action, `t3_worktree_handoff{branch, baseRef:"main", startFromOrigin:false, runSetupScript:false, continuationPrompt}` | Child thread's exclusive Claude session. Its run 1 turn is `running` at detach | Child run 1 `failed`. Server WARN `orchestration V2 provider event ingestion failed … ProviderAdapterEventStreamError … Workspace changed.` Handoff tool item `cancelled`/stuck `running`. Run 2 `queued`, `queueHeld:true`, `startedAt:null`. Parent receives a premature `failed` delegated result, or none. `task_status` says `failed` | Child run 1 `interrupted`, with no ingestion-failed WARN. Run 2 goes to `starting` then `running` with cwd = new worktree, then `completed`. The parent receives exactly one delegated-completion message after run 2, `task_status`=`completed`. One Claude session closed (`closedSessionCount`=1 in the test). The new session opens with cwd = worktree | **Yes** (the core proof) |
| R1-codex (control) | Same as R1 but the child targets `codex` | Child thread on the pooled Codex session (multi-thread branch) | Run 1 `interrupted`, run 2 completes | Same | No. Use it as the control to show nothing regressed |
| **R1b** (brief's literal wording) | **Claude** parent starts an async Claude child (long-ish but tool-free task), then the parent itself calls `t3_worktree_handoff` with a `continuationPrompt` while the child runs | Parent's own Claude session (the parent's run 1 is running). The child's session is untouched: separate session id | The child keeps running in the original cwd (it was never affected). Parent run 1 `failed`, parent continuation `queueHeld:true`. The child's completion delivery is queued on the parent behind the held queue, so it probably never reaches the parent (**verify**). If a parent run failing disposes or holds the delegated cohort, that also shows up here | Parent run 1 `interrupted`, parent continuation runs in the worktree. The child completes and its result is delivered to the parent (**verify** whether the delivery lands as its own run or is steered into the continuation) | Yes, but on the **parent**. The child's own lifecycle is identical in both builds. If the brief means this row, label it "parent continuation and delivery", not "child keeps running" |
| **R2** (interrupt still works) | R1, then while child run 2 (the continuation, in the worktree) is running, dispatch `run.interrupt` on the child thread (WS `orchestration.dispatchCommand`), or have the parent call `task_cancel` | Not the diff's path. This goes through `ProviderTurnControl` interrupt on the child's **new** session. If it races the detach, `interruptTurn` returns a ProtocolError ("not the active turn"), which is caught and logged as `orchestration-v2.driver-session.detach-interrupt-failed` | **Unreachable as specified**: run 2 never starts (`queueHeld`). The nearest step is `queued-run.cancel` on the held run 2. Record that it cancels and that the parent's result stays `failed` | Run 2 → `interrupted`. The task becomes terminal and the parent is woken once with `interrupted`/stopped status. `task_cancel` stops the child thread and disposes delivery | Before/after differs only in reachability. The after build must show a clean interrupt and no hang |
| **R3** (no child) | **Claude** top-level thread (no delegation) calls `t3_worktree_handoff` with a `continuationPrompt` | Same exclusive-session detach as R1, on a top-level thread | Run 1 `failed`, continuation held, as in R1 | Run 1 `interrupted`, continuation starts in the worktree | **Yes, for Claude.** The brief's "unchanged" holds only for **Codex** (R3-codex), or for a Claude handoff with no running turn, which cannot happen through MCP |
| R3-codex (control) | Codex top-level thread handoff | Multi-thread branch | Run 1 interrupted, continuation runs | Same | No |
| R4 (optional, no MCP) | A Claude thread with a running tool-free turn (a long answer), then the driver dispatches `thread.metadata.update{worktreePath:<existing worktree>, branch, expectedWorktreePath:null}` over WS (that is what the handoff does internally). Variants: `thread.archive`, `thread.runtime-mode.set` while running | Same detach path, reason "Workspace changed." / "Thread archived." / "Runtime mode changed." | Run `failed` with a stream-closed error | Run `interrupted`; the session closes after `turn.terminal` | Yes. This is the scope change beyond handoff (archive and mode-change on a running Claude turn now interrupt cleanly). **Verify** that the server accepts the metadata update while a run is active; it may be rejected |

## How a running server reaches each row

- **Server**: one worktree per side. Before = a detached worktree at `ab1166d777^2`, after = `/home/ubuntu/wt/pr-22825`. Run each with `vp i`, then `env -u T3CODE_HOME vp run dev --home-dir /tmp/ev-22825/{before,after}/home --no-seed` in the background. Track the PID and read ports from `[dev-runner]`. Never use `~/.t3/userdata`, and never set `VITE_*_URL`.
- **Same seeded state**: build one seed home, `/tmp/ev-22825/seed/home`, holding a project row that points at a throwaway git repo (`git init /tmp/ev-22825/repo-{before,after}` with one commit on `main`; use one repo per side so worktrees and branches don't collide). Copy the seed into each side's home. Optionally copy `settings.json` (with the provider instances) from `~/.t3/userdata` read-only. No `migrate-dev-db` is needed: an empty-thread project is enough for these rows.
- **Auth**: `node apps/server/src/bin.ts pair --home-dir …` mints a token for the driver's WS client.
- **Driver entry points (WS RPC)**:
  - Project: the project-create mutation (`project.create`), or seed the row directly.
  - `orchestration.dispatchCommand` with `thread.create` and `message.dispatch{dispatchMode:{type:"start_immediately"}}` for the parent prompt.
  - `run.interrupt` (R2), `queued-run.cancel` (R2 before), and `thread.metadata.update` (R4).
  - `orchestration.subscribeThread` / `orchestration.getThreadProjection` to capture state (parent and child).
- **MCP tools** (`delegate_task`, `t3_worktree_status`, `t3_worktree_handoff`, `task_status`, `task_cancel`) need a thread-scoped MCP credential that only the provider session holds. A driver cannot call them directly, so **real agent turns must make these calls**. That is feasible: the earlier before-capture in the PR body did it with a Codex parent and a Claude child. Prompt rules: the parent may only call `delegate_task`, `task_status` and `t3_worktree_handoff` (R1b). The child may only call `t3_worktree_status` then `t3_worktree_handoff`. The continuation prompt is "Reply with the single word DONE. Do not use any tools or edit files." The original repro's `pwd`/proof file is optional; cwd is already provable from the session's open cwd in the provider log. Claude goes through the CLIProxy hub. Hub capacity errors are hub state, not a failure of this row.
- **Repo fixtures/test layers** (fallback; they cannot be loaded into a running server): `apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts` (`layerWithRegistry`, `runEffectWorker:false`), the restart adapter in `SelectionRestart.integration.test.ts` (`makeRestartAdapter` + `ClaudeProviderCapabilitiesV2`), and the Claude replay fixtures in `testkit/fixtures/` (`ClaudeReplayFixtures.integration.test.ts`). For before/after at the test level, copy the new test onto `^2`. It should fail there (`oldRunStatus` failed, continuation held) and pass on the head; this audit did **not** run either. Run `vp test run apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts -t "worktree_handoff"` on each side.

## What the capture must record (per row, both sides)

- Child and parent `runs[]`: `id, ordinal, status, queueHeld, requestedAt, startedAt, completedAt`, and failure summary/class.
- Domain events with timestamps: `provider-session.detached` (reason), the child's `provider-turn.updated` (`running` → `interrupted|failed`), `run.updated` for run 2 (`queued` → `starting` → `running` → `completed`, or `queueHeld:true`), and `subagent.updated` (status, `completionDelivery.state`).
- The parent's `messages[]` with `delegatedCompletion` (count, timing relative to child run 2's terminal), plus `task_status` (`status, workState, latestTerminalStatus, summary, hasPendingChildRuns`).
- The handoff tool's turn item: status and result (`continuation.status: "scheduled"`).
- `thread.worktreePath` and `branch` after the handoff. The cwd of the new Claude session for run 2, from `logs/provider/events.thread-*.log` or session-open events.
- Server log lines:
  - Before: `orchestration V2 provider event ingestion failed` with `ProviderAdapterEventStreamError … Workspace changed.`
  - After: none of those. Any `orchestration-v2.driver-session.detach-interrupt-failed` (a TimeoutError here means the 30 s wait elapsed: report it as a defect signal) and `orchestration-v2.claude-query-interrupt-timeout`.
  - **Timing between the detach and the session close**, since the after build blocks one effect-worker slot for up to 30 s.

## Rows likely to show no before/after difference

R1-codex and R3-codex (controls), and the child's own lifecycle in R1b. R2 differs only because the before build cannot reach a running run 2.

## Unreachable or weak rows, and fallbacks

- R2 as specified cannot be reached on before. Capture `queued-run.cancel` on the held run there, and show a real `run.interrupt` of run 2 on after.
- If R1b is ambiguous because of how delivery works (steer vs queued run), report what happened. The diff gives no mechanism by which a parent handoff could affect a Claude child.
- If the hub cannot run Claude turns, fall back to the integration test on both SHAs (it should fail before and pass after; not yet run). That is test-level, not runtime evidence; say so in the PR.
