# PR #22830 (GitCafe #22829): proof matrix, pass 1

**Head** `aa57d7d3ba` (fix/issue-15250). **Before** `8d3b061121` (merge-base).
**Scope** `apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts`, the `task.model === null` branch of `registerSubagentThread` (around line 3019), plus 2 new tests.

## What the client sees

- The adapter emits `subagent.updated`. `ProviderEventIngestor` turns it into a `subagent.updated` orchestration event, and `ProjectionStore.ts:847` upserts it into `orchestration_v2_projection_subagents.payload_json`.
- The fields are `$.model` (string|null) and `$.modelSelection` (`{instanceId, model, options:[reasoningEffort, serviceTier]}`).
- The web and mobile clients render it through `packages/client-runtime/src/state/subagentDisplay.ts:76` (`resolveSubagentMetadata`). A null or blank `model` shows as **"Not reported"**.
- Read it back (read-only, isolated home only):
  ```sh
  sqlite3 "file:$H/userdata/statev2.sqlite?mode=ro" \
    "select subagent_id,status,json_extract(payload_json,'$.model'),json_extract(payload_json,'$.modelSelection') from orchestration_v2_projection_subagents"
  sqlite3 "file:$H/userdata/statev2.sqlite?mode=ro" \
    "select count(*) from orchestration_v2_events where type='subagent.updated'"   # check the column name against 055_OrchestrationV2.ts
  ```
- Retry cadence evidence: the fake app-server logs every request it receives, with a timestamp (see the recipe).

## Mechanics of the change

**Before:** one `thread/read` (includeTurns:false). If it errors, fails to decode, has an id mismatch, or returns a blank model, the code falls through to one `thread/resume` (excludeTurns:true). The whole chain has a 5s timeout. Any failure is swallowed and the model stays null forever. The only guard is `!subagentSelections.has(child)` at the end.

**After:** up to **6 attempts**. Attempt k (k≥1) first sleeps 2^(k-1) seconds, so attempts start at roughly 0, 1, 3, 7, 15 and 31 s. `metadataNeeded()` runs at four points:
1. before each attempt,
2. after the sleep,
3. before `thread/resume`,
4. before applying a result.

`metadataNeeded()` is false when any of these holds:
- the child is unregistered,
- a selection is already known,
- `task.status` ∈ {interrupted, cancelled, failed},
- the parent native turn is in `terminalizedNonCompletedNativeTurns`.

Each attempt still has the 5s timeout. A blank `thread/resume` model now counts as failure and is retried. The fiber is forked into the session scope, so closing the session stops it.

Ways a child is registered with `model === null`:
- `item/completed` with `subAgentActivity{kind:"started"}`. The model is always null here, which is the main path.
- `item/started` or `item/completed` with `collabAgentToolCall{tool:"spawnAgent", receiverThreadIds:[X]}` and no or empty `model`.

Ways a selection is learned another way: `thread/settings/updated` (threadSettings.model), `model/rerouted` (toModel), and a non-empty `collabAgentToolCall.model`.

## Matrix

Stub key: `R`/`S` are the `thread/read` and `thread/resume` replies for the **child** threadId. `err` is a JSON-RPC error `{code:-32603}`. "Attempts" counts `thread/read` calls seen by the fake.

