Skip to content

fix(drift): the drift pipeline stops failing on faults that are not drift - #371

Merged
jpr5 merged 20 commits into
mainfrom
fix/drift-collector-timeout
Aug 12, 2026
Merged

fix(drift): the drift pipeline stops failing on faults that are not drift#371
jpr5 merged 20 commits into
mainfrom
fix/drift-collector-timeout

Conversation

@jpr5

@jpr5 jpr5 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The Fix Drift cron and its PR checks fail on faults that are not drift. Four defects, one surface: what a live provider's silence means, what a server-initiated close means, whether the sync's own gate can judge the edit in front of it, and whether a printed critical diff survives being parsed.

1. A printed critical diff could vanish, leaving the run "clean"

Mock:\s*(.+) — where \s matches newlines — combined with compareShapes setting mock: "" on 100% of its diffs, meant an entry with an empty mock value consumed its successor.

Measured across every permutation: the trigger is one empty-mock entry that has a successor, and the successor dies regardless of its own value. It does not need consecutive empties — a block whose only empty value sits in the middle loses its third entry. Two live surfaces emit these blocks, fal-queue.drift.ts and video.drift.ts, losing floor(N/2) entries silently.

Red on the unfixed collector with real emitter output: 2 entries, critical second → parsed 1 of 2, "No critical diffs found.", exit 0, conclusion: "clean". Green: Critical diffs: 1, exit 2, conclusion: "critical".

A ^ line anchor closes a second defect in the same pass: without it, a numbered line in prose followed by Path:/SDK:/Real:/Mock: was read as a real entry.

2. A silent provider was an unactionable manual-triage stop

Every drift run since 2026-08-11 failed — the scheduled main job as well as PR jobs, all three provider keys confirmed present by the job's own preflight, so neither credential-gating nor PR-specific. The Gemini Live leg times out having collected nothing, and the collector could not classify its own most common failure, so it quarantined with exit 5 — manual triage required.

Four lanes now, split on what the close frame actually says:

  • Refusal1002/1003/1007/1008/1009/1010 and 4000-4999. The frame names something we sent. Attributed critical drift, exit 2, code and reason preserved.
  • Hang-up1000/1001/1011/1012/1013. The peer left for its own reasons. exit 6: visible and alerted, not a drift finding — calling a provider hiccup "drift" pages the team and feeds the auto-fixer a phantom.
  • Silenceexit 6 / live-timeout. Deliberately not exit 0: those legs graded nothing, so the run must never be adopted as a reusable clean baseline.
  • Garbageexit 5, unchanged.

Measured end to end: 2, 6, 6, 5. The refusal codes are a set, not a range — a first attempt used 1002..1010 and the negative controls caught it, since 1004 is reserved and 1005/1006 are never sent on the wire. Boundaries pinned: 3999 and 5000 are not refusals.

Handshake attribution also stopped being hardcoded to openai-realtime; it follows the probe frame through a closed table, and an unregistered probe still quarantines, because a confidently wrong owner fails open.

3. A refused WS session looked exactly like silence

The drift probe's TLS client discarded the server's CLOSE frame with a bare socket.end(). That is why the 08-11 and 08-12 failures could not be explained — the one signal naming the cause was thrown away.

Before, the two cases are the same string:

CASE A (CLOSE 1008 + reason): Error: waitUntil timeout after 1500ms. Collected 0 messages: [] bodies=[]
CASE B (silent):              Error: waitUntil timeout after 1500ms. Collected 0 messages: [] bodies=[]
INDISTINGUISHABLE: true

After, A names code=1008 and its reason, and B is byte-identical before and after, compared with Buffer.compare rather than by eye. A clean close, a client-initiated close, and answer-then-close (buffered and same-segment) all stay byte-identical to baseline. socket.end() is untouched; lifecycle is unchanged. The serving mock (src/ws-framing.ts) already handled close codes correctly and is not in this diff.

4. The sync's gate vetoed an edit it cannot judge

gate-3 re-collects the live suite and refuses on its global critical count. But no *.drift.ts reads deprecatedFamilies — the deprecation direction is offline-only. So for a run whose whole changeset is recorded deprecations, gate-3 can neither confirm nor refute the edit; it can only veto it on unrelated drift.

That is what reddened 2026-08-11: run 31465219443, changeset 74f6efa43753f7d0, reason=gate-failed. The identical edit passed the next morning and became #370.

Gate-3 is now skipped for deprecation-only runs with the reason recorded; runs that add a family still run it. A fail-open is closed alongside — reportTrustNote no longer reports a quarantined, AG-UI-skipped, or conclusion-less report as a "clean re-collect", a state both failing mornings passed through.

