perf(routing): build the recorded decision compact, and stop health reads from probing breakers - #41
Merged
Conversation
…ing it
`buildRoutingDecision` materialised every candidate with its full factor
breakdown, and only then did `recordRoutingDecision` compact the result down
to 40 candidates / 10 factor breakdowns. `MAX_STORED_CANDIDATES` bounded what
was retained, not what was allocated, and recording is ungated: every live
auto request paid for the whole graph.
The live recording path now passes the store's own bounds as `retention`, so
the decision is built in the shape it is retained in. Factors are filled in
after ordering and selection, for the candidates that keep them plus the
selected one; nothing before that point reads `factors`, so the retained
candidates and their order are unchanged. The build carries the same
`omittedCandidates` count the store produces, and the store still compacts,
so a decision that arrives compact passes through unchanged.
Preview (`POST /api/omniroute/route/preview`, `previewRoutingDecision`) passes
no retention and keeps returning the full, uncompacted candidate list.
Explainability is untouched: decision id, policy version and the selected
candidate's factors are recorded for every routed request exactly as before.
Measured on a 300-candidate and a 50-candidate pool (build + record, mean of
2000 iterations; bytes = heap retained by one built decision, mean of 200):
300 candidates 18.877 ms / 605451 B (591.3 KiB) -> 4.891 ms / 27120 B (26.5 KiB)
50 candidates 5.177 ms / 101634 B (99.3 KiB) -> 1.390 ms / 27260 B (26.6 KiB)
Red-first: `tests/unit/routing-decision-compact-build.test.ts` fails on the
previous code (compact build returns 300 candidates, expected 40) and proves
the stored decision `GET /api/omniroute/route/decisions/{id}` returns is
byte-for-byte identical between a full build compacted by the store and a
build bounded at construction, for pools of 300, 50, 40 and 7 candidates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`getAllCircuitBreakerStatuses()` calls `getStatus()`, which transitions an OPEN breaker whose cooldown has elapsed to HALF_OPEN, persists that, and grants a half-open probe. Seven read paths used it, so merely *looking* at health flipped a breaker and let the next request through to a provider that is still broken — and a dashboard that polls did it on every poll. PR #38 fixed `/api/metrics` and the SLO timer with the read-only `getAllCircuitBreakerSnapshots()`/`peekStatus()` API; these are the remaining seven, all of them true reads, all converted: /api/resilience/connections · providerHealthMatrix · providerHealthAutopilot omnirouteStatus · /api/monitoring/health (GET) · webSessionPoolHealth accountFallback.getProvidersInCooldown New read-only API in `src/shared/utils/circuitBreaker.ts`: - `CircuitBreaker.peekCanExecute()` — `canExecute()` without the transition. An elapsed OPEN breaker reads as executable (the next live call would grant it a probe) but nothing transitions, persists or consumes one. - `peekCircuitBreaker(name)` — the registered breaker, or an unregistered copy of its persisted state. Never registers, evicts or transitions. - `getBlockedCircuitBreakerSnapshots()` — snapshots of the breakers that would refuse a request, the read-only counterpart of filtering by `!canExecute()`. `getProvidersInCooldown` and the webSessionPoolHealth deps used the probing accessors on individual breakers (`canExecute()`, `getRetryAfterMs()`, `getStatus()`), so switching only the registry call would have left them mutating; they now use the peek accessors and return the same values. Reported output is unchanged: the effective state of an elapsed breaker is still HALF_OPEN, which is what PR #38 established and what dashboards must show. Reporting that state is not the same as causing it. Two side effects are gone besides the transition: a read no longer registers persisted breakers into the in-process registry, and so no longer triggers cold-breaker eviction. Left alone, deliberately: - `DELETE /api/monitoring/health` and `POST /api/resilience/reset` — these are resets, not reads; enumerating breakers is how the reset reaches persisted ones that this process has not loaded. - live routing's `isProviderInCooldown` / `checkFallbackError` path — there the OPEN -> HALF_OPEN transition is the point: it is how a recovered provider gets its next request. - `src/lib/a2a/skills/providerDiscovery.ts` uses the same mutating call and is also a read, but it is outside this finding's stated scope; flagged for a follow-up rather than folded in here. Red-first: `tests/unit/health-reads-do-not-probe-breakers.test.ts` fails on the previous code for all seven paths (verified path by path) and asserts that after two reads an OPEN breaker whose cooldown elapsed is still OPEN, recorded no transition, was granted no half-open probe, and has a byte-identical persisted row — while live routing afterwards still gets its probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The eighth occurrence of the same finding, folded in on review. The A2A provider-discovery skill called `getAllCircuitBreakerStatuses()` purely to build a name -> breaker map for the health field of each listed candidate — a read, the same as the seven converted in a545603, and leaving it out made the fix inconsistent. `getStatus()` transitions an elapsed OPEN breaker to HALF_OPEN and persists that, so an agent asking which providers exist handed the next request a probe into a provider that is still broken. Converted to `getAllCircuitBreakerSnapshots()`. The map is now typed with the real `CircuitBreakerStatus` instead of a callback annotated with the file's loose `CircuitBreakerLike` alias, which also drops the `breaker.name || ""` fallback — `name` is required on the snapshot type. `CircuitBreakerLike` stays as the parameter type of `healthFromBreaker`, which legitimately takes a possibly absent, partially known breaker. No cast was added. Reported output is unchanged: `healthFromBreaker` maps HALF_OPEN to "recovering" either way, and that is what an elapsed breaker reported before, after the read had caused the transition. Red-first: the `a2a providerDiscovery` case added to tests/unit/health-reads-do-not-probe-breakers.test.ts fails on the previous code ("the read did not move the breaker to HALF_OPEN"; actual 'HALF_OPEN', expected 'OPEN') and asserts the same invariants as the other seven — still OPEN, no transition recorded, no half-open probe granted, persisted row byte-identical — while checking discovery really listed candidates, so the assertion cannot pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`check-mutation-test-coverage --strict` blocks when a unit test that imports a
mutated module is missing from stryker.conf.json `tap.testFiles`: Stryker only
runs the listed files against each mutant, so an unlisted test's kills stop
counting and the module's covered mutation score collapses on a cold-cache run.
tests/unit/health-reads-do-not-probe-breakers.test.ts imports two mutated
modules — open-sse/services/accountFallback.ts and
src/shared/utils/circuitBreaker.ts — and was the whole drift. Added in sorted
position; the list stays sorted (392 entries) and the file stays Prettier-clean,
written with LF so the committed blob matches the base blob's line endings.
tests/unit/routing-decision-compact-build.test.ts is deliberately NOT added: it
imports open-sse/services/autoCombo/routingDecision.ts,
open-sse/services/combo/autoRoutingDecision.ts and
open-sse/services/routing/decisionStore.ts, none of which are in stryker.conf.json
`mutate`. The gate does not ask for it, and listing it would make Stryker run an
extra test file against every mutant for no coverage.
Verified by calling the gate's own `findCoverageDrift` directly — the
check-mutation-test-coverage.mjs CLI exits 0 without checking anything on
Windows, because its `import.meta.url === ` + "`file://${process.argv[1]}`" + `
main-module guard never matches a drive-letter path, so running the script is
not proof:
before: 2 covering unit test(s) across 2 module(s) missing from tap.testFiles
open-sse/services/accountFallback.ts
+ tests/unit/health-reads-do-not-probe-breakers.test.ts
src/shared/utils/circuitBreaker.ts
+ tests/unit/health-reads-do-not-probe-breakers.test.ts
after: No drift - every covering unit test is listed in tap.testFiles.
(5039 unit test files scanned against 31 mutated modules)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LMPrado-DZ23
pushed a commit
that referenced
this pull request
Sep 19, 2026
The verification round — the three auditors re-checking their own fixes on the final tree — produced two more pull requests, so the release documents have to carry them. CHANGELOG [3.8.54] is dated 2026-09-19, the day the tag is cut, and gains the work from #41 and #42: the cross-origin redirect body leak in the TypeScript SDK, the eight health read paths that were transitioning an OPEN breaker to HALF_OPEN just by being read, the decision lookup now requiring auth when requireLogin is off, the routing-decision build going from 17.0ms/591KiB to 4.6ms/26.5KiB per request at 300 candidates, the adm-zip bump that took the root production audit to zero, and the check:lockfile fix. The two bullets the base tree already carried are still untouched, so the diff stays additive; the 41 i18n mirrors are resynced. EVOLUTION_STATUS now names all five audit pull requests and separates the first audit round from the verification round. Verification (isolated env): check-changelog-integrity exit 0 no base bullets lost check-docs-frontmatter exit 0 check-doc-links exit 0 check-fabricated-docs exit 0 check-docs-sync exit 0 41 locales Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LMPrado-DZ23
pushed a commit
that referenced
this pull request
Sep 19, 2026
Records both rounds of the independent audit in FINAL_THREE_AGENT_REVIEW, and restructures the file by release line so the v3.8.51 record stays intact underneath its own heading instead of being overwritten. Round 1, on 20d5b2c: three auditors in parallel, no access to each other's conclusions. Two HIGH (the decision store bounded only by count, and the webhook wizard leaving an enabled all-events webhook behind on cancel), plus the medium, low and improvement findings, each with where it was fixed. Round 2, the verification round on the final tree 68a00d0: the same three auditors re-checking their own fixes. None was found missing, partial or wrong. All three returned PASS with CRITICAL 0 and HIGH 0. The behaviour evidence that mattered most for this release is recorded: provider selection identical to v3.8.53 over 700 of 700 seeded cases, and previewRoutingDecision calling Math.random zero times over 200 previews with the rotator provably untouched. The findings round 2 raised were fixed, not deferred (#41, #42, #43). The only item accepted without a fix is B-03 / R-10, js-yaml 4.3.1 in the Electron update-check chain — not in the container image, the npm package or the server runtime. AUTONOMOUS_MISSION_STATE moves to CANDIDATE_COMPLETED -> RELEASING, with the phase table, the verification round, the Windows-only gate failures, and a note that the publication evidence has to live in the Release notes because publishing the Release locks this branch. Verification (isolated env): check-doc-links exit 0 172 docs, 1043 internal links check-fabricated-docs exit 0 check-docs-frontmatter exit 0 check-changelog-integrity exit 0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Two MEDIUM findings from the architecture audit of the final v3.8.54 tree, plus an eighth occurrence of the second finding folded in on review. One concern per commit, all red-first.
Finding 1 — MEDIUM, new in 3.8.54: routing-decision recording cost 12.5 ms and ~594 KiB on every live auto request
buildRoutingDecisionmaterialised every candidate with its full factor breakdown, and only then diddecisionStore.recordRoutingDecisioncompact the result down to 40 candidates / 10 factor breakdowns.MAX_STORED_CANDIDATESbounded what was retained, not what was allocated, and recording is ungated — it runs on every live auto request.What changed. The live recording path (
open-sse/services/combo/autoRoutingDecision.ts) now passes the decision store's own bounds tobuildRoutingDecisionas a new optionalretentioninput, so the decision is built in the shape it is retained in. Factors are computed after ordering and selection, only for the candidates that keep them plus the selected one; nothing before that point readsfactors, so the retained candidates and their order are identical to before. The build carries the sameomittedCandidatescount the store produces. The store still compacts, so a decision that arrives already compact passes through unchanged — the store remains the safety net.Explainability is untouched. The decision id, the policy version and the selected candidate's full factor breakdown are recorded for every routed request exactly as before. The decision-id / policy-version headers are unaffected.
Preview is untouched.
POST /api/omniroute/route/preview/previewRoutingDecisionpasses noretentionand keeps returning the full, uncompacted candidate list with every candidate's factors. Covered by a test.Before / after (real numbers)
Benchmark shape: build + record a decision over a pool of N candidates, with the engine selection done once up front and reused.
ms/decisionis the mean of 2000 iterations after a 300-iteration warm-up;bytes/decisionis the heap retained by one built decision graph (mean of 200,--expose-gc, measured on both sides of a forced GC) — the live path discards all of it except what the store keeps, so that is the transient allocation the request pays.Byte figures are deterministic across runs; the 300-candidate before-figure (591.3 KiB) matches the auditor's measured ~594 KiB. The remaining ~4.6 ms at 300 candidates is the unavoidable part: every candidate still has to be scored into a
RoutingCandidateto be ordered and excluded correctly, plus the policy-version hash and the store's size estimate. The factor objects, which were the bulk of the allocation, drop from 4800 to 160.Proof of equivalence
tests/unit/routing-decision-compact-build.test.tsbuilds a decision over the same pool both ways, records each, reads it back through the same accessorGET /api/omniroute/route/decisions/{id}uses, and asserts the serialised results are identical — for pools of 300, 50, 40 and 7 candidates. It also pins the retained ordering, theomittedCandidatescount, the selected candidate keeping its factors, and preview staying uncompacted. On the previous code it fails (actual: 300, expected: 40).Finding 2 — MEDIUM, pre-existing (identical in v3.8.53): read paths mutated circuit-breaker state
getAllCircuitBreakerStatuses()callsgetStatus(), which transitions an OPEN breaker whose cooldown has elapsed to HALF_OPEN, persists that, and grants it a half-open probe. So merely reading health flipped a breaker and let the next request through to a provider that is still broken — and a dashboard that polls did it on every poll. PR #38 fixed/api/metricsand the SLO timer with the read-onlygetAllCircuitBreakerSnapshots()/peekStatus()API.All remaining paths were true reads. All were converted — none legitimately needed the probing transition. The audit named seven; an eighth occurrence of the same bug was found by grep and folded in on review (row 8, its own commit
dea0030c4).GET /api/resilience/connectionssrc/app/api/resilience/connections/route.tssrc/lib/monitoring/providerHealthMatrix.tssrc/lib/monitoring/providerHealthAutopilot.tssrc/lib/omnirouteStatus.tsGET /api/monitoring/healthsrc/app/api/monitoring/health/route.tsopen-sse/services/webSessionPoolHealth.tsgetProvidersInCooldownopen-sse/services/accountFallback.tssrc/lib/a2a/skills/providerDiscovery.tsPaths 6 and 7 also used the probing accessors on individual breakers (
canExecute(),getRetryAfterMs(),getStatus()), so switching only the registry call would have left them mutating. New read-only API insrc/shared/utils/circuitBreaker.ts, adapted at each call site rather than cast:CircuitBreaker.peekCanExecute()—canExecute()without the transition. An elapsed OPEN breaker reads as executable, because the next live call would grant it a probe, but nothing transitions, persists or consumes one.peekCircuitBreaker(name)— the registered breaker, or an unregistered copy of its persisted state. Never registers, evicts or transitions.getBlockedCircuitBreakerSnapshots()— snapshots of the breakers that would refuse a request right now, the read-only counterpart of filtering by!canExecute().Path 8 builds a name-to-breaker map for the health field of each listed candidate. Its map is now typed with the real
CircuitBreakerStatusinstead of a callback annotated with the file's looseCircuitBreakerLikealias, which also drops thebreaker.name || ""fallback —nameis required on the snapshot type.CircuitBreakerLikestays as the parameter type ofhealthFromBreaker, which legitimately takes a possibly absent, partially known breaker. No cast was added anywhere.Reported output is unchanged. The effective state of an elapsed breaker is still reported as HALF_OPEN — that is what PR #38 established and what dashboards must show. Reporting that state is not the same as causing it. (
healthFromBreakermaps HALF_OPEN to"recovering"either way.) Two further side effects are gone: a read no longer registers persisted breakers into the in-process registry, and therefore no longer triggers cold-breaker eviction.Left alone, deliberately
DELETE /api/monitoring/healthandPOST /api/resilience/reset— resets, not reads. Enumerating breakers through the mutating call is how the reset reaches persisted breakers this process has not loaded.isProviderInCooldown/checkFallbackErrorpath — there the OPEN to HALF_OPEN transition is the point: it is how a recovered provider gets its next request.grepforgetAllCircuitBreakerStatusesnow returns only its own definition, the two reset endpoints above, and the doc comment ongetAllCircuitBreakerSnapshots.Regression test
tests/unit/health-reads-do-not-probe-breakers.test.tsopens a breaker, lets its cooldown elapse, then calls each of the eight converted read paths twice and asserts the breaker is stillOPEN, recorded no transition, was granted no half-open probe, and has a byte-identical persisted row — while live routing afterwards still gets its probe. It also pins that the reads still report the breaker (effective stateHALF_OPEN) and that a breaker still inside its cooldown is still reported in cooldown. Verified red on the previous code for each path individually (the discovery case fails withactual: 'HALF_OPEN', expected: 'OPEN'), and every case asserts the read really did something — the route answered 200, the report carried a breaker, discovery listed candidates — so no assertion can pass vacuously.Verification (real output, run on this branch)
Typechecks — all four clean, no output (run with
tscdirectly; thescripts/check/check-*-typecheck.mjswrappers fail on Windows withspawnSync npx.cmd EINVAL):ESLint — clean, no output:
Tests — routing / auto-combo / decision-store suites:
a2a / resilience / health / circuit-breaker / decision suites, after the eighth conversion:
wider resilience / health / circuit-breaker sweep:
All five of that file's tests pass; the failure is its
afterhook deleting its temp directory on Windows. Reproduced identically onorigin/release/v3.8.54with this branch's changes stashed — pre-existing environment flake, not from this PR.Mutation test-coverage gate.
tests/unit/health-reads-do-not-probe-breakers.test.tsimports two mutated modules (open-sse/services/accountFallback.ts,src/shared/utils/circuitBreaker.ts), so it is registered instryker.conf.jsontap.testFilesin sorted position (392 entries, still sorted), written with LF so the committed blob matches the base blob's line endings and stays Prettier-clean.tests/unit/routing-decision-compact-build.test.tsis deliberately not registered: the modules it imports (routingDecision.ts,autoRoutingDecision.ts,decisionStore.ts) are not inmutate, the gate does not ask for it, and listing it would make Stryker run an extra file against every mutant for no coverage.The
check-mutation-test-coverage.mjsCLI exits 0 without checking anything on Windows — itsimport.meta.url === file://${process.argv[1]}main-module guard never matches a drive-letter path — so this was verified by calling its exportedfindCoverageDriftdirectly:File size:
src/lib/db/core.tsis not touched by this PR (git diff origin/release/v3.8.54 -- src/lib/db/core.tsis empty); it is red at the base commit. No baseline was adjusted.open-sse/services/accountFallback.tssits at its frozen cap, so its change here is line-negative (2466 to 2465): the read-only helper lives incircuitBreaker.tsrather than being added to that file.Complexity ratchets:
Prettier.
prettier --checkreports every file in a Windows worktree as unformatted, becausecore.autocrlfchecks out CRLF and the config wants LF; comparing Prettier's output against the line-ending-normalised file instead, 11 of the 13 changed files are clean. The two that are not were already not Prettier-clean atorigin/release/v3.8.54— this is pre-existing drift, not a regression from this PR:open-sse/services/accountFallback.ts— running Prettier against the base blob produces a 40-line diff there, and against this branch's version the same 40-line diff, none of it touching a line this PR adds (the drift is inISO_RETRY_RE, theantigravityQuotaFamilyimport,persistAntigravityFamilyCooldownIfQuota, and two trailing commas, all untouched here).open-sse/services/webSessionPoolHealth.ts— 46 diff lines against the base blob versus 39 here; this PR's import change happens to remove one drifted block and adds none.Reformatting either would have rewritten ~25 unrelated lines and pushed
accountFallback.tspast its frozen size cap, so their existing formatting is left untouched.open-sse/services/combo.tswas not edited.🤖 Generated with Claude Code