switchroom 0.20.8 → 0.20.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/agent-scheduler/index.js +16 -13
  2. package/dist/auth-broker/index.js +51 -29
  3. package/dist/cli/autoaccept-poll.js +5 -3
  4. package/dist/cli/drive-write-pretool.mjs +5 -3
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -3
  6. package/dist/cli/notion-write-pretool.mjs +6 -6
  7. package/dist/cli/switchroom.js +12 -10
  8. package/dist/host-control/main.js +7 -7
  9. package/dist/vault/approvals/kernel-server.js +6 -6
  10. package/dist/vault/broker/server.js +6 -6
  11. package/package.json +1 -1
  12. package/profiles/default/CLAUDE.md.hbs +12 -13
  13. package/telegram-plugin/ask-user.ts +6 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +183 -66
  15. package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
  16. package/telegram-plugin/gateway/auth-command.ts +4 -2
  17. package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
  18. package/telegram-plugin/gateway/gateway.ts +8 -4
  19. package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
  20. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
  21. package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
  22. package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
  23. package/telegram-plugin/render/emphasis-guard.ts +92 -12
  24. package/telegram-plugin/render/line-start-guard.ts +27 -2
  25. package/telegram-plugin/sticker-aliases.ts +12 -14
  26. package/telegram-plugin/tests/ask-user.test.ts +15 -0
  27. package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
  28. package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
  29. package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
  30. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
  31. package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
  32. package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
  33. package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
  34. package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
  35. package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
  36. package/telegram-plugin/throttle-tier.ts +59 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Tests for the generic-transient PROBE-ONLY 429 path (#failover-429-corroborate).
3
+ *
4
+ * This pins the OUTCOMES the gateway promises for a bare `rate_limit_error`
5
+ * (generic-transient) 429 after Ken picked Option A (probe-only):
6
+ *
7
+ * - generic-transient + HEALTHY probe → the probe's ONLY effect is the
8
+ * silent broker quota refresh. Account-inert at the runner: NO throttle
9
+ * notice, NO self-restart nudge, NO `throttled_until` soft-defer, NO second
10
+ * card. The calm rate-limited card the gateway already emitted stays the
11
+ * ONLY user-visible output.
12
+ * - generic-transient + WALL (probe corroborates) → the escalation path runs
13
+ * exactly like the account-scoped `fire`: the corroborated-wall announcement
14
+ * posts and the dead turn is resumed. No redundant calm card is emitted from
15
+ * this branch — the announcement IS the output.
16
+ * - litellm-local → the runner does NOT fire at all (account-inert by
17
+ * mechanism, not discipline): the request never reached Anthropic, so
18
+ * account state must not be touched. `account-scoped` likewise takes its own
19
+ * `fire` path, not `fireProbeOnly`. Pinned via the pure classification gate
20
+ * the gateway consults before calling `fireProbeOnly`.
21
+ *
22
+ * The runner side is exercised with fully injected deps (no gateway import),
23
+ * mirroring throttle-tier-wiring.test.ts.
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest'
27
+ import {
28
+ createThrottleTierRunner,
29
+ type ThrottleBrokerClient,
30
+ type ThrottleTierRunnerDeps,
31
+ } from '../gateway/throttle-tier-wiring.js'
32
+ import { classification429WarrantsCorroboration } from '../throttle-tier.js'
33
+
34
+ const NOW = Date.UTC(2026, 6, 12, 8, 0, 0)
35
+
36
+ interface Harness {
37
+ deps: ThrottleTierRunnerDeps
38
+ calls: {
39
+ markThrottled: Array<{ until: number; probeOnly?: boolean }>
40
+ claims: string[]
41
+ notices: Array<{ chatId: string | number; markdown: string }>
42
+ deferrals: string[]
43
+ restarts: string[]
44
+ logs: string[]
45
+ timers: Array<{ ms: number; fn: () => void; cancelled: boolean }>
46
+ }
47
+ }
48
+
49
+ function makeHarness(opts: {
50
+ markThrottledResult?:
51
+ | { account: string; throttled_until: number; escalated: boolean; rolledTo?: string | null }
52
+ | 'unreachable'
53
+ | 'throw'
54
+ turnInFlight?: () => boolean
55
+ newestTurnStartedAt?: () => number | null
56
+ } = {}): Harness {
57
+ const nowMs = NOW
58
+ const calls: Harness['calls'] = {
59
+ markThrottled: [],
60
+ claims: [],
61
+ notices: [],
62
+ deferrals: [],
63
+ restarts: [],
64
+ logs: [],
65
+ timers: [],
66
+ }
67
+ const client: ThrottleBrokerClient = {
68
+ async markThrottled(until: number, probeOnly?: boolean) {
69
+ calls.markThrottled.push({ until, probeOnly })
70
+ if (opts.markThrottledResult === 'throw') throw new Error('boom')
71
+ const r = opts.markThrottledResult
72
+ if (r && r !== 'unreachable') return r
73
+ return { account: 'alice', throttled_until: until, escalated: false, rolledTo: null }
74
+ },
75
+ async claimNotification(key: string) {
76
+ calls.claims.push(key)
77
+ return { granted: true }
78
+ },
79
+ }
80
+ const deps: ThrottleTierRunnerDeps = {
81
+ agentName: 'carrie',
82
+ getBrokerClient: async () =>
83
+ opts.markThrottledResult === 'unreachable' ? null : client,
84
+ listNoticeChats: () => ['111', '222'],
85
+ sendNotice: (chatId, markdown) => calls.notices.push({ chatId, markdown }),
86
+ resumeDecide: () => 'resume',
87
+ newestActiveTurnStartedAtMs: opts.newestTurnStartedAt ?? (() => null),
88
+ turnInFlight: opts.turnInFlight ?? (() => false),
89
+ deferRestartToTurnComplete: (_agent, reason) => calls.deferrals.push(reason),
90
+ restartNow: (_agent, reason) => calls.restarts.push(reason),
91
+ log: (m) => calls.logs.push(m),
92
+ now: () => nowMs,
93
+ schedule: (fn, ms) => {
94
+ const t = { ms, fn, cancelled: false }
95
+ calls.timers.push(t)
96
+ return { cancel: () => { t.cancelled = true } }
97
+ },
98
+ jitterMs: () => 0,
99
+ }
100
+ return { deps, calls }
101
+ }
102
+
103
+ describe('generic-transient probe-only — classification gate (litellm-local never fires)', () => {
104
+ it('fires the corroboration probe ONLY for generic-transient', () => {
105
+ expect(classification429WarrantsCorroboration('generic-transient')).toBe(true)
106
+ // litellm-local: request never reached Anthropic — account-inert by mechanism.
107
+ expect(classification429WarrantsCorroboration('litellm-local')).toBe(false)
108
+ // account-scoped: runs its own throttle tier / failover (fire, not fireProbeOnly).
109
+ expect(classification429WarrantsCorroboration('account-scoped')).toBe(false)
110
+ // null: not a rate-limited event.
111
+ expect(classification429WarrantsCorroboration(null)).toBe(false)
112
+ })
113
+ })
114
+
115
+ describe('generic-transient probe-only — HEALTHY probe is account-inert', () => {
116
+ it('probes the broker but emits NOTHING else (calm card stays the only output)', async () => {
117
+ const h = makeHarness() // default result: escalated:false (healthy)
118
+ const runner = createThrottleTierRunner(h.deps)
119
+ await runner.fireProbeOnly('carrie')
120
+
121
+ // The probe reached the broker in probe-only mode…
122
+ expect(h.calls.markThrottled).toHaveLength(1)
123
+ expect(h.calls.markThrottled[0].probeOnly).toBe(true)
124
+
125
+ // …but produced NO user-visible or account-mutating side effects:
126
+ expect(h.calls.notices).toHaveLength(0) // no throttle notice, no second card
127
+ expect(h.calls.claims).toHaveLength(0) // no fleet-dedup broadcast at all
128
+ expect(h.calls.restarts).toHaveLength(0) // no self-restart nudge
129
+ expect(h.calls.deferrals).toHaveLength(0)
130
+ expect(h.calls.timers).toHaveLength(0) // no throttled_until soft-defer / retry nudge
131
+ expect(runner.inspect().nudgePending).toBe(false)
132
+ })
133
+
134
+ it('a broker-unreachable probe is inert and never throws', async () => {
135
+ const h = makeHarness({ markThrottledResult: 'unreachable' })
136
+ const runner = createThrottleTierRunner(h.deps)
137
+ await expect(runner.fireProbeOnly('carrie')).resolves.toBeUndefined()
138
+ expect(h.calls.markThrottled).toHaveLength(0)
139
+ expect(h.calls.notices).toHaveLength(0)
140
+ expect(h.calls.restarts).toHaveLength(0)
141
+ expect(h.calls.timers).toHaveLength(0)
142
+ })
143
+
144
+ it('a markThrottled throw is swallowed and stays inert (no card, no restart)', async () => {
145
+ const h = makeHarness({ markThrottledResult: 'throw' })
146
+ const runner = createThrottleTierRunner(h.deps)
147
+ await expect(runner.fireProbeOnly('carrie')).resolves.toBeUndefined()
148
+ expect(h.calls.notices).toHaveLength(0)
149
+ expect(h.calls.restarts).toHaveLength(0)
150
+ expect(h.calls.timers).toHaveLength(0)
151
+ })
152
+ })
153
+
154
+ describe('generic-transient probe-only — WALL corroborated → escalation', () => {
155
+ it('posts the corroborated-wall announcement and resumes immediately (no calm card, no nudge timer)', async () => {
156
+ const h = makeHarness({
157
+ markThrottledResult: {
158
+ account: 'alice',
159
+ throttled_until: NOW + 60_000,
160
+ escalated: true,
161
+ rolledTo: 'bob',
162
+ },
163
+ })
164
+ const runner = createThrottleTierRunner(h.deps)
165
+ await runner.fireProbeOnly('carrie')
166
+
167
+ // Probe ran in probe-only mode, then escalation took over.
168
+ expect(h.calls.markThrottled[0].probeOnly).toBe(true)
169
+
170
+ // The escalation announcement (fleet-deduped), NOT the staying-put notice.
171
+ expect(h.calls.claims).toEqual([
172
+ `throttle-escalation:alice:111`,
173
+ `throttle-escalation:alice:222`,
174
+ ])
175
+ expect(h.calls.notices).toHaveLength(2)
176
+ expect(h.calls.notices[0].markdown).toContain('actually a wall')
177
+ expect(h.calls.notices[0].markdown).toContain('bob')
178
+
179
+ // Immediate resume via the escalation lever; NO delayed retry nudge armed.
180
+ expect(h.calls.restarts).toEqual(['throttle-escalation-resume'])
181
+ expect(h.calls.timers).toHaveLength(0)
182
+ })
183
+
184
+ it('escalated with rolledTo=null (all blocked) announces but does NOT restart', async () => {
185
+ const h = makeHarness({
186
+ markThrottledResult: {
187
+ account: 'alice',
188
+ throttled_until: NOW + 60_000,
189
+ escalated: true,
190
+ rolledTo: null,
191
+ },
192
+ })
193
+ const runner = createThrottleTierRunner(h.deps)
194
+ await runner.fireProbeOnly('carrie')
195
+ expect(h.calls.notices[0].markdown).toContain('all blocked')
196
+ expect(h.calls.restarts).toHaveLength(0)
197
+ expect(h.calls.timers).toHaveLength(0)
198
+ })
199
+
200
+ it('escalated resume respects the live-turn guards (defers under the dead turn gate)', async () => {
201
+ const h = makeHarness({
202
+ markThrottledResult: {
203
+ account: 'alice',
204
+ throttled_until: NOW + 60_000,
205
+ escalated: true,
206
+ rolledTo: 'bob',
207
+ },
208
+ turnInFlight: () => true,
209
+ newestTurnStartedAt: () => NOW - 60_000, // the dead turn still holds the gate
210
+ })
211
+ const runner = createThrottleTierRunner(h.deps)
212
+ await runner.fireProbeOnly('carrie')
213
+ expect(h.calls.restarts).toHaveLength(0)
214
+ expect(h.calls.deferrals).toEqual(['throttle-escalation-resume'])
215
+ })
216
+ })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * L1 SEAM-BOUNDARY GUARD for the 429 classify→route wiring (follow-up to
3
+ * switchroom#4381 / #4379).
4
+ *
5
+ * `throttle-tier-route-429.test.ts` proves the ROUTING LOGIC of the pure seam
6
+ * `routeRateLimit429` in isolation (generic-transient → fireProbeOnly;
7
+ * litellm-local / account-scoped / null → no probe). But nothing asserted that
8
+ * the GATEWAY still calls that seam at its 429-handling path. That is the exact
9
+ * gap the extraction opened: a future edit deleting the gateway callsite
10
+ * (gateway.ts, `emitGatewayOperatorEvent` rate-limited calm branch) would
11
+ * silently drop the corroboration probe for every `generic-transient` 429 —
12
+ * a real 5h/7d wall hiding behind transient wording would again die with no
13
+ * failover — while the seam's own unit suite stayed fully green.
14
+ *
15
+ * gateway.ts is a side-effecting IIFE that cannot be imported in a unit test
16
+ * (same constraint documented in turn-flush-suppression-wiring.test.ts /
17
+ * activity-card-wiring.test.ts): `emitGatewayOperatorEvent` is a module-scoped,
18
+ * un-exported function reachable only after the gateway boots. So an
19
+ * outcome-level "drive emitGatewayOperatorEvent and observe fireProbeOnly" test
20
+ * is infeasible without exporting internal gateway state — scope creep the
21
+ * repo deliberately avoids. Following the established wiring-guard pattern,
22
+ * this is a STRUCTURAL assertion that pins the load-bearing call site. It
23
+ * COMPLEMENTS the seam suite: the routing outcomes are proven there; this
24
+ * guards that the gateway actually routes through them.
25
+ *
26
+ * The outcome that flips on the regression: with the callsite present, a
27
+ * `generic-transient` 429 reaches `routeRateLimit429(..., throttleTierRunner,
28
+ * agent)` and fires the probe on the REAL runner; delete or neuter the
29
+ * callsite and these assertions go red. Verified fails-red by deleting
30
+ * gateway.ts:7831 locally (see PR body).
31
+ */
32
+ import { describe, it, expect } from 'vitest'
33
+ import { readFileSync } from 'node:fs'
34
+ import { resolve } from 'node:path'
35
+
36
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
37
+
38
+ function between(src: string, startMarker: string, endMarker: string): string {
39
+ const after = src.split(startMarker)[1] ?? ''
40
+ return after.split(endMarker)[0] ?? ''
41
+ }
42
+
43
+ /** Strip comment lines so prose (a docstring mentioning the call, or a comment
44
+ * describing the OLD shape) can neither satisfy nor trip an assertion about
45
+ * the actual CODE. */
46
+ function codeOnly(src: string): string {
47
+ return src
48
+ .split('\n')
49
+ .filter((l) => !l.trim().startsWith('//'))
50
+ .filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
51
+ .join('\n')
52
+ }
53
+
54
+ describe('429 route-seam wiring — the gateway calls routeRateLimit429 (switchroom#4381 L1)', () => {
55
+ // The calm-429 branch: from the classification binding through the surface
56
+ // decision that immediately follows the seam call.
57
+ const branch = between(
58
+ gatewaySrc,
59
+ 'const rateLimit429Classification =',
60
+ "if (surface === 'litellm-local-notice')",
61
+ )
62
+
63
+ it('the calm-429 branch routes the classification through routeRateLimit429', () => {
64
+ expect(branch.length).toBeGreaterThan(100)
65
+ const code = codeOnly(branch)
66
+ // The load-bearing call site. Deleting it drops the corroboration probe for
67
+ // every generic-transient 429 with the seam's unit suite still green — the
68
+ // exact regression this guard exists to catch.
69
+ expect(code).toMatch(/routeRateLimit429\(/)
70
+ // It must be fed the CLASSIFICATION computed for this event, not a literal
71
+ // or a stale variable — otherwise the wrong family would (not) be probed.
72
+ expect(code).toMatch(
73
+ /routeRateLimit429\(\s*rateLimit429Classification\s*,/,
74
+ )
75
+ })
76
+
77
+ it('the seam is handed the REAL throttleTierRunner (a live probe, not a no-op)', () => {
78
+ const code = codeOnly(branch)
79
+ // Passing anything other than the gateway's real runner (e.g. a stub) would
80
+ // make the probe fire into the void — the branch outcome would look correct
81
+ // in a naive grep but corroborate nothing.
82
+ expect(code).toMatch(
83
+ /routeRateLimit429\(\s*rateLimit429Classification\s*,\s*throttleTierRunner\s*,\s*agent\s*\)/,
84
+ )
85
+ })
86
+
87
+ it('routeRateLimit429 is imported into the gateway (the seam is actually linked)', () => {
88
+ // A dangling call to an unimported symbol would fail tsc, but pinning the
89
+ // import makes the dependency edge explicit and its removal a red test too.
90
+ expect(codeOnly(gatewaySrc)).toMatch(/\brouteRateLimit429\b/)
91
+ })
92
+ })
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tests for the classify→route WIRING of the gateway's 429 corroboration path
3
+ * (#failover-429-corroborate, follow-up switchroom#4379).
4
+ *
5
+ * The pure gate `classification429WarrantsCorroboration` is already unit-tested
6
+ * (throttle-tier-probe-only.test.ts), and the runner's `fireProbeOnly` outcomes
7
+ * are pinned there too. What was NOT covered is the WIRING that connects them:
8
+ * the gateway callsite (gateway.ts, `handleOperatorEvent` rate-limited branch)
9
+ * that decides whether to invoke `throttleTierRunner.fireProbeOnly(agent)` for a
10
+ * given classification.
11
+ *
12
+ * gateway.ts is hard to unit-test in isolation and is under a hard line ratchet,
13
+ * so rather than stand up a gateway harness the decision was extracted into a
14
+ * pure, injectable seam — `routeRateLimit429(classification, runner, agent)` in
15
+ * throttle-tier.ts — which the gateway callsite now calls verbatim. These tests
16
+ * exercise that seam with a fake runner that records which entrypoint fired,
17
+ * pinning the three routing outcomes the gateway promises:
18
+ *
19
+ * - generic-transient → routes to fireProbeOnly (the probe fires)
20
+ * - litellm-local → does NOT route to fireProbeOnly (account-inert by
21
+ * mechanism: the request never reached Anthropic)
22
+ * - account-scoped → does NOT route to fireProbeOnly (that classification
23
+ * takes its own `fire` path, not the probe)
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest'
27
+ import { routeRateLimit429, type RateLimit429ProbeRunner } from '../throttle-tier.js'
28
+
29
+ interface FakeRunner extends RateLimit429ProbeRunner {
30
+ probeCalls: string[]
31
+ }
32
+
33
+ function makeFakeRunner(): FakeRunner {
34
+ const probeCalls: string[] = []
35
+ return {
36
+ probeCalls,
37
+ async fireProbeOnly(triggerAgent: string) {
38
+ probeCalls.push(triggerAgent)
39
+ },
40
+ }
41
+ }
42
+
43
+ describe('routeRateLimit429 — gateway classify→route wiring (switchroom#4379)', () => {
44
+ it('generic-transient → routes to fireProbeOnly', () => {
45
+ const runner = makeFakeRunner()
46
+ const fired = routeRateLimit429('generic-transient', runner, 'carrie')
47
+ expect(fired).toBe(true)
48
+ expect(runner.probeCalls).toEqual(['carrie'])
49
+ })
50
+
51
+ it('litellm-local → does NOT route to fireProbeOnly (stays account-inert)', () => {
52
+ const runner = makeFakeRunner()
53
+ const fired = routeRateLimit429('litellm-local', runner, 'carrie')
54
+ expect(fired).toBe(false)
55
+ expect(runner.probeCalls).toHaveLength(0)
56
+ })
57
+
58
+ it('account-scoped → does NOT route to fireProbeOnly (uses fire, not the probe)', () => {
59
+ const runner = makeFakeRunner()
60
+ const fired = routeRateLimit429('account-scoped', runner, 'carrie')
61
+ expect(fired).toBe(false)
62
+ expect(runner.probeCalls).toHaveLength(0)
63
+ })
64
+
65
+ it('null (not a rate-limited event) → does NOT route to fireProbeOnly', () => {
66
+ const runner = makeFakeRunner()
67
+ const fired = routeRateLimit429(null, runner, 'carrie')
68
+ expect(fired).toBe(false)
69
+ expect(runner.probeCalls).toHaveLength(0)
70
+ })
71
+ })
@@ -120,6 +120,65 @@ export function classify429Detail(text: string): RateLimit429Classification {
120
120
  return 'generic-transient'
121
121
  }
122
122
 
123
+ /**
124
+ * #failover-429-corroborate — does this terminal 429 classification warrant a
125
+ * fire-and-forget broker corroboration probe (throttle-tier runner's
126
+ * PROBE-ONLY entrypoint) IN ADDITION to the calm rate-limited card?
127
+ *
128
+ * TRUE for `generic-transient` ONLY. That family (a bare `rate_limit_error`
129
+ * body / novel wording that matches neither the account-scoped negation
130
+ * strings nor litellm-local) used to drop on the calm-card floor with NO broker
131
+ * contact — so a genuine 5h/7d wall hiding behind transient wording was never
132
+ * probed, and the turn died with no failover. Routing it through the runner's
133
+ * `fireProbeOnly` lets the broker take ONE live quota probe (rate-bounded) and
134
+ * convert a real wall into failover. A HEALTHY probe is fully account-inert:
135
+ * probe-only records NO `throttled_until`, sends NO throttle notice, arms NO
136
+ * self-restart nudge, and adds NO second card — so the calm rate-limited card
137
+ * stays the ONLY user-visible output, exactly as before this path existed.
138
+ *
139
+ * FALSE (never corroborate) for:
140
+ * - `litellm-local` — INVARIANT: the request never reached Anthropic (the
141
+ * proxy's own tpm/rpm limiter tripped), so account state must not be
142
+ * touched. This mechanism, not discipline, keeps that path account-inert.
143
+ * - `account-scoped` — already runs its own throttle tier / failover path.
144
+ * - `null` — not a rate-limited event.
145
+ */
146
+ export function classification429WarrantsCorroboration(
147
+ classification: RateLimit429Classification | null,
148
+ ): boolean {
149
+ return classification === 'generic-transient'
150
+ }
151
+
152
+ /**
153
+ * Minimal runner shape the classify→route seam needs: just the PROBE-ONLY
154
+ * entrypoint. The full gateway `ThrottleTierRunner` satisfies it structurally,
155
+ * and a test can supply a fake that records the call.
156
+ */
157
+ export interface RateLimit429ProbeRunner {
158
+ fireProbeOnly(triggerAgent: string): Promise<void>
159
+ }
160
+
161
+ /**
162
+ * #failover-429-corroborate — the classify→route WIRING extracted from the
163
+ * gateway callsite so the routing decision (not just the gate) is unit-testable
164
+ * without a gateway harness. Fires the runner's PROBE-ONLY entrypoint IFF the
165
+ * classification warrants corroboration (`generic-transient` only, per
166
+ * `classification429WarrantsCorroboration`); `litellm-local`, `account-scoped`,
167
+ * and `null` never fire it. Fire-and-forget: the runner promise is intentionally
168
+ * not awaited (the gateway must not block the operator-event path on a probe).
169
+ * Returns `true` when a probe was fired, `false` otherwise — observable in tests
170
+ * and unused at the gateway callsite.
171
+ */
172
+ export function routeRateLimit429(
173
+ classification: RateLimit429Classification | null,
174
+ runner: RateLimit429ProbeRunner,
175
+ agent: string,
176
+ ): boolean {
177
+ if (!classification429WarrantsCorroboration(classification)) return false
178
+ void runner.fireProbeOnly(agent)
179
+ return true
180
+ }
181
+
123
182
  /**
124
183
  * Build the `rate_limit_429_classified` runtime metric for one terminal
125
184
  * rate-limited operator event — the instrumentation that lets an operator