Because this is a non-determinism fix, a single green run proves nothing. Stability is the evidence: 25 separate processes → 1 distinct outcome, and the probe is proven able to detect divergence — against the unfixed code it yields 2 outcomes, the 08-11 revert and the 08-12 pass.

Verification standard

Red-green throughout is against real code and real payloads, not reconstructions. The gate reconstruction's own computeChangesetKey reproduces 74f6efa43753f7d0 byte-identically. The collector and WS halves were verified against each other by merging both and driving the real client against a local TLS server sending a real CLOSE frame, then feeding the resulting error's own stack through the real collector.

Every guard was mutation-tested. Several rounds found guards that could not fail and they were fixed or deleted rather than kept: an "undecodable reason" test whose input never reached the decoder, a double-count that needed a mixed run, one genuinely unreachable branch deleted, and one comment that sold an inert ordering as active protection — rewritten to record that the ordering is inert while the close check itself is load-bearing, so a later reader does not delete it.

Correction to an earlier claim in this workstream

A refusal was not previously being tolerated as a benign infra flake. That indicator is anchored ^\s*(?:HTTP\s+)?waitUntil timeout\b and vitest always prefixes Error: , so it can never fire — the old message matched nothing either. The old behaviour was exit 5, a hard stop. The dead anchor is itself a guard that cannot fire.

Unproven

Why Gemini Live went silent. This makes the next occurrence explain itself; it does not explain that one. Whether Gemini Live sends a CLOSE frame at all in that scenario is unverified — a clean FIN without one remains indistinguishable from silence.

How much drift was historically lost to the parser swallow cannot be bounded: the loss happened before the report was written, and raw vitest output is not retained. Carried as unknown, not zero.

drift-live-pr on this PR runs the collector from origin/main by design (git worktree add ../base-main origin/main), so it cannot pass until this lands. The falsifiable test is that #372's identical failure clears once it does.

jpr5 added 13 commits August 12, 2026 09:50
… (RED)

Reconstructs the 2026-08-11 (gate-failed) / 2026-08-12 (ok-applied) pair from
the run artifacts. The real changeset key the fixture produces is byte-identical
to production's 74f6efa43753f7d0, and the real gate returns two verdicts for it.
gate-3 re-runs the whole live drift suite and vetoes on its global critical
count, which is not a function of the changeset. A recorded deprecation is
invisible to that suite (no *.drift.ts reads deprecatedFamilies), so gate-3
could only veto it on unrelated drift — which is how 74f6efa43753f7d0 was
refused on 2026-08-11 and applied on 2026-08-12.

Gate-3 is now skipped, with the reason recorded, for the runs it cannot
observe; it names the residual diffs when it does refuse; and it no longer
reports a quarantined or AG-UI-skipped re-collect as a clean one.
A skip with no stated reason is indistinguishable in the log from a gate that
ran and passed, so the caller must say what it could not observe.
@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@371

commit: 4464c9b

@jpr5 jpr5 changed the title fix(drift): a live timeout is not manual triage, and a printed critical no longer vanishes fix(drift): the drift pipeline stops failing on faults that are not drift Aug 12, 2026
@jpr5

jpr5 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Merging with drift-live-pr red. That job establishes its baseline by running the collector from maingit worktree add ../base-main origin/main — so it executes the unfixed code by design and cannot pass until this lands. It fails on the exact fault this PR fixes.

Every other check is green (26), local suite 5251 passing, version-neutral.

The falsifiable test: #370 fails the same job for the same reason. If it does not clear once this is on main, the fix is wrong.

