evidence/FixD2D3D4D5.md
Rama master · réplica de lectura del repositorio original.
# FixD2D3D4D5 — Connect stream schema validation, fresh-fallback tool history, cache-replay accounting, generator offset
Baseline: `2598299e` (`git rev-parse HEAD` in the lane worktree at start).
Lane branch: `fix/d2-d3-d4-d5`, worktree
`C:/Users/dwgx1/.dwgx/ops/artifacts/windsurfapi-autonomous-20261026/w17x-d2d3d4d5`.
Direction rows: `evidence/DIRECTION.md` D2, D3, D4, D5.
Every Node command below uses `test/setup-env.mjs`; bare `node --test` was never run
(it can rewrite the operator `runtime-config.json`).
## Red-first proof (final test files against baseline source)
The four test files were run against the unmodified `2598299e` source
(`git stash push -- src/`, run, `git stash pop`):
```
$ node --import ./test/setup-env.mjs --test test/structured-output-schema.test.js \
test/cascade-native-history-delta.test.js \
test/cache-replay-usage-accounting.test.js \
test/cascade-generator-offset.test.js
ℹ tests 24
ℹ suites 8
ℹ pass 15
ℹ fail 9
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
```
The 9 failures are the new arms; the 15 passes are the pre-existing arms plus the
legitimate-behaviour controls (a conforming Connect stream, a fresh cascade, a
valid-usage offset window, a failed-metadata read). Each failure names the audited
defect:
| Row | Failing arm on baseline | Baseline observation |
|---|---|---|
| D2 | `invalid then valid: the bad turn is a typed failure that is neither cached nor committed` | `expected one typed error frame, got [role chunk, content chunk {"answer":"twelve"}, finish_reason:"stop", …]` — the non-conforming payload reached the client as a completed turn and was cached/committed |
| D3 | `control: an ordinary reuse sends only the newest round` | `actual: [3], expected: [1]` — the client had no way to apply the delta index |
| D3 | `a rejected resume re-sends the full history on the fresh restart` | `actual: 3, expected: 1` for the first send — the send was already fixed at "full", so the fallback could not change |
| D4 | `non-stream: the hit adds no tokens, no credits and no account spend` | `actual: 16, expected: 8` — the cache hit doubled the upstream token ledger |
| D4 | `stream: the replay adds no tokens, no credits and no account spend` | `actual: 16, expected: 8` — same on the stream replay |
| D4 | `charges the credit card only for a turn that ran upstream` | `actual: 8, expected: 4` — a cache-served row charged the credit card again |
| D5 | `the parser keeps the record count when no record carries usage` | `actual: null` — `parseGeneratorMetadata` discarded `entryCount` |
| D5 | `no-usage records still advance the window: the next reuse asks from 2, not 0` | `actual: null, expected: 2` — the turn emitted no offset, which the pool turned into 0 |
| D5 | `the pool keeps an unknown window start unknown instead of record 0` | `actual: 0, expected: null` — `checkin()` coerced the unknown offset |
D3 note on the numbers: the audited baseline measured 3/1/1 field-9 steps because
`chat.js` sliced the list to the delta *before* calling `cascadeChat`. The fix moves
that decision into `cascadeChat` (the only place that knows a resume was rejected), so
the contract the test pins is 3 (fresh) / 1 (ordinary reuse) / 1 then 3 (expired
resume → fresh restart). On the baseline source the client cannot honour either half,
which is what the two red arms show.
## What changed
### D2 — Connect stream schema validation (`src/handlers/chat.js`)
`structuredOutputViolation()` was only reached on the non-stream Connect arm and the
buffered Cascade arm. The Connect **stream** arm passed the adapter's frames straight
through and then committed success, so a `response_format: json_schema` request could
receive non-conforming JSON with `finish_reason: 'stop'`, be cached, and be committed
to session continuity.
- `attemptStream` now buffers the adapter's frames when the request carries a JSON
Schema (`schemaFrames` / `sink`), for both the first attempt and the re-login replay
(the replay clears the buffer instead of appending). `emitted` therefore stays false
until the flush, so pre-flush re-login and account failover remain safe.
- In the `ok` arm, after the existing delivery gate: the client-visible payload
(`delivered ?? content`) is validated. On a violation the turn closes with one
`STRUCTURED_OUTPUT_NOT_CONFORMING` error frame, `terminalOk` is forced false (so no
`[DONE]`), and the code `break`s **before** `bindConnectSticky`, the session commit
and the cache write. Usage and per-account spend were already recorded inside
`attemptStream` — the upstream turn really ran, only its answer is unusable (same
rule as the Cascade arm).
- A turn that delivered tool calls has no JSON payload to judge and keeps its normal
terminal (parity with the Cascade arm's `wantJson && accText` guard).
- Conforming frames are released in their original order through the same sequential
writer, so a conforming stream is unchanged.
### D3 — reuse→fresh must carry the full mapped tool history (`src/client.js`, `src/handlers/chat.js`)
`chat.js` pre-sliced `additionalSteps` to `deltaStart` whenever a reuse entry existed.
When the LS rejected the resume and the client restarted fresh, `rebuildFullHistoryText()`
restored the text history but the replacement trajectory still received only the delta,
so the planner reasoned from tool observations it could not see.
- `cascadeChat` now accepts `additionalStepsFrom` and decides per send:
`effectiveAdditionalSteps()` slices only while `resumedTrajectory` is true.
`resumedTrajectory` is set when `openCascade()` actually resumes a cascade id and is
cleared when the expired-resume recovery nulls the reuse entry.
- Both handler call sites (non-stream `:6189-6190`, stream `:8086-8087`) now pass the
full list plus `deltaStart`.
### D4 — cache-replay usage accounting (V-27) (`src/handlers/chat.js`, `src/dashboard/stats.js`)
Both Connect replay arms called `recordTokenUsage()` with the cached usage and
`recordRequest()` as an ordinary success. No upstream turn ran, yet every hit re-added
the token block and re-charged the model's credit cost.
- `recordRequest(model, success, durationMs, accountId, { cacheServed: true })` skips
the credit computation and stamps `cacheServed: true` on the row; the request is
still counted as served traffic.
- The stream replay (`:4519-4526`) and non-stream replay (`:4961-4963`) pass the flag
and no longer call `recordTokenUsage`. The cached `usage` still rides the client's
response body/frames — it describes the replayed answer, not a second upstream turn.
Per-account spend was already untouched on this path and stays that way (asserted).
### D5 — generator offset must not reset to 0 (`src/windsurf.js`, `src/client.js`, `src/conversation-pool.js`)
`parseGeneratorMetadata()` returned `null` as soon as no record carried a usage block,
discarding `entryCount`; the client then emitted `generatorOffset: null`, and
`conversation-pool.checkin()` coerced that to `0`. The next resume asked the metadata
RPC for the whole cascade and billed every earlier turn again (measured `[0,0]` vs the
valid-usage control `[0,1]`).
- `parseGeneratorMetadata()` now returns the record count whenever records exist, with
`hasUsage` saying whether the token figures are real. `null` remains "no records".
- `client.js` resume snapshot: `?.entryCount || 0` replaced by an explicit
`Number.isInteger(...)` check plus `generatorOffsetKnown`; an unreadable offset stays
unknown and the turn abstains (warn log) instead of asking from 0.
- `client.js` end-of-turn: `serverUsage` is only set when `hasUsage` is true (so the
step-level fallback still runs), while `chunks.generatorOffset` advances by
`metaResult.entryCount` when the start is known, else `null`.
- `conversation-pool.checkin()` keeps a non-finite `generatorOffset` as `null`
("unknown"), which the client's `Number.isInteger` check re-derives on the next
resume. `stepOffset`'s existing 0 default is unchanged (not part of this row).
## Verification
Targeted suites (cascade + connect + billing stats):
```
$ node --import ./test/setup-env.mjs --test test/cascade-*.test.js test/connect-*.test.js test/billing-stats-*.test.js
ℹ tests 499
ℹ suites 112
ℹ pass 499
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
```
Per-row proof files (green after the fix):
```
$ node --import ./test/setup-env.mjs --test test/structured-output-schema.test.js
ℹ tests 12 ℹ pass 12 ℹ fail 0
$ node --import ./test/setup-env.mjs --test test/cascade-native-history-delta.test.js
ℹ tests 4 ℹ pass 4 ℹ fail 0
$ node --import ./test/setup-env.mjs --test test/cache-replay-usage-accounting.test.js
ℹ tests 3 ℹ pass 3 ℹ fail 0
$ node --import ./test/setup-env.mjs --test test/cascade-generator-offset.test.js
ℹ tests 5 ℹ pass 5 ℹ fail 0
```
Full suite (`npm test` → `node scripts/run-tests.mjs`, 587 discovered files):
```
$ node scripts/run-tests.mjs
TEST_DISCOVERY 587 unique files
ℹ tests 7126
ℹ suites 1350
ℹ pass 7029
ℹ fail 0
ℹ cancelled 0
ℹ skipped 97
ℹ todo 0
exit=0
```
One harness needed a one-line accommodation: `test/s4-r5.ladder.test.js` extracts the
`attemptStream` source and evaluates it against an injected dependency bag, so the new
`jsonSchema` closure dependency is now passed as `null` there (the ladder pins failover,
i.e. the no-schema arm). Without it the extracted block threw `ReferenceError:
jsonSchema is not defined`; the file is green again (8/8 for `s4-r5*.test.js`).
Project gate (`npm run gate`):
```
$ npm run gate
PASS spec-static-check exit=0
PASS spec-baseline-check exit=0
PASS secret-scan exit=0
PASS test:release exit=0 — 7029 pass / 0 fail / 97 skip (587 files) — 6 inert file(s), 6 declared
PASS git diff --check exit=0
PASS git diff --cached --check exit=0
INCREMENTAL GATE: PASS exit=0
```
The first gate invocation (without `GATE_INERT_SKIP_PATHS`) failed only the inert-file
declaration check, not a test: `detach-ls-pool-pressure` (needs a POSIX shebang fake LS),
`detach-soak` (`DETACH_SOAK=1` opt-in), `devin-connect-relogin-live` (real credentials),
`mutate-verify-harness` + `self-update-untracked-safety` (real-Git fixture at a trusted
POSIX path), `wire-byte-identity` (needs `WIRE_BASE_TREE`). All six self-skip on this
Windows host for reasons that predate and are unrelated to this diff; the rerun declares
them, which is what `GATE_INERT_SKIP_PATHS` exists for. No test failed in either run.
## Boundaries
- No live-upstream, paid-account, Docker, deployment or registry action was performed.
The Connect arms use the handler's own dependency seam (`__setConnectDeps`) and the
Cascade arms a fake language server over the real `cascadeChat`.
- D2 validates the payload the *client would see*; the adapter still writes deltas as
they arrive, so the fix buffers them for schema-constrained requests only. Requests
without `response_format: json_schema` keep the unbuffered, backpressure-aware path.
- D3 changes the client/handler contract (`additionalStepsFrom`). No other caller of
`cascadeChat` exists in the tree (checked with grep); a caller that passes
`additionalSteps` without the new index keeps the old "full list" behaviour.
- D5 does not change `stepOffset`'s null→0 default; that is the polling cursor, not the
billing window, and is outside this row.