switchroom 0.18.15 → 0.18.18

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +16 -0
  2. package/dist/auth-broker/index.js +445 -10
  3. package/dist/cli/notion-write-pretool.mjs +16 -0
  4. package/dist/cli/switchroom.js +654 -479
  5. package/dist/host-control/main.js +20 -1
  6. package/dist/vault/approvals/kernel-server.js +16 -0
  7. package/dist/vault/broker/server.js +16 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/bridge/bridge.ts +7 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  12. package/telegram-plugin/dist/gateway/gateway.js +1758 -661
  13. package/telegram-plugin/dist/server.js +26 -1
  14. package/telegram-plugin/draft-stream.ts +78 -3
  15. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  16. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  17. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +64 -22
  18. package/telegram-plugin/gateway/effort-command.ts +9 -7
  19. package/telegram-plugin/gateway/gateway.ts +627 -291
  20. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  21. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  22. package/telegram-plugin/gateway/model-command.ts +96 -18
  23. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  24. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  25. package/telegram-plugin/gateway/session-model-file.ts +141 -172
  26. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  27. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  28. package/telegram-plugin/litellm-local-notice.ts +189 -0
  29. package/telegram-plugin/llm-error-present.ts +436 -0
  30. package/telegram-plugin/operator-events.ts +7 -1
  31. package/telegram-plugin/permission-title.ts +172 -10
  32. package/telegram-plugin/premium-recovery.ts +101 -0
  33. package/telegram-plugin/quota-watch.ts +16 -4
  34. package/telegram-plugin/raw-error-scrub.ts +73 -0
  35. package/telegram-plugin/retry-api-call.ts +8 -2
  36. package/telegram-plugin/runtime-metrics.ts +16 -0
  37. package/telegram-plugin/send-gate-degraded.test.ts +161 -8
  38. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  39. package/telegram-plugin/send-gate-observability.ts +65 -20
  40. package/telegram-plugin/send-gate.test.ts +143 -1
  41. package/telegram-plugin/send-gate.ts +246 -23
  42. package/telegram-plugin/session-tail.ts +16 -0
  43. package/telegram-plugin/shared/local-time.ts +69 -0
  44. package/telegram-plugin/stream-controller.ts +143 -20
  45. package/telegram-plugin/stream-reply-handler.ts +12 -2
  46. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  47. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  48. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  49. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  50. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  51. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  52. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  53. package/telegram-plugin/tests/flood-windows-persistence.test.ts +5 -4
  54. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  55. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  56. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  57. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  58. package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
  59. package/telegram-plugin/tests/model-command.test.ts +84 -1
  60. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  61. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  62. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  63. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  64. package/telegram-plugin/tests/reaction-gate-routing.test.ts +8 -3
  65. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  66. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  67. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  68. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  69. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  70. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  71. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +212 -2
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  74. package/telegram-plugin/tier-downgrade.ts +198 -0
  75. package/telegram-plugin/tool-activity-summary.ts +99 -0
  76. package/telegram-plugin/worker-activity-feed.ts +543 -368
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Integration test for the premium-recovery ping GLUE
3
+ * (`runPremiumRecoveryPing`, premium-recovery-wiring.ts) — the never-storm
4
+ * orchestration the pure `decidePremiumRecovery` / `renderPremiumRecoveryPing`
5
+ * tests do NOT cover. This is the P0 at-most-once guarantee (claim-gate → clear-
6
+ * marker-before-send → per-chat fan-out) that was inline in the gateway and so
7
+ * had ZERO coverage; the same untested-glue gap the tier-downgrade extraction
8
+ * closed for the downgrade path.
9
+ *
10
+ * It drives the real runner with injected seams and PINS:
11
+ * - clear-BEFORE-send ordering (never-storm) + exactly one send per chat;
12
+ * - claim !granted → marker cleared, NO send;
13
+ * - decision.fire === false → no clear, no claim, no send;
14
+ * - no marker / no agent dir → no-op;
15
+ * - the button `callback_data` is EXACTLY `mdl:alias:<premium>` (a future edit
16
+ * can't silently break the session-scoped switch-back path).
17
+ * These are outcome assertions that FAIL if the guard logic is reordered/dropped.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ runPremiumRecoveryPing,
23
+ type PremiumRecoveryPingDeps,
24
+ type PremiumRecoveryMarkerView,
25
+ type PremiumRecoveryKeyboard,
26
+ } from '../gateway/premium-recovery-wiring.js'
27
+ import type { PremiumRecoveryPing } from '../premium-recovery.js'
28
+
29
+ interface Recorder {
30
+ /** Ordered event log — pins clear-before-send. */
31
+ events: string[]
32
+ clears: number
33
+ claims: string[]
34
+ sends: Array<{ chatId: string; ping: PremiumRecoveryPing; keyboard: PremiumRecoveryKeyboard }>
35
+ decides: number
36
+ logs: string[]
37
+ }
38
+
39
+ function makeDeps(
40
+ over: {
41
+ agentDir?: string | null
42
+ marker?: PremiumRecoveryMarkerView | null
43
+ fire?: boolean
44
+ granted?: boolean
45
+ fallbackChats?: string[]
46
+ } = {},
47
+ ): { deps: PremiumRecoveryPingDeps; rec: Recorder } {
48
+ const rec: Recorder = { events: [], clears: 0, claims: [], sends: [], decides: 0, logs: [] }
49
+ const deps: PremiumRecoveryPingDeps = {
50
+ getAgentDir: () => (over.agentDir === undefined ? '/tmp/agent' : over.agentDir),
51
+ readMarker: () =>
52
+ over.marker === undefined
53
+ ? { premiumModel: 'fable', chats: ['111', '222'] }
54
+ : over.marker,
55
+ clearMarker: () => {
56
+ rec.clears += 1
57
+ rec.events.push('clear')
58
+ },
59
+ getAgent: () => 'klanker',
60
+ decide: () => {
61
+ rec.decides += 1
62
+ return over.fire === undefined ? true : over.fire
63
+ },
64
+ claimNotification: async (key) => {
65
+ rec.claims.push(key)
66
+ return over.granted === undefined ? true : over.granted
67
+ },
68
+ fallbackChats: () => over.fallbackChats ?? ['999'],
69
+ sendToChat: (chatId, ping, keyboard) => {
70
+ rec.sends.push({ chatId, ping, keyboard })
71
+ rec.events.push(`send:${chatId}`)
72
+ },
73
+ log: (msg) => rec.logs.push(msg),
74
+ }
75
+ return { deps, rec }
76
+ }
77
+
78
+ describe('runPremiumRecoveryPing — never-storm at-most-once ordering', () => {
79
+ it('fire + granted: marker cleared BEFORE any send, exactly one send per chat', async () => {
80
+ const { deps, rec } = makeDeps({ marker: { premiumModel: 'fable', chats: ['111', '222'] } })
81
+ await runPremiumRecoveryPing(deps)
82
+ expect(rec.clears).toBe(1)
83
+ // One send per recorded chat, in order, no duplicates.
84
+ expect(rec.sends.map((s) => s.chatId)).toEqual(['111', '222'])
85
+ // The clear MUST precede the first send (a double-send is unrecoverable).
86
+ const clearIdx = rec.events.indexOf('clear')
87
+ const firstSendIdx = rec.events.findIndex((e) => e.startsWith('send:'))
88
+ expect(clearIdx).toBeGreaterThanOrEqual(0)
89
+ expect(firstSendIdx).toBeGreaterThanOrEqual(0)
90
+ expect(clearIdx).toBeLessThan(firstSendIdx)
91
+ // The fleet claim was keyed on agent + the dropped premium token.
92
+ expect(rec.claims).toEqual(['premium-recovery:klanker:fable'])
93
+ expect(rec.logs).toHaveLength(1)
94
+ })
95
+
96
+ it('claim NOT granted: marker cleared (no lingering re-attempt), NO send', async () => {
97
+ const { deps, rec } = makeDeps({ granted: false })
98
+ await runPremiumRecoveryPing(deps)
99
+ expect(rec.claims).toEqual(['premium-recovery:klanker:fable'])
100
+ expect(rec.clears).toBe(1)
101
+ expect(rec.sends).toHaveLength(0)
102
+ })
103
+
104
+ it('decision.fire === false: NO clear, NO claim, NO send (marker survives for a later tick)', async () => {
105
+ const { deps, rec } = makeDeps({ fire: false })
106
+ await runPremiumRecoveryPing(deps)
107
+ expect(rec.decides).toBe(1)
108
+ expect(rec.clears).toBe(0)
109
+ expect(rec.claims).toHaveLength(0)
110
+ expect(rec.sends).toHaveLength(0)
111
+ })
112
+
113
+ it('no marker present: no-op — no decide, no claim, no clear, no send', async () => {
114
+ const { deps, rec } = makeDeps({ marker: null })
115
+ await runPremiumRecoveryPing(deps)
116
+ expect(rec.decides).toBe(0)
117
+ expect(rec.clears).toBe(0)
118
+ expect(rec.claims).toHaveLength(0)
119
+ expect(rec.sends).toHaveLength(0)
120
+ })
121
+
122
+ it('no agent dir: immediate no-op (marker is never even read)', async () => {
123
+ const { deps, rec } = makeDeps({ agentDir: null })
124
+ await runPremiumRecoveryPing(deps)
125
+ expect(rec.decides).toBe(0)
126
+ expect(rec.clears).toBe(0)
127
+ expect(rec.sends).toHaveLength(0)
128
+ })
129
+
130
+ it('marker with no chats falls back to the access allowFrom chats', async () => {
131
+ const { deps, rec } = makeDeps({
132
+ marker: { premiumModel: 'fable', chats: [] },
133
+ fallbackChats: ['999'],
134
+ })
135
+ await runPremiumRecoveryPing(deps)
136
+ expect(rec.sends.map((s) => s.chatId)).toEqual(['999'])
137
+ })
138
+ })
139
+
140
+ describe('runPremiumRecoveryPing — session-scoped switch-back button', () => {
141
+ it('the button callback_data is EXACTLY mdl:alias:<premium> (routes the model-menu apply path)', async () => {
142
+ const { deps, rec } = makeDeps({ marker: { premiumModel: 'fable', chats: ['111'] } })
143
+ await runPremiumRecoveryPing(deps)
144
+ const button = rec.sends[0].keyboard.inline_keyboard[0][0]
145
+ // Pin the exact wire format — a change to MODEL_CALLBACK_ALIAS or the button
146
+ // construction that broke the session-scoped `/model` apply path must fail here.
147
+ expect(button.callback_data).toBe('mdl:alias:fable')
148
+ expect(button.text).toBe('Switch to fable')
149
+ })
150
+ })
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Unit tests for the "premium model recovered" ping.
3
+ *
4
+ * - `decidePremiumRecovery` is the PURE recovery predicate: fires iff a marker
5
+ * is pending AND the broker shows the premium tier servable again (at least
6
+ * one account neither `exhausted` nor `premium_walled`) — the exact
7
+ * complement of the `all-blocked` condition that fired the downgrade. This
8
+ * IS the deterministic recovery signal: both fields are the broker's live-
9
+ * authoritative verdicts, so the ping never depends on model judgement.
10
+ * - `renderPremiumRecoveryPing` pins the honest, session-scoped wording +
11
+ * button label.
12
+ * - `premiumRecoveryClaimKey` keys the fleet-wide dedup on agent + token.
13
+ * - the `.premium-recovery` carrier round-trips + is shape-gated (parity with
14
+ * the `.session-model` / `.session-effort` carriers).
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
18
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import {
22
+ decidePremiumRecovery,
23
+ renderPremiumRecoveryPing,
24
+ premiumRecoveryClaimKey,
25
+ } from '../premium-recovery.js'
26
+ import {
27
+ writePremiumRecoveryFile,
28
+ readPremiumRecoveryFile,
29
+ clearPremiumRecoveryFile,
30
+ parsePremiumRecovery,
31
+ PREMIUM_RECOVERY_FILE,
32
+ } from '../gateway/session-model-file.js'
33
+
34
+ describe('decidePremiumRecovery — deterministic recovery predicate', () => {
35
+ it('no marker → never fires', () => {
36
+ expect(
37
+ decidePremiumRecovery({ hasMarker: false, accounts: [{ exhausted: false, premiumWalled: false }] }),
38
+ ).toEqual({ fire: false, reason: 'no-marker' })
39
+ })
40
+
41
+ it('marker pending but EVERY account still walled/exhausted → no fire (still-walled)', () => {
42
+ const d = decidePremiumRecovery({
43
+ hasMarker: true,
44
+ accounts: [
45
+ { exhausted: true, premiumWalled: false },
46
+ { exhausted: false, premiumWalled: true },
47
+ { exhausted: true, premiumWalled: true },
48
+ ],
49
+ })
50
+ expect(d).toEqual({ fire: false, reason: 'still-walled' })
51
+ })
52
+
53
+ it('marker pending AND one account can serve the premium tier again → fire', () => {
54
+ const d = decidePremiumRecovery({
55
+ hasMarker: true,
56
+ accounts: [
57
+ { exhausted: true, premiumWalled: true },
58
+ { exhausted: false, premiumWalled: false }, // recovered
59
+ ],
60
+ })
61
+ expect(d).toEqual({ fire: true, reason: 'recovered' })
62
+ })
63
+
64
+ it('an account that is exhausted-but-not-premium-walled does NOT count as recovered', () => {
65
+ // The premium tier is only servable on an account that is BOTH not
66
+ // exhausted AND not premium-walled.
67
+ const d = decidePremiumRecovery({
68
+ hasMarker: true,
69
+ accounts: [{ exhausted: true, premiumWalled: false }],
70
+ })
71
+ expect(d.fire).toBe(false)
72
+ })
73
+
74
+ it('an account that is premium-walled-but-not-exhausted does NOT count as recovered', () => {
75
+ const d = decidePremiumRecovery({
76
+ hasMarker: true,
77
+ accounts: [{ exhausted: false, premiumWalled: true }],
78
+ })
79
+ expect(d.fire).toBe(false)
80
+ })
81
+
82
+ it('empty account list with a pending marker → no fire (nothing servable)', () => {
83
+ expect(decidePremiumRecovery({ hasMarker: true, accounts: [] }).fire).toBe(false)
84
+ })
85
+ })
86
+
87
+ describe('renderPremiumRecoveryPing — honest wording', () => {
88
+ it('names the model, says available again, and offers a session-scoped switch', () => {
89
+ const p = renderPremiumRecoveryPing('fable')
90
+ expect(p.text).toContain('`fable`')
91
+ expect(p.text.toLowerCase()).toContain('available again')
92
+ expect(p.text.toLowerCase()).toContain('this session')
93
+ expect(p.buttonText).toBe('Switch to fable')
94
+ // Must NOT over-promise a durable pin.
95
+ expect(p.text.toLowerCase()).not.toContain('permanently')
96
+ expect(p.text.toLowerCase()).not.toContain('forever')
97
+ })
98
+ })
99
+
100
+ describe('premiumRecoveryClaimKey', () => {
101
+ it('keys the dedup on agent + token so different drops never collide', () => {
102
+ expect(premiumRecoveryClaimKey('klanker', 'fable')).toBe('premium-recovery:klanker:fable')
103
+ expect(premiumRecoveryClaimKey('klanker', 'fable')).not.toBe(
104
+ premiumRecoveryClaimKey('klanker', 'opus'),
105
+ )
106
+ })
107
+ })
108
+
109
+ describe('.premium-recovery carrier — round-trip + shape gate', () => {
110
+ let dir: string
111
+ beforeEach(() => {
112
+ dir = mkdtempSync(join(tmpdir(), 'premium-recovery-'))
113
+ })
114
+ afterEach(() => {
115
+ rmSync(dir, { recursive: true, force: true })
116
+ })
117
+
118
+ it('write → read round-trips model + chats (a VALID marker is NOT swept)', () => {
119
+ writePremiumRecoveryFile(dir, 'fable', ['12345', '67890'])
120
+ const rec = readPremiumRecoveryFile(dir)
121
+ expect(rec?.premiumModel).toBe('fable')
122
+ expect(rec?.chats).toEqual(['12345', '67890'])
123
+ expect(typeof rec?.ts).toBe('number')
124
+ // A well-formed marker must survive a read — only corrupt files are swept.
125
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
126
+ })
127
+
128
+ it('clear removes the marker', () => {
129
+ writePremiumRecoveryFile(dir, 'fable', ['12345'])
130
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
131
+ clearPremiumRecoveryFile(dir)
132
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(false)
133
+ expect(readPremiumRecoveryFile(dir)).toBeNull()
134
+ })
135
+
136
+ it('refuses a non-canonical model token (shell-injection shape)', () => {
137
+ expect(() => writePremiumRecoveryFile(dir, 'bad name; rm -rf /', ['12345'])).toThrow()
138
+ })
139
+
140
+ it('refuses an empty chat list (a marker with nowhere to ping is a bug)', () => {
141
+ expect(() => writePremiumRecoveryFile(dir, 'fable', [])).toThrow()
142
+ })
143
+
144
+ it('parse rejects corrupt JSON / bad shape / bad token / empty chats', () => {
145
+ expect(parsePremiumRecovery('{broken')).toBeNull()
146
+ expect(parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: [], ts: 1 }))).toBeNull()
147
+ expect(
148
+ parsePremiumRecovery(JSON.stringify({ premiumModel: 'bad;name', chats: ['1'], ts: 1 })),
149
+ ).toBeNull()
150
+ expect(
151
+ parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: [1, 2], ts: 1 })),
152
+ ).toBeNull()
153
+ expect(parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: ['1'] }))).toBeNull()
154
+ })
155
+
156
+ it('read returns null on a corrupt on-disk marker AND sweeps the garbled file', () => {
157
+ writeFileSync(join(dir, PREMIUM_RECOVERY_FILE), '{not json\n')
158
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
159
+ expect(readPremiumRecoveryFile(dir)).toBeNull()
160
+ // LOW-1: a corrupt marker is garbage-collected on read so it can't linger
161
+ // forever (neither the ping path nor the manual-switch clear could ever
162
+ // delete it — both read null first).
163
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(false)
164
+ })
165
+ })
@@ -1170,4 +1170,25 @@ describe("buildFleetRollMessage — reason attribution (#3031 PR 2 reason field)
1170
1170
  expect(msg).toContain("7-day window at 96% on");
1171
1171
  }