| Row | Fixture | Stub behaviour | Before (8d3b061121) | After (aa57d7d3ba) | Client value |
|---|---|---|---|---|---|
| **R1** | `first-ok` | R → `{thread:{id:X, model:"gpt-6-luna", reasoningEffort:"high"}}` | 1 read, 0 resume; one `subagent.updated` with model | same; the loop returns after attempt 0 | `model="gpt-6-luna"`, `modelSelection.options=[reasoningEffort:high]`, both SHAs |
| **R2a** | `read-err+resume-err-then-ok` | attempt 0: R err "rollout is empty", S err; from attempt 1: R ok as R1 | read, resume, then stop. **Model stays null (bug #15250)** | read, resume, sleep 1s, read OK at about 1s; one update | before **"Not reported"**; after `gpt-6-luna` |
| **R2b** | `read-empty+resume-err-then-ok` | attempt 0: R `{thread:{id:X, model:null}}` (or `""`), S err; then R ok | same as R2a before | same as R2a after | same as R2a |
| **R2c** | `ok-on-6th` | R and S err on attempts 0–4; R ok on attempt 5 (about 31s) | null | update at about 31s, 6 reads total | before null; after model. This is the edge of the bound |
| **R3** | `always-fail` | R err, S err, always | exactly 1 R + 1 S | **exactly 6 R + 6 S**, starting at about 0, 1, 3, 7, 15 and 31s, and none after (watch for at least 60s more) | stays null ("Not reported"), 0 model updates, both SHAs |
| **R3b** | `always-hang` | R never answers | 1 R, 5s timeout, no resume (the chain timed out), stop | 6 R, each cut at 5s (timeout plus sleeps, about 61s), **no resume** (the timeout covers read+resume together) | null at both SHAs. Note: there is no resume fallback when the read hangs |
| **R4a** | `child-interrupted-midretry` | R and S err; after attempt 0 the fake emits `item/completed{subAgentActivity, kind:"interrupted", agentThreadId:X}` | 1 R + 1 S (it had already stopped) | no attempts after the interrupt is ingested (the check after the sleep returns false) | `status="interrupted"`, `model=null` |
| **R4b** | `child-failed` / `child-shutdown` | as R4a, but `item/completed{collabAgentToolCall, tool:"wait"\|"closeAgent", agentsStates:{X:{status:"errored"\|"shutdown"\|"notFound"}}}` → failed or cancelled | 1 + 1 | stops | `status` failed or cancelled, model null |
| **R4c** | `parent-turn-failed` | as R4a, but the fake emits `turn/completed{threadId:P, turn:{id:T, status:"failed"\|"interrupted"}}` for the parent | 1 + 1 | stops: T is in `terminalizedNonCompletedNativeTurns` | model null |
| **R4d** | `parent-turn-completed` (control) | R and S err ×2, then ok; the parent turn completes **normally** in between | null | **keeps retrying** and reports the model (a normal completion does not stop it) | after: model |
| **R4e** | `child-completed` (control) | R and S err ×2, then ok; the child emits `subAgentActivity kind:"completed"` in between | null | **keeps retrying**: completed is not in the stop set | after: model; `status=completed` |
| **R4f** ⚠ | `interrupt-during-inflight-ok` | R blocks; the fake emits the child interrupt, then releases R with a valid model | **model reported**: the old code only checked `subagentSelections` | **stays null**: the check before the update fails | before `gpt-6-luna`; after **"Not reported"**. This is a semantics change (see the audit note) |
| **R5** | `rerouted-midretry` | R and S err; between attempts 0 and 1 the fake emits `model/rerouted{threadId:X, turnId, fromModel, toModel:"gpt-5.6-sol", reason:"highRiskCyberActivity"}` (or `thread/settings/updated{threadId:X, threadSettings:{model,...}}`) | 1 + 1; one update from the reroute | 1 + 1, then **no more reads** after the reroute | exactly one `subagent.updated` carrying a model, value `gpt-5.6-sol` (never the parent's model) |
| **R5b** | `rerouted-while-inflight` | R blocks; the fake emits reroute, then releases R with `gpt-6-astra` | reroute wins (existing test at line 5037) | reroute wins; the check before the update fails | `gpt-5.6-sol`, with no flip to the older model |
| **R6** | `read-empty-resume-ok` | R `{thread:{id:X, model:null}}`; S `{thread:{id:X}, model:"gpt-6-luna", reasoningEffort:"high", serviceTier:null}` | 1 R + 1 S, model reported | same, attempt 0 | `gpt-6-luna` at both SHAs |
| **R6b** | `resume-blank-model` | R empty; S `{..., model:""}` on attempt 0, then valid | null, no retry | retries (new `response.model?.trim()` gate), reported at about 1s | before null; after model |
| **R7** | `id-mismatch` | R and S return `thread.id:"other"` | null | treated as failure, retried ×6, then null | null |

The ⚠ on R4f marks an **audit note, not a blocker**. After the change, a child that goes interrupted, failed or cancelled before any lookup succeeds can never get its model, even when Codex could report it. This includes a child that errors within about 1s and a lookup already in flight. The model is still meaningful for a finished child, since the panel displays it. The PR test "stops native child metadata lookups after interruption" asserts this behaviour on purpose. A maintainer should confirm that is intended, or the stop set could be limited to session or parent-turn teardown.

## How a running server reaches each row (Codex unavailable)

The server spawns `providerInstances.<id>.config.binaryPath || "codex"` with args `app-server` plus the launch args (`CodexAdapterV2.ts:1566`; the probe is at `CodexProvider.ts:~500`). This only applies to `setupMode` other than `"managed"`; managed mode downloads its own runtime, so avoid it. There is no env var for the binary. Point `binaryPath` at a fake. `packages/provider-testing/src/fakeCli.ts` `writeFakeCli` is the existing pattern: an sh launcher plus `node stub.mjs`.

### Fake `codex app-server` (stdio, newline-delimited JSON-RPC frames `{id,method,params}` and `{id,result|error}`, no `jsonrpc` field required; mirror the frames in `codexReplayPreamble`, CodexAdapterV2.test.ts:522)

**Probe (provider snapshot):**
- `initialize` → `{userAgent:"codex_cli_rs/0.144.0", codexHome:<tmp>, platformFamily:"unix", platformOs:"linux"}`. Then expect an `initialized` notification.
- `account/read` → `{account:{type:"apiKey"}, requiresOpenaiAuth:false}`.
- `model/list` → `{data:[<one model; copy the shape from a CodexProvider test fixture>], nextCursor:null}`.
- `skills/list` → `{data:[]}`, `config/read` → `{config:{}}`, `account/rateLimits/read` → an error (it degrades gracefully).
- Anything unknown → `{error:{code:-32601}}`.

**Session:**
- `initialize`, `initialized`.
- `thread/start` → the preamble result (thread id P, `model:"gpt-5.4"`, …).
- `turn/start` → `{turn:{id:T, items:[], itemsView:"notLoaded", status:"inProgress", error:null, startedAt, completedAt:null, durationMs:null}}`. Then emit:
  1. `turn/started{threadId:P, turn}`,
  2. `item/completed{threadId:P, turnId:T, completedAtMs, item:{type:"subAgentActivity", id:"call-spawn", kind:"started", agentThreadId:X, agentPath:"/root/a"}}`, which registers the null-model child and starts the lookup loop,
  3. the scripted row events (interrupt, reroute, parent turn/completed),
  4. finally `turn/completed{threadId:P, turn:{...status:"completed"}}`, except in R4c.
- `thread/read`/`thread/resume` keyed by threadId: X → the fixture script by attempt index; P → a minimal valid thread. `turn/interrupt` → `{}`. `mcpServerStatus/list` → `{data:[], nextCursor:null}`.
- Select the fixture with a control file, e.g. `/tmp/ev-22830/fixture`, re-read on every new turn so no server restart is needed. Append `{t:Date.now(), method, threadId}` to `/tmp/ev-22830/req-<fixture>.ndjson` for every inbound request. That log is the attempt-count and timing proof for R3, R3b, R4 and R5.

### Server and client drive (isolated home, PID tracked)

```sh
H=/tmp/ev-22830/home-<sha>; mkdir -p $H/userdata
cat > $H/userdata/settings.json <<J
{"providerInstances":{"codex":{"driver":"codex","config":{"setupMode":"existing","binaryPath":"/tmp/ev-22830/fake/codex"}}}}
J
cd /tmp/ev-22830/proj && git init -q   # dummy project
node <wt>/apps/server/src/bin.ts start --base-dir $H --no-browser --auto-bootstrap-project-from-cwd /tmp/ev-22830/proj & echo $! > $H/pid
node <wt>/apps/server/src/bin.ts thread start . --base-dir $H --provider codex --model gpt-5.4 "spawn a child"   # from /tmp/ev-22830/proj
node <wt>/apps/server/src/bin.ts thread show <threadId> --base-dir $H
# wait about 70s (the R3 window), then run the sqlite readback above; finally kill "$(cat $H/pid)"
```

- Run once from a worktree checked out at `8d3b061121` and once at `aa57d7d3ba`. Use separate `$H` per SHA and a detached `git worktree add`, not `~/wt/pr-23005`.
- Each row is one `thread start` (or `thread send`) with the fixture file switched first.
- The CLI goes through the server's MCP tool session (`t3_thread_launch` / `t3_thread_send`), so no browser is needed.
- Optional: open the web client (`vp run dev` in the same worktree) to screenshot "Not reported" versus the model label.

**Unverified assumptions for the proof runner to check in the first dry run:**
- the exact `settings.json` key nesting,
- whether `thread start` needs `--provider codex` to match the instance id `codex`,
- whether the provider snapshot must be "ready" before a turn is accepted (if so, the fake's `model/list` must pass schema decode),
- the `orchestration_v2_events` column name.

### Feasibility per row

- **Feasible on a running server:** R1, R2a–c, R3, R3b, R4a–e, R5, R6, R6b, R7. Each needs only fake-emitted notifications plus scripted replies. Real wall-clock time is at most about 70s per row, because the backoff is real seconds.
- **R4f and R5b** need a release of an in-flight `thread/read` timed after the event has been *ingested*. The fake can delay the reply about 1.5s after emitting the event. That is close to deterministic, but it is still a race. If it flakes, use the fallback.
- **Fallback (labelled, not server-level):** the scoped vitest with the `withCodexReplayChildMetadata` stub and TestClock. The two new tests cover R2a and R4a/R4f at head; the test at line 5037 covers R5b at both SHAs. To show "before" for R2a/R3, run the new test file's two cases against the 8d3b061121 source; they should fail there. Command: `vp test run apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts -t "native child"`.

**Recommended evidence set for the PR:** R2a (the bug fix, before null and after model), R3 (the bound: 6 and 6, then quiet, from the fake's request log) and R5 (no duplicate update) on a running server, plus R4a (stop on interrupt). Call out R4f as a design question.
