evidence/CodexParallelFix.md
Rama master · réplica de lectura del repositorio original.
# CodexParallelFix — `parallel_tool_calls:false` is served, never refused
Lane: `CodexParallelFix` · worktree `C:/Users/dwgx1/.dwgx/ops/artifacts/windsurfapi-autonomous-20261022/w110-codexparallel`
Branch: `fix/codex-parallel-tool-calls` · base: `451250a` (`master`) · no push, no tag, no merge.
---
## 1. The regression, restated
Codex CLI 0.164 sends `parallel_tool_calls:false` on **29/29** captured requests
(`evidence/ClientSandboxes.md` §3.1, §3.3 C1), always together with 11 tools and
`tool_choice:"auto"`. Wave 2 taught `responsesToChat()` to forward the field
(`src/handlers/responses.js`, F1) — correct in itself, the surface used to drop a
first-class Responses parameter. But the chat route's gate answered
```
400 invalid_request_error / code=unsupported_parameter / param=parallel_tool_calls
```
whenever `DEVIN_CONNECT_TOOL_CHOICE` was not `1`, which is the **shipped default**.
Codex surfaced `turn.failed` and exited 1: the client was unusable against v3.10.x
with default config. The gate shipped in `d31e760` (v3.10.0); the 3.9.60 baseline
never saw it.
The field was **silently dropped** before wave 2, which is why Codex worked then.
The regression is not "the field reaches the gate" — it is "the gate turns a
preference into a hard error".
## 2. Reproduction
`evidence/CodexParallel-probe.mjs` drives the real `handleResponses` and the real
chat handler; only the connect transport is stubbed (no bytes leave the box, no
account is touched).
```
node --import ./test/setup-env.mjs evidence/CodexParallel-probe.mjs
```
Before the fix (`451250a`, passthrough off — the shipped default):
```json
{"label":"codex-shape with parallel_tool_calls:false","status":400,"modelCalls":0,"error":{"code":"unsupported_parameter","param":"parallel_tool_calls"}}
{"label":"same request WITHOUT the field","status":200,"modelCalls":1}
{"label":"parallel_tool_calls:true","status":200,"modelCalls":1,"forwardedParallelToolCalls":true}
```
After the fix (same probe, same env):
```json
{"label":"codex-shape with parallel_tool_calls:false","status":200,"modelCalls":1,"forwardedParallelToolCalls":false,"hintIgnoredDelta":1}
{"label":"same request WITHOUT the field","status":200,"modelCalls":1,"hintIgnoredDelta":0}
{"label":"parallel_tool_calls:true","status":200,"modelCalls":1,"forwardedParallelToolCalls":true,"hintIgnoredDelta":0}
{"label":"codex-shape with parallel_tool_calls:false [switch ON]","status":200,"modelCalls":1,"forwardedParallelToolCalls":false,"hintIgnoredDelta":0}
```
and the once-per-process notice, verbatim:
```
[WARN] Chat[...]: DEVIN_CONNECT event=parallel_tool_calls_hint_ignored — caller asked for
parallel_tool_calls:false and the #11 passthrough is off (DEVIN_CONNECT_TOOL_CHOICE is not "1";
tag unconfirmed), so the request is served without the hint and the upstream may return several
calls per turn. Counted per request; logged once per process.
```
## 3. Decision memo — (a) honour by default vs (b) accept + warn + count
### What the wire evidence actually supports
| Question | Evidence | Verdict |
|---|---|---|
| Is `#11 disable_parallel_tool_calls` a **verified** upstream coordinate? | `src/devin-connect.js` header: "UNCONFIRMED COORDINATES … DECLARATION ORDER in someone else's .proto, not a wire capture". `docs/AUDIT-LEDGER.md:1728`: the five wire-coordinate switches (`#13`/`#12`/`#11`/`credit_cost ^14`/`Metadata #21`) are **all default-off**, to be unlocked by one paid capture. | **No.** Tag number and bool encoding are guesses. |
| Does emitting it work when an operator opts in? | `evidence/ClientSandboxes.md` §3.3 C1 observation: with the passthrough **on**, `gpt-6-luna-max` returned exactly one `custom_tool_call` in **24/24** tool-call responses. | Suggestive, **not proof**: a model can also choose one call unaided, and no capture shows the field on the wire. |
| Does *not* emitting it leave the hint inert? | `evidence/ToolMatrixLive.md` §8: 4/4 models returned two calls with `parallel_tool_calls:false`. Measured while the chat handler did not forward the field at all (`evidence/ToolChoiceFlag.md` §1), so it proves the *pre-wave-1* inertness, not that `#11` fails. | The default-off state is **untested against the wire**, in both directions. |
### Option (a): emit `#11` whenever the caller says `false`
Rejected **as a default**. It would put an unconfirmed tag into every Codex request
on every turn. The repo has already paid for this class of mistake once: pointing
`tool_choice` at `#1` produced **two** `#1` fields and let a 10-byte value overwrite
the ~800-byte `ClientMetadata` the upstream authenticates on — a silent "auth
failure" (`docs/HISTORY-LEDGER-2026-08-docs.md:34`). `#11` is exactly the same
epistemic position, and a wrong bool at a wrong tag is undetectable from the
response. "Looks like the right field" is not evidence, and the repo's standing rule
for unverified coordinates is default-off until a capture lands
(`docs/AUDIT-LEDGER.md:1728`: 不要因为「看起来该开」就改默认).
Note the asymmetry that decides it: if `#11` is **wrong**, option (a) corrupts the
request for every caller; if `#11` is right but off, option (b) merely leaves the
call count to the model — which is the behaviour every shipped version before
v3.10.0 had, and which Codex 0.164 demonstrably tolerates (it worked against 3.9.60).
### Option (b): serve, warn once, count — **chosen**
* The request is served: a preference never costs a turn.
* The drop is **not silent**: one structured WARN per process
(`event=parallel_tool_calls_hint_ignored`, naming the switch) plus a per-request
counter `parallel_tool_calls_hint_ignored` in `/connect-metrics`.
* The counter is the measurement that would justify calibrating `#11` later: a
non-zero value means real clients ask for it, and the count is the denominator for
any future live A/B (switch on/off) — the same "measure before flipping a wire
coordinate" rule the other four switches follow.
* Warn **once**, not per request: Codex sends the hint on every turn, so a
per-request WARN would be permanent noise on a request the proxy deliberately
accepts. The counter carries the rate; the warn carries the explanation.
* No `errorCount`, no account-health effect: nothing failed, and a caller preference
must never degrade a token's standing.
### What is kept
* `parallel_tool_calls:true` — unchanged (upstream default; no field, no warn, no count).
* Absent — unchanged.
* `DEVIN_CONNECT_TOOL_CHOICE=1` — the hint is still honoured natively (`#11` on the
wire) and **not** counted, so the counter measures exactly the ignored subset.
* The `/v1/messages` F2 mapping (`tool_choice.disable_parallel_tool_use` →
`parallel_tool_calls:false`) still carries the Anthropic spelling into the same
field, and that surface is served too.
* No other 400 gate was touched. No handler produces `unsupported_parameter` any
more — the parallel hint was its only producer (the two remaining mentions in
`src/` are historical comments), so the Anthropic-side error remap that existed
solely to reword it is deleted (clean cutover, not a shim).
This supersedes §2 of `evidence/ToolChoiceFlag.md` ("honour or refuse, never
ignore"). That memo's premise — "a correctness hint, so a 400 is the honest
refusal" — is refuted by the shipped-client evidence: Codex 0.164 is a client that
handles multiple calls perfectly well and still sends `false` on every request.
## 4. Changes
| File | Change |
|---|---|
| `src/devin-connect.js` | `isParallelToolCallsUnsupported` → `isParallelToolCallsHintIgnored`; the predicate is unchanged (`=== false && !switch`) so "counted as dropped" and "would not be emitted" stay the same condition. Doc comment rewritten: preference, never refused. |
| `src/handlers/chat.js` | the 400 return is replaced by `bumpConnect('parallel_tool_calls_hint_ignored')` + a once-per-process structured `log.warn`. Still inside the `devin_connect` short-circuit, still before the drought gate. Adds `__resetParallelToolCallsHintWarn()` (test seam, same family as `__setConnectDeps`). |
| `src/devin-connect-metrics.js` | new counter `parallel_tool_calls_hint_ignored` (process-lifetime, exported by `getConnectMetrics()`); documented as expected-non-zero, never an account-health signal. |
| `src/handlers/messages.js` | `PARALLEL_HINT_REFUSED_MESSAGE` and the `param === 'parallel_tool_calls'` remap deleted; `toAnthropicChatError` loses its now-unused `body` parameter. |
| `test/parallel-tool-calls-connect.test.js` | rewritten for the new policy: Codex-shaped responses request → 200 + model called + counter; absent/`true` unchanged; warn-once + counter-per-request; honoured-when-on (and not counted); `tool_choice` threading; Cascade route untouched; `/v1/messages` served. |
| `test/responses.test.js` | the "surfaces the chat route 400" case is replaced by "serves a Codex-shaped request when the passthrough is off" (same test count). |
| `test/drought-gate-reachability.test.js` | the removed `return` is no longer an accepted escape above the drought gate; the enumeration still guards the two that remain. |
| `test/mutations/parallel-tool-calls.json` | 10 mutations re-pinned to the new policy (the regression itself, silent drop, switch-blind predicate, missing counter, per-request warn, ungreppable warn, wire forwarding, both `connectParams` threadings, F2 mapping). `expectBaselinePass` re-measured to 12. |
| `test/mutations/retry-after-route-parity.json` | anchor repaired (`toAnthropicChatError(result)`), the call site the parameter removal changed. |
| `docs/ENV-SWITCHES.md:225` | the `DEVIN_CONNECT_TOOL_CHOICE` row now documents serve + warn + counter instead of the 400. |
| `docs/DEVIN-CONNECT-CUTOVER.md` §7 | known-limits entry: the hint is served but not honoured by default, with the log event and counter named. |
| `.env.example` | declares `DEVIN_CONNECT_TOOL_CHOICE` / `DEVIN_CONNECT_TOOL_CHOICE_TAGS` — the runbook mention would otherwise be undiscoverable from the file readers consult, which the prose-docs guard enforces. |
| `evidence/CodexParallel-probe.mjs` | re-runnable reproduction (§2). |
## 5. Verification
Logs cited below live in the lane evidence directory
`C:/Users/dwgx1/.dwgx/ops/artifacts/windsurfapi-autonomous-20261022/evidence/`
(the `…/evidence/` prefix in this memo).
Acceptance command (brief), final tree (`5a5095b` + docs), plus the three focused
suites and the two guards this lane touches:
```
node --import ./test/setup-env.mjs --test test/responses.test.js test/chat*.test.js test/devin-connect*.test.js \
test/parallel-tool-calls-connect.test.js test/drought-gate-reachability.test.js \
test/retry-after-route-parity.test.js test/docs-consistency-guard.test.js \
test/default-on-switch-registry.test.js
ℹ tests 1220 ℹ suites 164 ℹ pass 1220 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0
```
(log: `…/evidence/w110-final-acceptance.log`; the brief's three patterns alone are
1144 pass / 0 fail — the remaining 76 are the focused suites and the two guards.)
Focused suites: `parallel-tool-calls-connect` 12/12, `drought-gate-reachability`
10/10, `retry-after-route-parity` 5/5.
Red-first: the new suite failed on `451250a` for the behavioural reason, not just a
missing seam — 4 failures, e.g.
```
✖ serves a Codex-shaped responses request (field + tools) and calls the model
actual: 400, expected: 200
✖ accepts tool_choice.disable_parallel_tool_use instead of refusing it
actual: 400, expected: 200
✖ warns once per process ... got 0 lines
```
Mutations (WSL, `node scripts/mutate-verify.mjs test/mutations/parallel-tool-calls.json`):
see §6.
## 6. Mutation results (WSL, Node v24.21.0, clone of `b8a7d24`)
`node scripts/mutate-verify.mjs test/mutations/parallel-tool-calls.json`
(log: `…/evidence/w110-mut-parallel.log`):
```
baseline 12 pass / 0 fail
CAUGHT 4 REGRESSION: the 400 comes back for parallel_tool_calls:false
CAUGHT 4 the drop goes silent again: the predicate never fires
CAUGHT 1 the predicate ignores the passthrough switch
CAUGHT 4 the counter is dropped
CAUGHT 1 the warn fires on every request instead of once per process
CAUGHT 1 the warn stops naming the event and the switch
CAUGHT 1 streamChat stops forwarding the opt-in fields
CAUGHT 5 the handler stops threading parallel_tool_calls into connectParams
CAUGHT 1 the handler stops threading tool_choice
CAUGHT 1 the /v1/messages F2 mapping is dropped
all 10 mutation(s) behaved as expected; tree clean
```
The first run of this spec **found a real gap**: `tool_choice` threading
SURVIVED, because replacing the old refusal test had dropped the only assertion on
it. The test was restored (commit `b8a7d24`), the baseline re-pinned 11 → 12, and the
mutation is caught now — recorded here because a mutation that survives is evidence
about the tests, and deleting it instead would have hidden the loss.
`node scripts/mutate-verify.mjs test/mutations/retry-after-route-parity.json`
(log: `…/evidence/w110-mut-retry-after.log`): anchor repaired after the
`toAnthropicChatError` signature change; `all 6 mutation(s) behaved as expected`
(5 caught, 1 documented survivor), baseline 5/5.
Specs whose test lists include this lane's acceptance files were re-measured for
baseline drift with `scripts/spec-baseline-audit.mjs` on `56f9fb8` (log:
`…/evidence/w110-baseline-audit.log`) — 13 specs, every one `1/1 match, 0 drifted,
0 untrustworthy`:
```
alias-resolver-systemic 57 · connect-catalog-hotfix 55 · cred-gcm-tag-length 21
credit-cost-top-level 197 · devin-connect-warning-cache 6 · reasoning-continuity 326
response-id-entropy-guard 60 · responses-post-tool-text-item 75
responses-stream-done-sentinel 62 · retry-rescue-budget-split 89
session-continuity-compaction-survival 50 · unary-body-bound 9 · usage-unknown-not-zero 65
```
`test/responses.test.js` kept its test count (the refusal case was replaced 1:1), which
is why none of the specs that sum it drifted. Only the two specs above needed
mutation runs, because only they anchor on text this lane changed.
## 7. What is NOT verified here
* **No live upstream turn.** Every 200 above is at handler level with the connect
transport stubbed. Whether the upstream honours `#11` when the switch is on is
still an unverified wire question (see §3) — this lane deliberately does not flip
that default, so nothing about the live wire changed.
* **The real Codex CLI was not re-run** in this lane. The client-level PASS for
0.164 with the switch on, and the 400 with it off, are the ClientSandboxes lane's
captures (`evidence/ClientSandboxes.md` §3); this lane only removes the 400.
* **Counter is process-lifetime** by design (same as every other connect counter);
a restart resets it, so a scraper must rate it, not treat it as a total.
* `evidence/ClientSandboxes.md` and `evidence/ToolMatrixLive.md` live in the parent
checkout / lane evidence dir, not in this branch (they are untracked there).