1172
1172
  });
1173
+
1174
+ it("model-tier-wall (#3176) names the flagship tier and reassures opus/haiku are unaffected — not a generic quota window", () => {
1175
+ // A tier wall binds on the 7d_oi bucket; window/pct are absent (5h/7d read
1176
+ // healthy). exhausted_until carries the tier reset.
1177
+ const roll: FleetRollInfo = {
1178
+ from: "alice@example.com",
1179
+ to: "bob@example.com",
1180
+ at: NOW - 60_000,
1181
+ reason: "model-tier-wall",
1182
+ bucket: "seven_day_overage_included",
1183
+ exhausted_until: NOW + 2.8 * 60 * 60 * 1000,
1184
+ };
1185
+ const msg = buildFleetRollMessage(roll, NOW);
1186
+ expect(msg).toContain("Flagship (premium) tier weekly limit reached");
1187
+ expect(msg).toContain("opus/haiku on that account are unaffected");
1188
+ // Must NOT mislead as a generic 5h/7d window roll.
1189
+ expect(msg).not.toContain("quota window");
1190
+ expect(msg).not.toContain("Proactive switch");
1191
+ // The tier reset is surfaced.
1192
+ expect(msg).toContain("resets");
1193
+ });
1173
1194
  });
@@ -29,7 +29,7 @@
29
29
  import { describe, it, expect, vi } from 'vitest'
