switchroom 0.19.26 → 0.19.28

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 (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Flood-pressure signal, proved against the real 2026-07 incident stream.
3
+ *
4
+ * The governing question for every assertion here: would this have told the
5
+ * operator BEFORE 2026-07-27T20:19:57Z, and would it have stayed quiet on the
6
+ * benign days? A test that passes on synthetic data but cannot answer that is
7
+ * not a test of this feature.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
10
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+
14
+ import {
15
+ classifyFlood429Pressure,
16
+ foldFlood429Observation,
17
+ isPenaltyEpisode,
18
+ readFlood429Ledger,
19
+ readFlood429LedgerResult,
20
+ recordFlood429,
21
+ flood429LedgerPath,
22
+ flood429LedgerPathFromFloodState,
23
+ FLOOD_429_LEDGER_FILE,
24
+ FLOOD_429_LEDGER_MAX_EPISODES,
25
+ TRIVIAL_PRESSURE_WARN_COUNT,
26
+ type Flood429Episode,
27
+ } from '../flood-429-ledger.js'
28
+ import { REAL_429_STREAM, at } from './fixtures/real-429-stream.js'
29
+
30
+ /** Replay the real stream (or a prefix of it) through the write-path fold. */
31
+ function replay(untilTs = Number.POSITIVE_INFINITY): Flood429Episode[] {
32
+ let episodes: Flood429Episode[] = []
33
+ for (const obs of REAL_429_STREAM) {
34
+ if (obs.ts > untilTs) break
35
+ episodes = foldFlood429Observation(episodes, obs, obs.ts)
36
+ }
37
+ return episodes
38
+ }
39
+
40
+ const INCIDENT_TS = at('2026-07-27T20:19:56.704Z')
41
+
42
+ describe('episode folding — the real ban shape', () => {
43
+ it('collapses the 87 re-observations of the 2026-07-27 ban into ONE episode', () => {
44
+ const episodes = replay()
45
+ const incident = episodes.filter(
46
+ (e) => e.firstTs >= at('2026-07-27T20:00:00Z') && isPenaltyEpisode(e),
47
+ )
48
+ expect(incident).toHaveLength(1)
49
+ expect(incident[0].peakRetryAfterSec).toBe(15908)
50
+ expect(incident[0].count).toBe(88)
51
+ expect(incident[0].firstTs).toBe(INCIDENT_TS)
52
+ // Every re-observation implies the SAME expiry as the first — that is
53
+ // what makes them one episode. The 88 observations span 41 minutes yet
54
+ // their implied expiries agree to within a second.
55
+ expect(incident[0].untilTs - (INCIDENT_TS + 15908 * 1000)).toBeLessThan(1000)
56
+ expect(new Date(incident[0].untilTs).toISOString()).toMatch(/^2026-07-28T00:45:0/)
57
+ })
58
+
59
+ it('keeps the four real bans distinct rather than merging them', () => {
60
+ const peaks = replay()
61
+ .filter(isPenaltyEpisode)
62
+ .map((e) => e.peakRetryAfterSec)
63
+ expect(peaks).toEqual([21397, 436, 563, 283, 425, 27951, 3713, 15908])
64
+ })
65
+
66
+ it('does not classify the benign rate nudges as penalties', () => {
67
+ const trivial = replay().filter((e) => !isPenaltyEpisode(e))
68
+ expect(trivial.length).toBeGreaterThan(0)
69
+ for (const e of trivial) expect(e.peakRetryAfterSec).toBeLessThanOrEqual(5)
70
+ })
71
+ })
72
+
73
+ describe('the signal fires before the incident', () => {
74
+ it('FAILS 20 hours before the 4.4h ban, on the strength of the 2026-07-25 one', () => {
75
+ const t = at('2026-07-27T00:00:00Z')
76
+ expect(t).toBeLessThan(INCIDENT_TS)
77
+ const verdict = classifyFlood429Pressure(replay(t), t)
78
+ expect(verdict?.status).toBe('fail')
79
+ expect(verdict?.reason).toBe('severe_penalty')
80
+ expect(verdict?.detail).toContain('3713')
81
+ })
82
+
83
+ it('is already failing at the end of 2026-07-26, the day before', () => {
84
+ const t = at('2026-07-26T23:59:59Z')
85
+ const verdict = classifyFlood429Pressure(replay(t), t)
86
+ expect(verdict?.status).toBe('fail')
87
+ })
88
+
89
+ it('FAILS 75 minutes before the 7.8h ban on 2026-07-13, and says it is growing', () => {
90
+ // 00:37 (563s) and 01:42 (283s) are already on the ledger; 21397s from the
91
+ // previous day is still inside the 7-day window.
92
+ const t = at('2026-07-13T02:00:00Z')
93
+ expect(t).toBeLessThan(at('2026-07-13T03:17:47Z'))
94
+ const verdict = classifyFlood429Pressure(replay(t), t)
95
+ expect(verdict?.status).toBe('fail')
96
+ expect(verdict?.reason).toBe('repeat_penalty')
97
+ expect(verdict?.penalties.map((p) => p.peakRetryAfterSec)).toEqual([21397, 436, 563, 283])
98
+ })
99
+
100
+ it('names the 4.3x escalation once both bans are on the ledger', () => {
101
+ const t = at('2026-07-28T01:00:00Z') // after the ban window closed
102
+ const verdict = classifyFlood429Pressure(replay(t), t)
103
+ expect(verdict?.reason).toBe('repeat_penalty')
104
+ expect(verdict?.detail).toContain('GROWING')
105
+ expect(verdict?.detail).toContain('3713s → 15908s')
106
+ })
107
+
108
+ it('reports the open ban while it is running', () => {
109
+ const t = at('2026-07-27T21:30:00Z')
110
+ const verdict = classifyFlood429Pressure(replay(t), t)
111
+ expect(verdict?.status).toBe('fail')
112
+ expect(verdict?.reason).toBe('ban_open')
113
+ expect(verdict?.detail).toContain('remaining')
114
+ })
115
+ })
116
+
117
+ describe('the signal stays quiet on the benign days', () => {
118
+ it('says nothing on 2026-07-24 — 7 short 429s, no penalty in the window', () => {
119
+ const t = at('2026-07-24T23:59:59Z')
120
+ const episodes = replay(t)
121
+ expect(episodes.length).toBeGreaterThan(0) // the ledger is not empty
122
+ expect(classifyFlood429Pressure(episodes, t)).toBeNull()
123
+ })
124
+
125
+ it('says nothing about the busiest benign day (14 nudges on 2026-07-19)', () => {
126
+ // Isolated to the day's own traffic: the point is that 14 short 429s in
127
+ // 24h are NOT worth waking anyone for. (Replayed in full context the
128
+ // classifier still fails that day — correctly, because four real bans
129
+ // landed six days earlier.)
130
+ const day = REAL_429_STREAM.filter((o) => o.iso.startsWith('2026-07-19'))
131
+ expect(day).toHaveLength(14)
132
+ expect(day.every((o) => o.retryAfterSec <= 5)).toBe(true)
133
+ let episodes: Flood429Episode[] = []
134
+ for (const o of day) episodes = foldFlood429Observation(episodes, o, o.ts)
135
+ const t = at('2026-07-19T23:59:59Z')
136
+ expect(classifyFlood429Pressure(episodes, t)).toBeNull()
137
+ })
138
+
139
+ it('says nothing at all for a fleet-typical quiet week', () => {
140
+ const t = at('2026-07-22T12:00:00Z')
141
+ const window = REAL_429_STREAM.filter(
142
+ (o) => o.ts > at('2026-07-15T12:00:00Z') && o.ts <= t,
143
+ )
144
+ expect(window.every((o) => o.retryAfterSec <= 5)).toBe(true)
145
+ let episodes: Flood429Episode[] = []
146
+ for (const o of window) episodes = foldFlood429Observation(episodes, o, o.ts)
147
+ expect(classifyFlood429Pressure(episodes, t)).toBeNull()
148
+ })
149
+ })
150
+
151
+ describe('why raw daily count was rejected', () => {
152
+ /** Real 429s per calendar day, straight off the fixture. */
153
+ function perDay(day: string): number {
154
+ return REAL_429_STREAM.filter((o) => o.iso.startsWith(day)).length
155
+ }
156
+
157
+ it('a ">20 429s/day" rule is silent on every day that could have warned', () => {
158
+ // The proposal that started this work. On the real stream it fires on
159
+ // exactly one day: the day of the outage, 4.4 hours too late for the
160
+ // 20:19:57Z ban and only because the ban re-observed itself 87 times.
161
+ for (const day of [
162
+ '2026-07-19', // busiest benign day
163
+ '2026-07-24',
164
+ '2026-07-25', // the 62-minute ban lands here
165
+ '2026-07-26', // last chance to warn
166
+ ]) {
167
+ expect(perDay(day)).toBeLessThan(20)
168
+ }
169
+ expect(perDay('2026-07-27')).toBeGreaterThan(20)
170
+ })
171
+
172
+ it('the severity signal fires on 2026-07-25, the day the count rule misses', () => {
173
+ const t = at('2026-07-25T23:45:00Z') // 2 minutes after the 3713s ban
174
+ expect(perDay('2026-07-25')).toBe(4)
175
+ const verdict = classifyFlood429Pressure(replay(t), t)
176
+ expect(verdict?.status).toBe('fail')
177
+ // ~44 hours of warning before the 4.4h outage.
178
+ expect(INCIDENT_TS - t).toBeGreaterThan(43 * 60 * 60 * 1000)
179
+ })
180
+ })
181
+
182
+ describe('trivial-pressure tier', () => {
183
+ const base = at('2026-07-01T00:00:00Z')
184
+
185
+ function nudges(n: number): Flood429Episode[] {
186
+ let episodes: Flood429Episode[] = []
187
+ for (let i = 0; i < n; i++) {
188
+ // 10 minutes apart — far outside EPISODE_MERGE_MS, so each is its own
189
+ // episode and the count is unambiguous.
190
+ const ts = base + i * 600_000
191
+ episodes = foldFlood429Observation(episodes, { ts, retryAfterSec: 3 }, ts)
192
+ }
193
+ return episodes
194
+ }
195
+
196
+ it('warns at the threshold and is silent one below it', () => {
197
+ const t = base + 23 * 60 * 60 * 1000
198
+ expect(classifyFlood429Pressure(nudges(TRIVIAL_PRESSURE_WARN_COUNT - 1), t)).toBeNull()
199
+ const verdict = classifyFlood429Pressure(nudges(TRIVIAL_PRESSURE_WARN_COUNT), t)
200
+ expect(verdict?.status).toBe('warn')
201
+ expect(verdict?.reason).toBe('trivial_pressure')
202
+ })
203
+
204
+ it('ages out — the same nudges two days later say nothing', () => {
205
+ const t = base + 48 * 60 * 60 * 1000
206
+ expect(classifyFlood429Pressure(nudges(TRIVIAL_PRESSURE_WARN_COUNT), t)).toBeNull()
207
+ })
208
+ })
209
+
210
+ describe('ledger persistence', () => {
211
+ let dir: string
212
+ beforeEach(() => {
213
+ dir = mkdtempSync(join(tmpdir(), 'flood-429-'))
214
+ })
215
+ afterEach(() => {
216
+ rmSync(dir, { recursive: true, force: true })
217
+ })
218
+
219
+ it('round-trips through the on-disk file', () => {
220
+ const path = flood429LedgerPath(dir)
221
+ recordFlood429(path, { ts: at('2026-07-25T23:43:07.861Z'), retryAfterSec: 3713 })
222
+ recordFlood429(path, { ts: at('2026-07-27T20:19:56.704Z'), retryAfterSec: 15908 })
223
+ const episodes = readFlood429Ledger(path)
224
+ expect(episodes.map((e) => e.peakRetryAfterSec)).toEqual([3713, 15908])
225
+ const verdict = classifyFlood429Pressure(episodes, at('2026-07-28T01:00:00Z'))
226
+ expect(verdict?.reason).toBe('repeat_penalty')
227
+ })
228
+
229
+ it('does not grow under a long ban re-observed 88 times', () => {
230
+ const path = flood429LedgerPath(dir)
231
+ for (const obs of REAL_429_STREAM.filter((o) => o.ts >= INCIDENT_TS)) {
232
+ recordFlood429(path, obs)
233
+ }
234
+ expect(readFlood429Ledger(path)).toHaveLength(1)
235
+ // A whole 4.4h outage costs well under a kilobyte of state.
236
+ expect(readFileSync(path, 'utf-8').length).toBeLessThan(200)
237
+ })
238
+
239
+ it('stays bounded when every 429 is a separate episode', () => {
240
+ const path = flood429LedgerPath(dir)
241
+ const start = at('2026-07-01T00:00:00Z')
242
+ for (let i = 0; i < FLOOD_429_LEDGER_MAX_EPISODES + 50; i++) {
243
+ recordFlood429(path, { ts: start + i * 600_000, retryAfterSec: 3 })
244
+ }
245
+ expect(readFlood429Ledger(path)).toHaveLength(FLOOD_429_LEDGER_MAX_EPISODES)
246
+ })
247
+
248
+ it('degrades to empty on a corrupt or absent file instead of throwing', () => {
249
+ const path = flood429LedgerPath(dir)
250
+ expect(readFlood429Ledger(path)).toEqual([])
251
+ writeFileSync(path, 'not json at all')
252
+ expect(readFlood429Ledger(path)).toEqual([])
253
+ writeFileSync(path, '[{"nope":1},{"firstTs":1,"untilTs":2,"peakRetryAfterSec":3}]')
254
+ expect(readFlood429Ledger(path)).toHaveLength(1)
255
+ })
256
+
257
+ it('separates "never 429\'d" from "cannot read the ledger"', () => {
258
+ const path = flood429LedgerPath(dir)
259
+ expect(readFlood429LedgerResult(path).status).toBe('absent')
260
+ writeFileSync(path, 'not json at all')
261
+ expect(readFlood429LedgerResult(path).status).toBe('corrupt')
262
+ recordFlood429(path, { ts: at('2026-07-25T23:43:07.861Z'), retryAfterSec: 3713 })
263
+ expect(readFlood429LedgerResult(path).status).toBe('ok')
264
+ const boom = readFlood429LedgerResult(path, () => {
265
+ const err = new Error('EACCES: permission denied')
266
+ ;(err as NodeJS.ErrnoException).code = 'EACCES'
267
+ throw err
268
+ })
269
+ expect(boom.status).toBe('unreadable')
270
+ expect(boom.error).toContain('EACCES')
271
+ })
272
+
273
+ it('sits beside the flood-wait marker the breaker already writes', () => {
274
+ expect(flood429LedgerPathFromFloodState(join(dir, 'flood-wait.json'))).toBe(
275
+ join(dir, FLOOD_429_LEDGER_FILE),
276
+ )
277
+ })
278
+ })
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The wiring proof: a 429 that reaches `retryApiCall` reaches the pressure
3
+ * ledger.
4
+ *
5
+ * The classifier can be perfect and still useless if nothing feeds it. This
6
+ * suite drives the REAL `createRetryApiCall` policy with a real `GrammyError`
7
+ * carrying `parameters.retry_after`, wired exactly the way the gateway wires
8
+ * it, and asserts the ledger on disk afterwards. It fails if the recorder's
9
+ * ledger append is removed, if a future callsite stops going through
10
+ * `makeFloodWaitRecorder`, or if `onFloodWait` stops firing for the long-ban
11
+ * branch (the branch the 2026-07-27 incident took).
12
+ */
13
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
14
+ import { mkdtempSync, rmSync, statSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { GrammyError } from 'grammy'
18
+
19
+ import { createRetryApiCall } from '../retry-api-call.js'
20
+ import { makeFloodWaitRecorder, floodStatePath, readFloodState } from '../flood-circuit-breaker.js'
21
+ import {
22
+ classifyFlood429Pressure,
23
+ flood429LedgerPath,
24
+ readFlood429Ledger,
25
+ } from '../flood-429-ledger.js'
26
+
27
+ /** A 429 shaped like Telegram's, with the retry_after the gateway reads. */
28
+ function floodError(retryAfterSec: number): GrammyError {
29
+ return new GrammyError(
30
+ 'Call to sendMessage failed!',
31
+ {
32
+ ok: false,
33
+ error_code: 429,
34
+ description: 'Too Many Requests: retry later',
35
+ parameters: { retry_after: retryAfterSec },
36
+ },
37
+ 'sendMessage',
38
+ {},
39
+ )
40
+ }
41
+
42
+ describe('429 → pressure ledger, through the real retry policy', () => {
43
+ let dir: string
44
+ let ledgerPath: string
45
+ let statePath: string
46
+
47
+ beforeEach(() => {
48
+ dir = mkdtempSync(join(tmpdir(), 'flood-429-wiring-'))
49
+ ledgerPath = flood429LedgerPath(dir)
50
+ statePath = floodStatePath(dir)
51
+ })
52
+ afterEach(() => {
53
+ rmSync(dir, { recursive: true, force: true })
54
+ })
55
+
56
+ /** The gateway's own wiring shape (gateway.ts's `rawRobustApiCall`). */
57
+ function apiCall(now: () => number) {
58
+ return createRetryApiCall({
59
+ log: () => {},
60
+ sleep: async () => {},
61
+ onFloodWait: (retryAfterSec) => makeFloodWaitRecorder(statePath, now)(retryAfterSec),
62
+ })
63
+ }
64
+
65
+ it('records a long ban — the 2026-07-27 branch, which never sleeps', async () => {
66
+ const ts = Date.parse('2026-07-27T20:19:56.704Z')
67
+ await expect(
68
+ apiCall(() => ts)(async () => {
69
+ throw floodError(15908)
70
+ }),
71
+ ).rejects.toThrow()
72
+
73
+ const episodes = readFlood429Ledger(ledgerPath)
74
+ expect(episodes).toHaveLength(1)
75
+ expect(episodes[0].peakRetryAfterSec).toBe(15908)
76
+ expect(episodes[0].firstTs).toBe(ts)
77
+
78
+ // And the breaker's own marker is still written — the ledger is additive.
79
+ expect(readFloodState(statePath)?.retryAfterSec).toBe(15908)
80
+
81
+ // World-readable: `switchroom doctor` runs as the operator, not the agent.
82
+ expect(statSync(ledgerPath).mode & 0o777).toBe(0o644)
83
+ })
84
+
85
+ it('records a short slept-and-retried 429 too', async () => {
86
+ const ts = Date.parse('2026-07-24T08:22:21.126Z')
87
+ let attempts = 0
88
+ const result = await apiCall(() => ts)(async () => {
89
+ attempts += 1
90
+ if (attempts === 1) throw floodError(3)
91
+ return 'sent'
92
+ })
93
+ expect(result).toBe('sent')
94
+ expect(readFlood429Ledger(ledgerPath)[0].peakRetryAfterSec).toBe(3)
95
+ })
96
+
97
+ it('accumulates across calls into a verdict the doctor would report', async () => {
98
+ const first = Date.parse('2026-07-25T23:43:07.861Z')
99
+ const second = Date.parse('2026-07-27T20:19:56.704Z')
100
+ await expect(
101
+ apiCall(() => first)(async () => {
102
+ throw floodError(3713)
103
+ }),
104
+ ).rejects.toThrow()
105
+ await expect(
106
+ apiCall(() => second)(async () => {
107
+ throw floodError(15908)
108
+ }),
109
+ ).rejects.toThrow()
110
+
111
+ const verdict = classifyFlood429Pressure(
112
+ readFlood429Ledger(ledgerPath),
113
+ Date.parse('2026-07-28T01:00:00Z'),
114
+ )
115
+ expect(verdict?.status).toBe('fail')
116
+ expect(verdict?.reason).toBe('repeat_penalty')
117
+ expect(verdict?.detail).toContain('GROWING')
118
+ })
119
+
120
+ it('a non-429 failure leaves no ledger behind', async () => {
121
+ await expect(
122
+ apiCall(() => Date.now())(async () => {
123
+ throw new Error('boom')
124
+ }),
125
+ ).rejects.toThrow('boom')
126
+ expect(readFlood429Ledger(ledgerPath)).toEqual([])
127
+ })
128
+ })