fix(drift): the drift pipeline stops failing on faults that are not drift - #371
Merged
Conversation
… (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.
…, not as unparseable output
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.
…ng the drift legs
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.
…hat blocked the PRs
commit: |
This was referenced Aug 12, 2026
Contributor
Author
|
Merging with 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 |
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."
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The
Fix Driftcron 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\smatches newlines — combined withcompareShapessettingmock: ""on 100% of its diffs, meant an entry with an emptymockvalue consumed its successor.Measured across every permutation: the trigger is one empty-
mockentry 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.tsandvideo.drift.ts, losingfloor(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.", exit0,conclusion: "clean". Green:Critical diffs: 1, exit2,conclusion: "critical".A
^line anchor closes a second defect in the same pass: without it, a numbered line in prose followed byPath:/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
mainjob 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 withexit 5 — manual triage required.Four lanes now, split on what the close frame actually says:
1002/1003/1007/1008/1009/1010and4000-4999. The frame names something we sent. Attributed critical drift, exit 2, code and reason preserved.1000/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.exit 0: those legs graded nothing, so the run must never be adopted as a reusable clean baseline.Measured end to end:
2,6,6,5. The refusal codes are a set, not a range — a first attempt used1002..1010and the negative controls caught it, since1004is reserved and1005/1006are never sent on the wire. Boundaries pinned:3999and5000are 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:
After, A names
code=1008and its reason, and B is byte-identical before and after, compared withBuffer.comparerather 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-3re-collects the live suite and refuses on its global critical count. But no*.drift.tsreadsdeprecatedFamilies— 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, changeset74f6efa43753f7d0,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 —
reportTrustNoteno longer reports a quarantined, AG-UI-skipped, orconclusion-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
computeChangesetKeyreproduces74f6efa43753f7d0byte-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\band vitest always prefixesError:, so it can never fire — the old message matched nothing either. The old behaviour wasexit 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-pron this PR runs the collector fromorigin/mainby 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.