switchroom 0.19.26 → 0.19.27

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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Regression suite for the 2026-07-27 activity-feed flood ban.
3
+ *
4
+ * What happened: agent `overlord`'s live activity/worker feed edited its card
5
+ * at a sustained 4-17 `editMessageText`/min on ONE DM chat for hours (326
6
+ * edits against 6 real replies in the final 20 minutes). Telegram answered
7
+ * with a **15908-second** per-chat flood ban, which severed every outbound
8
+ * reply — a cosmetic progress indicator took out the agent's entire ability
9
+ * to communicate.
10
+ *
11
+ * The edit-flood fuse was already installed. It did not bind ONCE, because
12
+ * its ceilings (20 edits/60s per message, 30 edits/60s per chat) sat ABOVE
13
+ * the rate that earned the ban, and because it was class-blind: it could not
14
+ * tell a repaint from a reply, so it could not hold repaints to a low rate
15
+ * without also throttling answers.
16
+ *
17
+ * Every test here asserts an OUTCOME at the transformer seam — how many calls
18
+ * actually reached the API — under a scenario drawn from the incident. Each
19
+ * one fails if the cosmetic ceiling, the reply reservation, the class
20
+ * propagation, or the escalating 429 backoff is removed.
21
+ */
22
+ import { describe, it, expect } from 'vitest'
23
+ import {
24
+ createEditFloodFuse,
25
+ editFloodFuseConfigFromEnv,
26
+ EDIT_FLOOD_FUSE_DEFAULTS,
27
+ } from '../edit-flood-fuse.js'
28
+ import { withOutboundClass, currentOutboundClass } from '../outbound-class.js'
29
+ import { createSendGate, type Clock } from '../send-gate.js'
30
+
31
+ const CHAT = '1001'
32
+ const CARD = 4242
33
+
34
+ class FakeClock implements Clock {
35
+ private cur = 0
36
+ private seq = 0
37
+ private timers: { at: number; id: number; resolve: () => void }[] = []
38
+
39
+ now(): number { return this.cur }
40
+
41
+ sleep(ms: number): Promise<void> {
42
+ return new Promise<void>((resolve) => {
43
+ this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
44
+ })
45
+ }
46
+
47
+ async advance(ms: number): Promise<void> {
48
+ const target = this.cur + ms
49
+ for (;;) {
50
+ await flush()
51
+ const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
52
+ if (due.length === 0) break
53
+ const t = due[0]!
54
+ this.timers = this.timers.filter((x) => x !== t)
55
+ this.cur = t.at
56
+ t.resolve()
57
+ await flush()
58
+ }
59
+ this.cur = target
60
+ await flush()
61
+ }
62
+ }
63
+
64
+ function flush(): Promise<void> {
65
+ return new Promise((r) => setImmediate(r))
66
+ }
67
+
68
+ describe('feed flood ban — a cosmetic edit stream is held below the banned rate', () => {
69
+ it('replays the incident cadence: ~17 feed edits/min offered for 10 min admit ≤ the cosmetic ceiling', async () => {
70
+ const clock = new FakeClock()
71
+ const landed: number[] = []
72
+ const fuse = createEditFloodFuse({ clock })
73
+
74
+ // The incident's peak 10-minute bucket: 174 editMessageText calls against
75
+ // one card in one chat. Offered at a steady ~3.4s spacing, exactly as the
76
+ // feed heartbeat + worker ticks produced them.
77
+ const OFFERED = 174
78
+ const SPAN_MS = 600_000
79
+ const inflight: Promise<unknown>[] = []
80
+ for (let i = 0; i < OFFERED; i++) {
81
+ inflight.push(
82
+ withOutboundClass('cosmetic', () => fuse.apply(
83
+ 'editMessageText',
84
+ { chat_id: CHAT, message_id: CARD, text: `frame ${i}` },
85
+ async () => { landed.push(i); return true },
86
+ )),
87
+ )
88
+ await clock.advance(Math.floor(SPAN_MS / OFFERED))
89
+ }
90
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
91
+ await Promise.all(inflight)
92
+
93
+ const perMinuteAdmitted = landed.length / (SPAN_MS / 60_000)
94
+ // The ceiling that must hold. 4/min per card is the cosmetic per-message
95
+ // default; a little slack covers window-boundary effects.
96
+ expect(perMinuteAdmitted).toBeLessThanOrEqual(
97
+ EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerMessageMaxPerWindow + 1)
98
+ // …and it must be a REDUCTION, not a no-op: the offered rate is the rate
99
+ // that earned the ban.
100
+ expect(landed.length).toBeLessThan(OFFERED / 3)
101
+ // The card is still live — throttled, never silenced.
102
+ expect(landed.length).toBeGreaterThan(0)
103
+
104
+ // The pre-fix ceilings would not have bound at all. This is the assertion
105
+ // that makes the whole suite non-vacuous: 174/10min = 17.4/min, which is
106
+ // under BOTH old ceilings, which is precisely why the ban happened.
107
+ const offeredPerMin = OFFERED / (SPAN_MS / 60_000)
108
+ expect(offeredPerMin).toBeLessThan(EDIT_FLOOD_FUSE_DEFAULTS.perMessageMaxPerWindow)
109
+ expect(offeredPerMin).toBeLessThan(EDIT_FLOOD_FUSE_DEFAULTS.perChatEditMaxPerWindow)
110
+ })
111
+
112
+ it('coalesces rather than loses state: the LAST frame offered is the last frame painted', async () => {
113
+ const clock = new FakeClock()
114
+ const landed: string[] = []
115
+ const fuse = createEditFloodFuse({ clock })
116
+
117
+ const inflight: Promise<unknown>[] = []
118
+ for (let i = 1; i <= 60; i++) {
119
+ inflight.push(
120
+ withOutboundClass('cosmetic', () => fuse.apply(
121
+ 'editMessageText', { chat_id: CHAT, message_id: CARD, text: `v${i}` },
122
+ async () => { landed.push(`v${i}`); return true },
123
+ )),
124
+ )
125
+ await clock.advance(1_000)
126
+ }
127
+ await clock.advance(120_000)
128
+ await Promise.all(inflight)
129
+
130
+ // Backpressure, not dropping-on-the-floor: the operator sees CURRENT state
131
+ // at every permitted edit, just less often. If the newest frame could be
132
+ // shed the card would go stale, which is the failure mode a plain rate
133
+ // limiter would introduce.
134
+ expect(landed[landed.length - 1]).toBe('v60')
135
+ })
136
+
137
+ it('N concurrent worker cards in one chat share the cosmetic allowance', async () => {
138
+ const clock = new FakeClock()
139
+ const landed: string[] = []
140
+ const fuse = createEditFloodFuse({ clock })
141
+
142
+ // 6 background workers, each with its own card, each individually inside
143
+ // the per-message ceiling. Only a per-CHAT cosmetic ceiling stops their sum.
144
+ const inflight: Promise<unknown>[] = []
145
+ // 9 rounds ⇒ every offer lands inside ONE 60s window, so the snapshot below
146
+ // measures the ceiling and not a window-boundary refill.
147
+ for (let round = 0; round < 9; round++) {
148
+ for (let w = 0; w < 6; w++) {
149
+ inflight.push(
150
+ withOutboundClass('cosmetic', () => fuse.apply(
151
+ 'editMessageText', { chat_id: CHAT, message_id: 500 + w, text: `w${w} r${round}` },
152
+ async () => { landed.push(`w${w} r${round}`); return true },
153
+ )),
154
+ )
155
+ }
156
+ await clock.advance(6_000) // the gateway's FEED_HEARTBEAT_TICK_MS
157
+ }
158
+ const inWindow = landed.length
159
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
160
+ await Promise.all(inflight)
161
+
162
+ // 54 offers across one 60s window; the chat's cosmetic allowance binds.
163
+ expect(inWindow).toBeLessThanOrEqual(
164
+ EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerChatMaxPerWindow
165
+ + EDIT_FLOOD_FUSE_DEFAULTS.lateReleaseMaxPerWindow + 1)
166
+ })
167
+ })
168
+
169
+ describe('feed flood ban — a real reply is never starved by feed edits', () => {
170
+ it('delivers a reply while the cosmetic budget is fully exhausted', async () => {
171
+ const clock = new FakeClock()
172
+ const replies: string[] = []
173
+ const fuse = createEditFloodFuse({ clock })
174
+
175
+ // Saturate: 200 cosmetic edits offered as fast as the feed can produce
176
+ // them, spread across several cards so every cosmetic tier is pinned.
177
+ const noise: Promise<unknown>[] = []
178
+ for (let i = 0; i < 200; i++) {
179
+ noise.push(
180
+ withOutboundClass('cosmetic', () => fuse.apply(
181
+ 'editMessageText', { chat_id: CHAT, message_id: 700 + (i % 5), text: `n${i}` },
182
+ async () => true,
183
+ )),
184
+ )
185
+ }
186
+ await clock.advance(500)
187
+
188
+ // The operator's actual answer, issued mid-flood.
189
+ const reply = withOutboundClass('critical', () => fuse.apply(
190
+ 'sendMessage', { chat_id: CHAT, text: 'the answer' },
191
+ async () => { replies.push('the answer'); return { message_id: 1 } },
192
+ ))
193
+ // It must land, and land PROMPTLY — inside the reserve, not after the
194
+ // cosmetic window slides. This is the property the incident violated: the
195
+ // feed consumed the chat's budget and the reply could not go out at all.
196
+ await clock.advance(2_000)
197
+ await reply
198
+ expect(replies).toEqual(['the answer'])
199
+
200
+ await clock.advance(300_000)
201
+ await Promise.all(noise)
202
+ // Not one reply was dropped by the fuse.
203
+ expect(fuse.stats().dropped).toBeGreaterThan(0) // cosmetic edits were shed…
204
+ expect(replies).toHaveLength(1) // …the answer was not.
205
+ })
206
+
207
+ it('does not throttle a non-cosmetic edit with the cosmetic ceiling', async () => {
208
+ const clock = new FakeClock()
209
+ const landed: number[] = []
210
+ const fuse = createEditFloodFuse({ clock })
211
+
212
+ // An approval card being re-rendered by operator taps: `useful`, low
213
+ // volume, but above the 4/min cosmetic ceiling. Throttling it would be a
214
+ // regression — the fix must separate classes, not just lower one number.
215
+ const inflight: Promise<unknown>[] = []
216
+ for (let i = 0; i < 10; i++) {
217
+ inflight.push(
218
+ withOutboundClass('useful', () => fuse.apply(
219
+ 'editMessageText', { chat_id: CHAT, message_id: 900, text: `tap ${i}` },
220
+ async () => { landed.push(i); return true },
221
+ )),
222
+ )
223
+ await clock.advance(2_000)
224
+ }
225
+ await clock.advance(60_000)
226
+ await Promise.all(inflight)
227
+
228
+ expect(landed).toHaveLength(10)
229
+ expect(landed[landed.length - 1]).toBe(9)
230
+ })
231
+
232
+ it('treats an UNTAGGED edit as cosmetic — a new unkeyed call site is limited by default', async () => {
233
+ const clock = new FakeClock()
234
+ const landed: number[] = []
235
+ const fuse = createEditFloodFuse({ clock })
236
+
237
+ // No `withOutboundClass` wrapper at all: the shape of a future call site
238
+ // that never heard of the send gate. That is exactly how both the
239
+ // 2026-07-25 and 2026-07-27 floods started.
240
+ const inflight: Promise<unknown>[] = []
241
+ for (let i = 0; i < 60; i++) {
242
+ inflight.push(fuse.apply(
243
+ 'editMessageText', { chat_id: CHAT, message_id: 950, text: `u${i}` },
244
+ async () => { landed.push(i); return true },
245
+ ))
246
+ await clock.advance(1_000)
247
+ }
248
+ const inWindow = landed.length
249
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
250
+ await Promise.all(inflight)
251
+
252
+ expect(inWindow).toBeLessThanOrEqual(
253
+ EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerMessageMaxPerWindow + 1)
254
+ })
255
+
256
+ it('treats an UNTAGGED send as critical — never dropped', async () => {
257
+ const clock = new FakeClock()
258
+ const landed: number[] = []
259
+ const fuse = createEditFloodFuse({ clock })
260
+ const inflight: Promise<unknown>[] = []
261
+ for (let i = 0; i < 40; i++) {
262
+ inflight.push(fuse.apply('sendMessage', { chat_id: CHAT, text: `s${i}` },
263
+ async () => { landed.push(i); return { message_id: i } }))
264
+ }
265
+ await clock.advance(600_000)
266
+ await Promise.all(inflight)
267
+ expect(landed).toHaveLength(40)
268
+ })
269
+ })
270
+
271
+ describe('feed flood ban — a 429 reduces the subsequent feed edit rate', () => {
272
+ it('escalates: each 429 compounds the tightening rather than re-applying one step', async () => {
273
+ const clock = new FakeClock()
274
+ const fuse = createEditFloodFuse({ clock })
275
+ const base = EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerChatMaxPerWindow
276
+
277
+ expect(fuse.stats().tightenLevel).toBe(0)
278
+ expect(fuse.stats().cosmeticPerChatCeiling).toBe(base)
279
+
280
+ const flood = (): unknown => Object.assign(
281
+ new Error('Too Many Requests: retry after 3'),
282
+ { error_code: 429, parameters: { retry_after: 3 } },
283
+ )
284
+ const takeOne429 = async (): Promise<void> => {
285
+ await expect(fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' },
286
+ async () => { throw flood() })).rejects.toThrow(/Too Many Requests/)
287
+ }
288
+
289
+ await takeOne429()
290
+ const after1 = fuse.stats().cosmeticPerChatCeiling
291
+ await clock.advance(1_000)
292
+ await takeOne429()
293
+ const after2 = fuse.stats().cosmeticPerChatCeiling
294
+
295
+ // Pre-fix a second 429 changed nothing: the tightening was a single flat
296
+ // 0.5× re-armed for another 10 minutes, so a stream taking repeated small
297
+ // 429s held the SAME rate and walked into the long ban.
298
+ expect(after1).toBeLessThan(base)
299
+ expect(after2).toBeLessThan(after1)
300
+ expect(fuse.stats().tightenLevel).toBe(2)
301
+ })
302
+
303
+ it('a 429 actually reduces how many feed edits get through afterwards', async () => {
304
+ const clock = new FakeClock()
305
+ const fuse = createEditFloodFuse({ clock })
306
+
307
+ const offerBurst = async (tag: string): Promise<number> => {
308
+ const landed: number[] = []
309
+ const inflight: Promise<unknown>[] = []
310
+ for (let i = 0; i < 40; i++) {
311
+ inflight.push(
312
+ withOutboundClass('cosmetic', () => fuse.apply(
313
+ 'editMessageText', { chat_id: CHAT, message_id: 1234, text: `${tag}${i}` },
314
+ async () => { landed.push(i); return true },
315
+ )),
316
+ )
317
+ await clock.advance(500)
318
+ }
319
+ const n = landed.length
320
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
321
+ await Promise.all(inflight)
322
+ return n
323
+ }
324
+
325
+ const before = await offerBurst('a')
326
+ // Let every window fully drain so the comparison isolates the tightening.
327
+ await clock.advance(120_000)
328
+
329
+ await expect(fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => {
330
+ throw Object.assign(new Error('Too Many Requests: retry after 3'),
331
+ { error_code: 429, parameters: { retry_after: 3 } })
332
+ })).rejects.toThrow(/Too Many Requests/)
333
+ await clock.advance(120_000)
334
+
335
+ const after = await offerBurst('b')
336
+
337
+ expect(before).toBeGreaterThan(0)
338
+ expect(after).toBeLessThan(before)
339
+ })
340
+
341
+ it('decays one level at a time rather than restoring full rate in one step', async () => {
342
+ const clock = new FakeClock()
343
+ const fuse = createEditFloodFuse({ clock, tightenMs: 60_000 })
344
+ const base = EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerMessageMaxPerWindow
345
+
346
+ for (let i = 0; i < 3; i++) {
347
+ await expect(fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => {
348
+ throw Object.assign(new Error('Too Many Requests: retry after 3'),
349
+ { error_code: 429, parameters: { retry_after: 3 } })
350
+ })).rejects.toThrow(/Too Many Requests/)
351
+ }
352
+ expect(fuse.stats().tightenLevel).toBe(3)
353
+
354
+ // #3856: the hold is now `max(tightenMs, retry_after * 1000 + tightenMs)`,
355
+ // so a stated `retry_after: 3` holds for 63s — the fuse must not begin
356
+ // decaying while Telegram's own stated cooldown is still running. The
357
+ // one-level-at-a-time decay this test guards is unchanged; only the FIRST
358
+ // hold is longer, by the stated 3s.
359
+ await clock.advance(63_001)
360
+ expect(fuse.stats().tightenLevel).toBe(2)
361
+ await clock.advance(60_001)
362
+ expect(fuse.stats().tightenLevel).toBe(1)
363
+ await clock.advance(60_001)
364
+ expect(fuse.stats().tightenLevel).toBe(0)
365
+ expect(fuse.stats().cosmeticPerMessageCeiling).toBe(base)
366
+ })
367
+ })
368
+
369
+ describe('feed flood ban — the send gate publishes the class the fuse depends on', () => {
370
+ it('a cosmetic gated EDIT is visible as cosmetic at the transformer seam', async () => {
371
+ const gate = createSendGate({ enabled: true })
372
+ let seen: string | undefined
373
+ await gate.gate(
374
+ async () => { seen = currentOutboundClass(); return true },
375
+ { chat_id: CHAT, messageId: CARD, editPayload: 'body', priorityClass: 'cosmetic' },
376
+ )
377
+ // Without this propagation the fuse's class awareness is decorative: every
378
+ // edit would fall back to the untagged default and the classes would never
379
+ // actually separate.
380
+ expect(seen).toBe('cosmetic')
381
+ })
382
+
383
+ it('a critical gated SEND is visible as critical at the transformer seam', async () => {
384
+ const gate = createSendGate({ enabled: true })
385
+ let seen: string | undefined
386
+ await gate.gate(
387
+ async () => { seen = currentOutboundClass(); return true },
388
+ { chat_id: CHAT, priorityClass: 'critical' },
389
+ )
390
+ expect(seen).toBe('critical')
391
+ })
392
+
393
+ it('an untagged gated send reports the gate\'s own UNTAGGED_SEND_CLASS', async () => {
394
+ const gate = createSendGate({ enabled: true })
395
+ let seen: string | undefined
396
+ await gate.gate(async () => { seen = currentOutboundClass(); return true }, { chat_id: CHAT })
397
+ expect(seen).toBe('critical')
398
+ })
399
+ })
400
+
401
+ describe('feed flood ban — the ceilings are operator-configurable', () => {
402
+ it('reads every ceiling from env, and keeps defaults when unset', () => {
403
+ // Pre-fix the ONLY knob was SWITCHROOM_EDIT_FUSE=0, which turns the whole
404
+ // failsafe off — an operator being flooded had no way to tighten it.
405
+ const cfg = editFloodFuseConfigFromEnv({
406
+ SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN: '2',
407
+ SWITCHROOM_FEED_EDIT_MAX_PER_CHAT_PER_MIN: '3',
408
+ SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN: '15',
409
+ SWITCHROOM_CHAT_REPLY_RESERVE: '9',
410
+ })
411
+ expect(cfg).toMatchObject({
412
+ enabled: true,
413
+ cosmeticPerMessageMaxPerWindow: 2,
414
+ cosmeticPerChatMaxPerWindow: 3,
415
+ perChatTotalMaxPerWindow: 15,
416
+ perChatReplyReserve: 9,
417
+ })
418
+
419
+ const bare = editFloodFuseConfigFromEnv({})
420
+ expect(bare.cosmeticPerMessageMaxPerWindow).toBeUndefined()
421
+ expect(bare.enabled).toBe(true)
422
+ expect(editFloodFuseConfigFromEnv({ SWITCHROOM_EDIT_FUSE: '0' }).enabled).toBe(false)
423
+ // Garbage must not silently become 0 (which would deadlock every card).
424
+ expect(editFloodFuseConfigFromEnv({ SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN: 'lots' })
425
+ .cosmeticPerMessageMaxPerWindow).toBeUndefined()
426
+ })
427
+
428
+ it('an env-tightened ceiling actually binds', async () => {
429
+ const clock = new FakeClock()
430
+ const fuse = createEditFloodFuse({
431
+ clock, ...editFloodFuseConfigFromEnv({ SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN: '1' }),
432
+ })
433
+ const landed: number[] = []
434
+ const inflight: Promise<unknown>[] = []
435
+ for (let i = 0; i < 30; i++) {
436
+ inflight.push(
437
+ withOutboundClass('cosmetic', () => fuse.apply(
438
+ 'editMessageText', { chat_id: CHAT, message_id: 4321, text: `e${i}` },
439
+ async () => { landed.push(i); return true },
440
+ )),
441
+ )
442
+ await clock.advance(1_000)
443
+ }
444
+ const inWindow = landed.length
445
+ await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
446
+ await Promise.all(inflight)
447
+ expect(inWindow).toBeLessThanOrEqual(2)
448
+ })
449
+
450
+ it('a reserve larger than the total budget cannot deadlock cosmetic traffic', async () => {
451
+ const clock = new FakeClock()
452
+ const fuse = createEditFloodFuse({
453
+ clock, perChatTotalMaxPerWindow: 5, perChatReplyReserve: 99,
454
+ })
455
+ const landed: number[] = []
456
+ await withOutboundClass('cosmetic', () => fuse.apply(
457
+ 'editMessageText', { chat_id: CHAT, message_id: 8888, text: 'x' },
458
+ async () => { landed.push(1); return true },
459
+ ))
460
+ expect(landed).toHaveLength(1)
461
+ })
462
+ })
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The real 2026-07 Telegram 429 stream, verbatim.
3
+ *
4
+ * Every `telegram gateway: 429 …` line one production agent's gateway emitted
5
+ * between 2026-07-12 and 2026-07-27, extracted from `gateway-supervisor.log`
6
+ * (plus its rotated `.gz`) as
7
+ * `[<iso>] … (flood ban of <n>s | rate limited, waiting <n>s)`.
8
+ * 170 observations; nothing sampled, synthesised, or reordered. Timestamps and
9
+ * `retry_after` values only — no ids, no chat content.
10
+ *
11
+ * This is the fixture the flood-pressure classifier is proved against, because
12
+ * the shape is the whole problem:
13
+ *
14
+ * - 72 of the 170 are short rate nudges, the largest being 5s. They are
15
+ * invisible to the operator: `retryApiCall` sleeps them and the send lands.
16
+ * - The smallest `retry_after` belonging to a real ban is 282s. There is no
17
+ * observed traffic between 5s and 282s, so the benign band is wide.
18
+ * - 87 of the remaining observations are ONE ban (2026-07-27T20:19:56Z,
19
+ * `retry_after=15908`) re-observed every ~5s as it counted down. Any signal
20
+ * built on event COUNT explodes here, during the outage it should have
21
+ * predicted.
22
+ *
23
+ * The four real bans:
24
+ * 2026-07-12T05:06:46Z retry_after=21397 (5.9h)
25
+ * 2026-07-13T03:17:47Z retry_after=27951 (7.8h)
26
+ * 2026-07-25T23:43:07Z retry_after=3713 (62min)
27
+ * 2026-07-27T20:19:56Z retry_after=15908 (4.4h) ← the incident
28
+ *
29
+ * Generated once from the live logs; do not edit by hand.
30
+ */
31
+
32
+ /** One observed 429: ISO timestamp + Telegram's `parameters.retry_after`. */
33
+ export interface RawFlood429 {
34
+ iso: string
35
+ ts: number
36
+ retryAfterSec: number
37
+ }
38
+
39
+ const RAW: ReadonlyArray<{ iso: string; retryAfterSec: number }> = [
40
+ { iso: "2026-07-12T00:43:42.969Z", retryAfterSec: 3 },
41
+ { iso: "2026-07-12T00:43:52.754Z", retryAfterSec: 3 },
42
+ { iso: "2026-07-12T05:06:46.641Z", retryAfterSec: 21397 },
43
+ { iso: "2026-07-12T23:39:24.579Z", retryAfterSec: 436 },
44
+ { iso: "2026-07-13T00:37:18.647Z", retryAfterSec: 563 },
45
+ { iso: "2026-07-13T00:37:19.162Z", retryAfterSec: 563 },
46
+ { iso: "2026-07-13T01:42:00.795Z", retryAfterSec: 283 },
47
+ { iso: "2026-07-13T01:42:01.269Z", retryAfterSec: 282 },
48
+ { iso: "2026-07-13T01:42:01.741Z", retryAfterSec: 282 },
49
+ { iso: "2026-07-13T02:39:43.308Z", retryAfterSec: 425 },
50
+ { iso: "2026-07-13T03:17:47.764Z", retryAfterSec: 27951 },
51
+ { iso: "2026-07-14T01:00:55.498Z", retryAfterSec: 3 },
52
+ { iso: "2026-07-14T01:01:05.283Z", retryAfterSec: 3 },
53
+ { iso: "2026-07-14T01:42:51.141Z", retryAfterSec: 3 },
54
+ { iso: "2026-07-14T01:43:00.941Z", retryAfterSec: 3 },
55
+ { iso: "2026-07-14T10:17:08.494Z", retryAfterSec: 3 },
56
+ { iso: "2026-07-14T10:17:18.394Z", retryAfterSec: 3 },
57
+ { iso: "2026-07-15T06:47:31.287Z", retryAfterSec: 3 },
58
+ { iso: "2026-07-15T11:28:14.054Z", retryAfterSec: 3 },
59
+ { iso: "2026-07-15T11:28:23.913Z", retryAfterSec: 3 },
60
+ { iso: "2026-07-16T01:48:28.656Z", retryAfterSec: 3 },
61
+ { iso: "2026-07-16T01:48:38.477Z", retryAfterSec: 3 },
62
+ { iso: "2026-07-16T02:37:16.659Z", retryAfterSec: 3 },
63
+ { iso: "2026-07-16T02:37:26.496Z", retryAfterSec: 3 },
64
+ { iso: "2026-07-16T07:39:14.389Z", retryAfterSec: 3 },
65
+ { iso: "2026-07-16T07:39:24.206Z", retryAfterSec: 3 },
66
+ { iso: "2026-07-16T19:29:32.965Z", retryAfterSec: 3 },
67
+ { iso: "2026-07-16T19:29:42.817Z", retryAfterSec: 3 },
68
+ { iso: "2026-07-16T21:32:57.539Z", retryAfterSec: 5 },
69
+ { iso: "2026-07-17T00:04:44.818Z", retryAfterSec: 3 },
70
+ { iso: "2026-07-17T00:04:54.594Z", retryAfterSec: 3 },
71
+ { iso: "2026-07-17T02:45:40.820Z", retryAfterSec: 3 },
72
+ { iso: "2026-07-17T02:45:50.608Z", retryAfterSec: 3 },
73
+ { iso: "2026-07-17T06:10:38.674Z", retryAfterSec: 3 },
74
+ { iso: "2026-07-17T06:10:48.510Z", retryAfterSec: 3 },
75
+ { iso: "2026-07-17T07:55:18.855Z", retryAfterSec: 3 },
76
+ { iso: "2026-07-17T07:55:28.640Z", retryAfterSec: 3 },
77
+ { iso: "2026-07-17T09:21:15.798Z", retryAfterSec: 3 },
78
+ { iso: "2026-07-18T22:35:20.897Z", retryAfterSec: 3 },
79
+ { iso: "2026-07-19T01:25:52.829Z", retryAfterSec: 3 },
80
+ { iso: "2026-07-19T01:26:02.649Z", retryAfterSec: 3 },
81
+ { iso: "2026-07-19T03:29:55.399Z", retryAfterSec: 3 },
82
+ { iso: "2026-07-19T03:30:05.203Z", retryAfterSec: 3 },
83
+ { iso: "2026-07-19T06:05:47.796Z", retryAfterSec: 3 },
84
+ { iso: "2026-07-19T06:05:57.632Z", retryAfterSec: 3 },
85
+ { iso: "2026-07-19T10:21:12.347Z", retryAfterSec: 3 },
86
+ { iso: "2026-07-19T10:21:22.178Z", retryAfterSec: 3 },
87
+ { iso: "2026-07-19T10:21:50.826Z", retryAfterSec: 3 },
88
+ { iso: "2026-07-19T10:22:00.651Z", retryAfterSec: 3 },
89
+ { iso: "2026-07-19T10:22:10.486Z", retryAfterSec: 3 },
90
+ { iso: "2026-07-19T10:47:10.609Z", retryAfterSec: 3 },
91
+ { iso: "2026-07-19T10:47:20.439Z", retryAfterSec: 3 },
92
+ { iso: "2026-07-19T10:47:30.277Z", retryAfterSec: 3 },
93
+ { iso: "2026-07-20T01:40:22.311Z", retryAfterSec: 3 },
94
+ { iso: "2026-07-20T01:40:32.183Z", retryAfterSec: 3 },
95
+ { iso: "2026-07-20T02:26:37.436Z", retryAfterSec: 3 },
96
+ { iso: "2026-07-20T02:26:47.514Z", retryAfterSec: 3 },
97
+ { iso: "2026-07-21T11:14:47.910Z", retryAfterSec: 3 },
98
+ { iso: "2026-07-21T11:14:57.729Z", retryAfterSec: 3 },
99
+ { iso: "2026-07-21T22:59:02.412Z", retryAfterSec: 3 },
100
+ { iso: "2026-07-21T22:59:12.189Z", retryAfterSec: 3 },
101
+ { iso: "2026-07-23T19:58:39.060Z", retryAfterSec: 3 },
102
+ { iso: "2026-07-24T08:22:21.126Z", retryAfterSec: 3 },
103
+ { iso: "2026-07-24T09:00:54.528Z", retryAfterSec: 3 },
104
+ { iso: "2026-07-24T09:01:04.413Z", retryAfterSec: 3 },
105
+ { iso: "2026-07-24T21:47:04.852Z", retryAfterSec: 3 },
106
+ { iso: "2026-07-24T21:49:20.213Z", retryAfterSec: 3 },
107
+ { iso: "2026-07-24T21:49:30.004Z", retryAfterSec: 3 },
108
+ { iso: "2026-07-24T21:49:39.786Z", retryAfterSec: 3 },
109
+ { iso: "2026-07-25T05:24:04.941Z", retryAfterSec: 3 },
110
+ { iso: "2026-07-25T22:50:39.203Z", retryAfterSec: 3 },
111
+ { iso: "2026-07-25T22:50:48.981Z", retryAfterSec: 3 },
112
+ { iso: "2026-07-25T23:43:07.861Z", retryAfterSec: 3713 },
113
+ { iso: "2026-07-26T14:07:07.428Z", retryAfterSec: 3 },
114
+ { iso: "2026-07-26T14:07:20.567Z", retryAfterSec: 3 },
115
+ { iso: "2026-07-26T14:07:30.355Z", retryAfterSec: 3 },
116
+ { iso: "2026-07-26T22:43:03.042Z", retryAfterSec: 3 },
117
+ { iso: "2026-07-27T05:50:52.944Z", retryAfterSec: 3 },
118
+ { iso: "2026-07-27T10:04:58.523Z", retryAfterSec: 3 },
119
+ { iso: "2026-07-27T10:05:12.142Z", retryAfterSec: 3 },
120
+ { iso: "2026-07-27T10:05:21.953Z", retryAfterSec: 3 },
121
+ { iso: "2026-07-27T16:22:32.052Z", retryAfterSec: 3 },
122
+ { iso: "2026-07-27T20:19:56.704Z", retryAfterSec: 15908 },
123
+ { iso: "2026-07-27T20:53:27.532Z", retryAfterSec: 13897 },
124
+ { iso: "2026-07-27T20:53:31.923Z", retryAfterSec: 13893 },
125
+ { iso: "2026-07-27T20:53:36.919Z", retryAfterSec: 13888 },
126
+ { iso: "2026-07-27T20:53:41.904Z", retryAfterSec: 13883 },
127
+ { iso: "2026-07-27T20:53:46.908Z", retryAfterSec: 13878 },
128
+ { iso: "2026-07-27T20:53:51.904Z", retryAfterSec: 13873 },
129
+ { iso: "2026-07-27T20:53:56.914Z", retryAfterSec: 13868 },
130
+ { iso: "2026-07-27T20:54:01.911Z", retryAfterSec: 13863 },
131
+ { iso: "2026-07-27T20:54:06.907Z", retryAfterSec: 13858 },
132
+ { iso: "2026-07-27T20:54:11.932Z", retryAfterSec: 13853 },
133
+ { iso: "2026-07-27T20:54:16.925Z", retryAfterSec: 13848 },
134
+ { iso: "2026-07-27T20:54:21.929Z", retryAfterSec: 13843 },
135
+ { iso: "2026-07-27T20:54:26.932Z", retryAfterSec: 13838 },
136
+ { iso: "2026-07-27T20:54:31.929Z", retryAfterSec: 13833 },
137
+ { iso: "2026-07-27T20:54:36.940Z", retryAfterSec: 13828 },
138
+ { iso: "2026-07-27T20:54:41.914Z", retryAfterSec: 13823 },
139
+ { iso: "2026-07-27T20:54:46.919Z", retryAfterSec: 13818 },
140
+ { iso: "2026-07-27T20:54:51.949Z", retryAfterSec: 13813 },
141
+ { iso: "2026-07-27T20:54:56.911Z", retryAfterSec: 13808 },
142
+ { iso: "2026-07-27T20:55:01.932Z", retryAfterSec: 13803 },
143
+ { iso: "2026-07-27T20:55:06.918Z", retryAfterSec: 13798 },
144
+ { iso: "2026-07-27T20:55:11.949Z", retryAfterSec: 13793 },
145
+ { iso: "2026-07-27T20:55:17.011Z", retryAfterSec: 13788 },
146
+ { iso: "2026-07-27T20:55:21.997Z", retryAfterSec: 13783 },
147
+ { iso: "2026-07-27T20:55:26.949Z", retryAfterSec: 13778 },
148
+ { iso: "2026-07-27T20:55:31.941Z", retryAfterSec: 13773 },
149
+ { iso: "2026-07-27T20:55:36.943Z", retryAfterSec: 13768 },
150
+ { iso: "2026-07-27T20:55:41.924Z", retryAfterSec: 13763 },
151
+ { iso: "2026-07-27T20:55:46.927Z", retryAfterSec: 13758 },
152
+ { iso: "2026-07-27T20:55:51.928Z", retryAfterSec: 13753 },
153
+ { iso: "2026-07-27T20:55:56.922Z", retryAfterSec: 13748 },
154
+ { iso: "2026-07-27T20:56:01.937Z", retryAfterSec: 13743 },
155
+ { iso: "2026-07-27T20:56:06.922Z", retryAfterSec: 13738 },
156
+ { iso: "2026-07-27T20:56:11.950Z", retryAfterSec: 13733 },
157
+ { iso: "2026-07-27T20:56:16.950Z", retryAfterSec: 13728 },
158
+ { iso: "2026-07-27T20:56:21.954Z", retryAfterSec: 13723 },
159
+ { iso: "2026-07-27T20:56:26.955Z", retryAfterSec: 13718 },
160
+ { iso: "2026-07-27T20:56:31.952Z", retryAfterSec: 13713 },
161
+ { iso: "2026-07-27T20:56:36.954Z", retryAfterSec: 13708 },
162
+ { iso: "2026-07-27T20:56:41.952Z", retryAfterSec: 13703 },
163
+ { iso: "2026-07-27T20:56:46.938Z", retryAfterSec: 13698 },
164
+ { iso: "2026-07-27T20:56:51.937Z", retryAfterSec: 13693 },
165
+ { iso: "2026-07-27T20:56:56.942Z", retryAfterSec: 13688 },
166
+ { iso: "2026-07-27T20:57:01.984Z", retryAfterSec: 13683 },
167
+ { iso: "2026-07-27T20:57:06.955Z", retryAfterSec: 13678 },
168
+ { iso: "2026-07-27T20:57:11.959Z", retryAfterSec: 13673 },
169
+ { iso: "2026-07-27T20:57:16.976Z", retryAfterSec: 13668 },
170
+ { iso: "2026-07-27T20:57:21.964Z", retryAfterSec: 13663 },
171
+ { iso: "2026-07-27T20:57:26.962Z", retryAfterSec: 13658 },
172
+ { iso: "2026-07-27T20:57:31.968Z", retryAfterSec: 13653 },
173
+ { iso: "2026-07-27T20:57:36.960Z", retryAfterSec: 13648 },
174
+ { iso: "2026-07-27T20:57:41.981Z", retryAfterSec: 13643 },
175
+ { iso: "2026-07-27T20:57:46.972Z", retryAfterSec: 13638 },
176
+ { iso: "2026-07-27T20:57:51.976Z", retryAfterSec: 13633 },
177
+ { iso: "2026-07-27T20:57:56.966Z", retryAfterSec: 13628 },
178
+ { iso: "2026-07-27T20:58:01.971Z", retryAfterSec: 13623 },
179
+ { iso: "2026-07-27T20:58:07.023Z", retryAfterSec: 13618 },
180
+ { iso: "2026-07-27T20:58:12.081Z", retryAfterSec: 13613 },
181
+ { iso: "2026-07-27T20:58:16.975Z", retryAfterSec: 13608 },
182
+ { iso: "2026-07-27T20:58:21.974Z", retryAfterSec: 13603 },
183
+ { iso: "2026-07-27T20:58:26.994Z", retryAfterSec: 13598 },
184
+ { iso: "2026-07-27T20:58:31.979Z", retryAfterSec: 13593 },
185
+ { iso: "2026-07-27T20:58:36.977Z", retryAfterSec: 13588 },
186
+ { iso: "2026-07-27T20:58:41.983Z", retryAfterSec: 13583 },
187
+ { iso: "2026-07-27T20:58:46.966Z", retryAfterSec: 13578 },
188
+ { iso: "2026-07-27T20:58:51.982Z", retryAfterSec: 13573 },
189
+ { iso: "2026-07-27T20:58:56.984Z", retryAfterSec: 13568 },
190
+ { iso: "2026-07-27T20:59:01.983Z", retryAfterSec: 13563 },
191
+ { iso: "2026-07-27T20:59:06.982Z", retryAfterSec: 13558 },
192
+ { iso: "2026-07-27T20:59:11.987Z", retryAfterSec: 13553 },
193
+ { iso: "2026-07-27T20:59:16.995Z", retryAfterSec: 13548 },
194
+ { iso: "2026-07-27T20:59:21.982Z", retryAfterSec: 13543 },
195
+ { iso: "2026-07-27T20:59:26.987Z", retryAfterSec: 13538 },
196
+ { iso: "2026-07-27T20:59:31.991Z", retryAfterSec: 13533 },
197
+ { iso: "2026-07-27T20:59:36.994Z", retryAfterSec: 13528 },
198
+ { iso: "2026-07-27T20:59:41.993Z", retryAfterSec: 13523 },
199
+ { iso: "2026-07-27T20:59:47.005Z", retryAfterSec: 13518 },
200
+ { iso: "2026-07-27T20:59:51.997Z", retryAfterSec: 13513 },
201
+ { iso: "2026-07-27T20:59:56.996Z", retryAfterSec: 13508 },
202
+ { iso: "2026-07-27T21:00:02.004Z", retryAfterSec: 13503 },
203
+ { iso: "2026-07-27T21:00:07.009Z", retryAfterSec: 13498 },
204
+ { iso: "2026-07-27T21:00:11.996Z", retryAfterSec: 13493 },
205
+ { iso: "2026-07-27T21:00:17.016Z", retryAfterSec: 13488 },
206
+ { iso: "2026-07-27T21:00:22.002Z", retryAfterSec: 13483 },
207
+ { iso: "2026-07-27T21:00:27.001Z", retryAfterSec: 13478 },
208
+ { iso: "2026-07-27T21:00:32.002Z", retryAfterSec: 13473 },
209
+ { iso: "2026-07-27T21:00:36.999Z", retryAfterSec: 13468 },
210
+ ]
211
+
212
+ export const REAL_429_STREAM: ReadonlyArray<RawFlood429> = RAW.map((e) => ({
213
+ ...e,
214
+ ts: Date.parse(e.iso),
215
+ }))
216
+
217
+ /** Convenience: `Date.parse` of an ISO instant, for assertion timestamps. */
218
+ export function at(iso: string): number {
219
+ return Date.parse(iso)
220
+ }