30
30
  import { readFileSync } from 'node:fs'
31
31
  import { fileURLToPath } from 'node:url'
32
- import { createSendGate, type Clock } from '../send-gate.js'
32
+ import { createSendGate, SEND_GATE_SHED, type Clock } from '../send-gate.js'
33
33
  import { createRetryApiCall } from '../retry-api-call.js'
34
34
  import { errors } from './fake-bot-api.js'
35
35
 
@@ -95,7 +95,7 @@ describe('#3155 reactions route through the send gate (cosmetic)', () => {
95
95
  // resolves undefined (fire-and-forget callers .catch nothing), and the gate
96
96
  // counts the shed.
97
97
  expect(setMessageReaction).not.toHaveBeenCalled()
98
- expect(result).toBeUndefined()
98
+ expect(result).toBe(SEND_GATE_SHED) // shed sentinel (#3110 F1)
99
99
  expect(sendGate.stats().global.shed).toBe(1)
100
100
  })
101
101
 
@@ -130,7 +130,12 @@ describe('#3155 reactions route through the send gate (cosmetic)', () => {
130
130
  // The reaction actually reached the API (was admitted, not shed)...
131
131
  expect(setMessageReaction).toHaveBeenCalled()
132
132
  // ...and its 429 was recorded by the breaker — the whole gap this closes.
133
- expect(onFloodWait).toHaveBeenCalledWith(7)
133
+ // #3111: the retry policy now also passes the call's opts so the gateway can
134
+ // open a scope-precise window (a reaction 429 implies only that chat).
135
+ expect(onFloodWait).toHaveBeenCalledWith(
136
+ 7,
137
+ expect.objectContaining({ chat_id: 'chatA', priorityClass: 'cosmetic' }),
138
+ )
134
139
  })
135
140
  })
136
141
 
@@ -576,6 +576,27 @@ describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)'
576
576
  expect(out).toBe('ok')
577
577
  expect(seen).toEqual([42])
578
578
  })
579
+
580
+ it('passes the call opts to onFloodWait so the gateway can open a scope-precise window (#3111)', async () => {
581
+ const seen: { sec: number; chat_id?: string; chatType?: string }[] = []
582
+ const sleep = vi.fn(async () => {})
583
+ let n = 0
584
+ const retry = createRetryApiCall({
585
+ maxRetries: 3,
586
+ sleep,
587
+ onFloodWait: (sec, opts) => seen.push({ sec, chat_id: opts?.chat_id, chatType: opts?.chatType }),
588
+ })
589
+ const out = await retry(
590
+ async () => {
591
+ if (n++ === 0) throw errors.floodWait(7)
592
+ return 'ok'
593
+ },
594
+ { chat_id: '42', chatType: 'supergroup' },
595
+ )
596
+ expect(out).toBe('ok')
597
+ // The hook received the same chat scope the send carried — not a bare number.
598
+ expect(seen).toEqual([{ sec: 7, chat_id: '42', chatType: 'supergroup' }])
599
+ })
579
600
  })
580
601
 
581
602
  describe('#3084 — a long flood ban fails FAST instead of sleeping for hours', () => {
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Durable session-model file helpers (session-model-file.ts) — the gateway
3
- * side of the stickiness contract (reference/rfcs/session-model-stickiness.md).
2
+ * Session-model file helpers (session-model-file.ts) — the gateway side of the
3
+ * session-scoped /model contract (reference/rfcs/session-model-stickiness.md
4
+ * §0.1, rev 4 — consume-once). The `.relaunch-model-intent` subsystem and the
5
+ * crashloop counter were retired with rev 4; their tests are gone.
4
6
  */
5
7
 
6
8
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
- import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
9
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
8
10
  import { join } from 'node:path'
9
11
  import { tmpdir } from 'node:os'
10
12
  import {
@@ -15,17 +17,8 @@ import {
15
17
  readSessionModelFileRaw,
16
18
  restoreSessionModelFileRaw,
17
19
  clearSessionModelFile,
18
- clearSessionModelBootAttempts,
19
- SESSION_MODEL_BOOT_ATTEMPTS_FILE,
20
- writeRelaunchModelIntent,
21
- clearRelaunchModelIntent,
22
20
  readConfiguredDefaultModel,
23
- intentForRestartReason,
24
- readRelaunchModelIntent,
25
- clearStaleGatewayShutdownIntent,
26
- GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX,
27
21
  SESSION_MODEL_FILE,
28
- RELAUNCH_MODEL_INTENT_FILE,
29
22
  CONFIGURED_DEFAULT_MODEL_FILE,
30
23
  parseSessionEffort,
31
24
  writeSessionEffortFile,
@@ -87,86 +80,6 @@ describe('rollback snapshot (scheduleModelRelaunch dispatch failure)', () => {
87
80
  })
88
81
  })
89
82
 
90
- describe('relaunch intent', () => {
91
- it('writes one-line JSON with intent, reason, and embedded ts (the freshness clock)', () => {
92
- writeRelaunchModelIntent(dir, 'keep', 'user: /new from chat')
93
- const raw = readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')
94
- const parsed = JSON.parse(raw)
95
- expect(parsed.intent).toBe('keep')
96
- expect(parsed.reason).toBe('user: /new from chat')
97
- expect(Math.abs(Date.now() - parsed.ts)).toBeLessThan(5000)
98
- })
99
-
100
- it('last-writer-wins and clearable', () => {
101
- writeRelaunchModelIntent(dir, 'keep', 'a')
102
- writeRelaunchModelIntent(dir, 'revert', 'b')
103
- expect(JSON.parse(readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')).intent).toBe('revert')
104
- clearRelaunchModelIntent(dir)
105
- expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
106
- })
107
- })
108
-
109
- describe('intentForRestartReason — the triggerSelfRestart per-reason table (RFC §3)', () => {
110
- it.each([
111
- 'schedule-restart-immediate',
112
- 'restart-drain-cap-forced',
113
- 'turn-complete-pending-restart',
114
- 'fleet-fallback-resume',
115
- 'sr-to-claude-model-switch',
116
- ])('switchroom-managed relaunch %s → keep', (reason) => {
117
- expect(intentForRestartReason(reason)).toBe('keep')
118
- })
119
-
120
- it('inline-button-restart → keep (#3039: a restart is not "clear my model")', () => {
121
- expect(intentForRestartReason('inline-button-restart')).toBe('keep')
122
- })
123
-
124
- it('unknown gateway reasons default to keep (only gateway code calls triggerSelfRestart; crashes never do)', () => {
125
- expect(intentForRestartReason('some-future-recovery-path')).toBe('keep')
126
- })
127
- })
128
-
129
- describe('readRelaunchModelIntent', () => {
130
- it('round-trips a stamped intent; null when absent / corrupt / malformed', () => {
131
- expect(readRelaunchModelIntent(dir)).toBeNull()
132
- writeRelaunchModelIntent(dir, 'keep', 'watchdog recovery')
133
- const rec = readRelaunchModelIntent(dir)!
134
- expect(rec.intent).toBe('keep')
135
- expect(rec.reason).toBe('watchdog recovery')
136
- expect(Math.abs(Date.now() - rec.ts)).toBeLessThan(5000)
137
- writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{broken')
138
- expect(readRelaunchModelIntent(dir)).toBeNull()
139
- writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{"intent":"maybe","reason":"x","ts":1}\n')
140
- expect(readRelaunchModelIntent(dir)).toBeNull()
141
- })
142
- })
143
-
144
- describe('clearStaleGatewayShutdownIntent (#3018 finding 4 — a gateway-only bounce must not leave a keep stamp)', () => {
145
- it('clears ONLY a gateway-shutdown-stamped intent and reports it', () => {
146
- writeRelaunchModelIntent(
147
- dir,
148
- 'keep',
149
- `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful SIGTERM shutdown (deploy/rolling restart) — preserving user-chosen session model`,
150
- )
151
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(true)
152
- expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
153
- // Idempotent: a second call finds nothing.
154
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
155
- })
156
-
157
- it('never touches a triggerSelfRestart / user-slash stamp (un-prefixed reason)', () => {
158
- writeRelaunchModelIntent(dir, 'keep', 'sr-to-claude-model-switch')
159
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
160
- expect(readRelaunchModelIntent(dir)!.reason).toBe('sr-to-claude-model-switch')
161
- })
162
-
163
- it('is a safe no-op on an absent or corrupt intent file', () => {
164
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
165
- writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'not json')
166
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
167
- })
168
- })
169
-
170
83
  describe('readConfiguredDefaultModel', () => {
171
84
  it('reads the trimmed value; null when absent or empty', () => {
172
85
  expect(readConfiguredDefaultModel(dir)).toBeNull()
@@ -181,9 +94,9 @@ describe('readConfiguredDefaultModel', () => {
181
94
  })
182
95
  })
183
96
 
184
- // ─── #3039: durable session-effort carrier ───────────────────────────────────
97
+ // ─── #3186: consume-once session-effort carrier (queued-command boot apply) ──
185
98
 
186
- describe('session-effort file helpers (#3039)', () => {
99
+ describe('session-effort file helpers (#3186)', () => {
187
100
  let dir: string
188
101
  beforeEach(() => {
189
102
  dir = mkdtempSync(join(tmpdir(), 'sr-session-effort-'))
@@ -218,64 +131,3 @@ describe('session-effort file helpers (#3039)', () => {
218
131
  expect(readSessionEffortFile(dir)).toBeNull()
219
132
  })
220
133
  })
221
-
222
- describe('intentForRestartReason is keep-for-everything (#3039)', () => {
223
- it('every reason keeps — restarts never clear a user model choice', () => {
224
- for (const reason of [
225
- 'inline-button-restart',
226
- 'user: /restart from chat',
227
- 'schedule-restart-immediate',
228
- 'anything-else',
229
- ]) {
230
- expect(intentForRestartReason(reason)).toBe('keep')
231
- }
232
- })
233
- })
234
-
235
- describe('clearSessionModelBootAttempts (#3043 item 2: bridge-register clears the crashloop counter)', () => {
236
- const bootAttemptsPath = () => join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE)
237
-
238
- // Faithful model of start.sh.hbs "Override crashloop self-heal": each boot
239
- // within 150s of the previous stamp bumps the count; a stale stamp resets to
240
- // 1. Reproduced here so the accumulate-vs-reset OUTCOME is asserted, not just
241
- // the delete call.
242
- function simulateBootStamp(nowSec: number): number {
243
- let count = 0
244
- let prev = 0
245
- if (existsSync(bootAttemptsPath())) {
246
- const [c, p] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
247
- count = Number(c) || 0
248
- prev = Number(p) || 0
249
- }
250
- count = nowSec - prev < 150 ? count + 1 : 1
251
- writeFileSync(bootAttemptsPath(), `${count} ${nowSec}\n`)
252
- return count
253
- }
254
-
255
- it('WITHOUT a bridge register, three fast boots accumulate toward the 3-strike clear', () => {
256
- // Three operator hand-bounces, each <150s apart, with no register between.
257
- expect(simulateBootStamp(1000)).toBe(1)
258
- expect(simulateBootStamp(1010)).toBe(2)
259
- expect(simulateBootStamp(1020)).toBe(3) // start.sh would now clear a HEALTHY override — the false positive
260
- })
261
-
262
- it('a bridge register between boots resets the counter, so a healthy agent never reaches 3', () => {
263
- expect(simulateBootStamp(1000)).toBe(1)
264
- // Boot 1 came all the way up and the bridge registered → gateway clears it.
265
- clearSessionModelBootAttempts(dir)
266
- expect(existsSync(bootAttemptsPath())).toBe(false)
267
-
268
- // Next fast boot starts fresh at 1 (no accumulation), and register clears again.
269
- expect(simulateBootStamp(1010)).toBe(1)
270
- clearSessionModelBootAttempts(dir)
271
- expect(simulateBootStamp(1020)).toBe(1)
272
- // The healthy agent's counter never climbs to the 3-strike clear.
273
- const [count] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
274
- expect(Number(count)).toBeLessThan(3)
275
- })
276
-
277
- it('is best-effort — no throw when the counter file is absent', () => {
278
- expect(existsSync(bootAttemptsPath())).toBe(false)
279
- expect(() => clearSessionModelBootAttempts(dir)).not.toThrow()
280
- })
281
- })