@jpr5
jpr5 merged commit 0e3618c into main Aug 12, 2026
28 of 29 checks passed
@jpr5
jpr5 deleted the fix/drift-collector-timeout branch August 12, 2026 19:15
jpr5 added a commit that referenced this pull request Aug 12, 2026
…obe a modality Gemini serves (#374)

Follow-ups to #371. Three fixes: the drift collector could not attribute
a WS refusal and stopped for manual triage; the CLI could be killed
ungracefully by a signal arriving as it announced readiness; and the
Gemini Live probe requested a modality its model does not serve.

## 1. A WS refusal now names its provider instead of stopping the run

After #371, `drift-live-pr` still exited 5 — the message moved from
"could not be parsed as drift reports" to **"could not be mapped to a
provider"**. The classification worked; attribution did not, and the
manual-triage stop remained.

From run `31571018005`'s own artifact, the two failures are not
timeouts:

```
WSClosedError code=1007
reason="The requested combination of response modalities (TEXT) is not supported
        by the model. models/gemini-3.1-flash-live-preview"
```

`1007` is an RFC 6455 refusal, so the correct outcome is attributed
critical drift at **exit 2**, not the hang-up lane.

Attribution keyed on a `*.drift.ts` stack frame, and **there is no such
frame** — every frame is `ws-providers.ts` or a node internal, because
the throw is raised from a socket callback the probe's frame does not
survive. It is absent, not deeper, so no frame pattern recovers it. The
suite title carries the answer, so the frame is tried first (still
load-bearing) and the title is the fallback, anchored at the start
against the same closed `WS_HANDSHAKE_PROBES` table via each surface's
registry label.

Red reproduces CI byte-identically at `REAL_RC=5`. Green gives
`REAL_RC=2` with `provider=Gemini Live`,
`builderFile=src/ws-gemini-live.ts`, `id=ws-close:1007`, and Google's
own reason preserved into the finding. Exit 2 is tolerated on the base
leg, so the stop clears.

Refusing to guess an owner is retained deliberately — a confidently
wrong owner fails open. Unregistered suite with no frame still
quarantines at 5; a label appearing mid-title still quarantines; a
non-refusal code still routes to the hang-up lane. All three probes
attribute on both the refusal and handshake lanes, with each probe's
real `describe` title bound to its registry label, because a table that
works for one provider is how the previously hardcoded `openai-realtime`
bug survived.

An unfailable guard was deleted: a longest-label-first sort that no
input could distinguish, since no WS label is a prefix of another. It is
replaced by a test pinning that prefix invariant, so the assumption
fails loudly if it ever stops holding.

## 2. The CLI installs signal handlers before announcing readiness

`aimock server listening on …` was printed before
`process.on("SIGTERM")` was registered. A signal landing in that window
hit Node's default handler, which re-raises — the process died
`code=null, signal=SIGTERM` and never ran its shutdown. Anyone sending
SIGTERM to aimock in a container could hit it.

The pre-fix artifact fails **35/40** attempts idle and **54/60** under
load. Post-fix: **0 failures in 100 attempts**. Reverting the reorder
reds the guard 10/10.

The guard also fails loudly with `run pnpm build` when `dist/cli.js`
predates `src/cli.ts`. `pnpm test` never builds and these tests exercise
`dist/`, so a stale artifact previously made the guard silently test the
wrong binary — which happened during this work and produced a false
conclusion.

Scope stated honestly: a SIGTERM arriving *before* readiness is
announced is still fatal (20/20). That window is not closed, and the
test name and comment say so rather than implying coverage the code does
not provide.

## 3. The Gemini Live probe requests a modality its model serves

Google's reason string blames the modality request, but the cause is one
layer back: `fetchLiveCapableTextModels` selected models with
`!name.includes("native-audio")` — **a name heuristic standing in for a
capability check**. `gemini-3.1-flash-live-preview` is a native-audio
model whose name omits that substring, so it was silently misclassified
as text-capable and the probe asked it for TEXT.

Selection now keys on the declared `bidiGenerateContent` capability. The
probe requests `["AUDIO"]` — native-audio Live models support only
`AUDIO`, one modality per session, per Google's capabilities guide,
which names this model. The SDK shape becomes `inlineData` +
`turnComplete`, the drift server gains an audio fixture (the mock
already implemented the audio shape), and both legs send identical
`generationConfig`.

An `expect(true).toBe(true)` canary was deleted rather than kept.

Red replays Google's frame locally: `code=1007`, 6 failures. Green: 12
pass. Eleven mutations all red, including restoring the name heuristic,
flipping the fixture to text, and conflating audio with text in the
summary.

## Verification

Full suite 5280 passing, `test:drift`, lint, `format:check`, build,
commitlint all clean. Tests-inclusive typecheck at the 115 baseline with
zero errors in every touched file, harness mutation-tested so the zero
is a measurement rather than an artifact of a program that compiled
nothing. Version-neutral: `package.json`, `CHANGELOG.md`, `charts/**`
and `.claude-plugin/**` untouched.

## Unproven, and two findings not fixed here

Only a live run proves the Gemini endpoint accepts `["AUDIO"]` and emits
this sequence; there are no Google credentials in the environment where
this was verified. Only a real CI run proves the cron clears.

`aimock` ignores `generationConfig` entirely — removing it from the mock
leg fails nothing. It is sent for parity, and that parity is unguarded.

`src/ws-gemini-live.ts:468-505` — the audio branch emits `inlineData` +
`turnComplete` and returns, dropping the `toolCalls` / `content`
companions that `types.ts:398-406` documents as preserved "so the tool
call / content / reasoning are not silently discarded."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant