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,340 @@
1
+ /**
2
+ * The reply reserve must actually reserve — at EVERY tighten level (#3885).
3
+ *
4
+ * ── What broke in production ─────────────────────────────────────────────
5
+ * A 12-agent staggered restart drew three genuine 429s. The chat logged 792
6
+ * fuse events in an hour against a 35-50/hr baseline, shed 221 typing
7
+ * indicators, deferred 18 reactions (the operator noticed his reactions had
8
+ * stopped working), and held real replies long enough that the MCP `reply`
9
+ * tool's 60s timeout fired — so the agent retried and the operator received
10
+ * the same answer twice. One ~1900-character reply was lost entirely.
11
+ *
12
+ * The arithmetic: at `maxTightenLevel: 4` with `tightenFactor: 0.5`, the shared
13
+ * per-chat budget `max(1, floor(20 * 0.5^4))` is **1 call per minute for the
14
+ * whole chat**. `perChatReplyReserve: 8` was a fixed count subtracted from the
15
+ * BASE — `max(1, 20 - 8) = 12`, itself tightened to 1 — so a reply and a
16
+ * progress repaint ended up with the identical budget of 1. The reservation
17
+ * was arithmetically dead in exactly the situation it exists for.
18
+ *
19
+ * ── What these tests pin ─────────────────────────────────────────────────
20
+ * Outcomes on the wire, not code paths. Every case here counts how many calls
21
+ * a fake downstream actually received.
22
+ *
23
+ * 1. A `critical` message gets through at maximum tightening, inside the
24
+ * window. (This is the case that fails on pre-#3885 code.)
25
+ * 2. Critical strictly out-ranks cosmetic on the shared budget at EVERY
26
+ * tighten level — the reserve is proportional, not absolute.
27
+ * 3. Cosmetic traffic is still shed under pressure. #3847 built this feature
28
+ * to stop a repaint surface earning a flood ban; the reserve fix must not
29
+ * hand that budget back.
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest'
33
+
34
+ import { createEditFloodFuse, EDIT_FLOOD_FUSE_DEFAULTS } from '../edit-flood-fuse.js'
35
+ import { withOutboundClass } from '../outbound-class.js'
36
+ import type { Clock } from '../send-gate.js'
37
+
38
+ const CHAT = '1001'
39
+
40
+ class FakeClock implements Clock {
41
+ private cur = 0
42
+ private seq = 0
43
+ private timers: { at: number; id: number; resolve: () => void }[] = []
44
+ now(): number { return this.cur }
45
+ sleep(ms: number): Promise<void> {
46
+ return new Promise<void>((resolve) => {
47
+ this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
48
+ })
49
+ }
50
+ async advance(ms: number): Promise<void> {
51
+ const target = this.cur + ms
52
+ for (;;) {
53
+ await flush()
54
+ const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
55
+ if (due.length === 0) break
56
+ const t = due[0]!
57
+ this.timers = this.timers.filter((x) => x !== t)
58
+ this.cur = t.at
59
+ t.resolve()
60
+ await flush()
61
+ }
62
+ this.cur = target
63
+ await flush()
64
+ }
65
+ }
66
+ const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
67
+
68
+ /**
69
+ * A fuse pinned at `maxTightenLevel` via a large persisted ban window — the
70
+ * production shape, and the one where `0.5^4` bites.
71
+ */
72
+ function tightestFuse(clock: FakeClock, over: Record<string, number> = {}) {
73
+ return createEditFloodFuse({
74
+ clock,
75
+ floodWaitRemainingMs: () => 12_247_000, // a 3.4h ban: maximum tightening
76
+ floodProbeIntervalMs: 0,
77
+ perChatTotalMaxPerWindow: 20,
78
+ perChatReplyReserve: 8,
79
+ perChatSendMaxPerWindow: 25,
80
+ perChatEditMaxPerWindow: 30,
81
+ perMessageMaxPerWindow: 20,
82
+ perChatWindowMs: 60_000,
83
+ perTokenMaxPerWindow: 1_000, // take the per-second tier out of the picture
84
+ maxTightenLevel: 4,
85
+ tightenFactor: 0.5,
86
+ ...over,
87
+ })
88
+ }
89
+
90
+ describe('a reply gets through at maximum tightening (#3885)', () => {
91
+ it('delivers a critical send inside the window when the chat is at its tightest', async () => {
92
+ // THE regression test. On pre-#3885 code the whole chat had an effective
93
+ // budget of 1/min and the reserve could not raise it, so the second and
94
+ // subsequent replies sat until `maxDeferMs` — past the MCP reply tool's
95
+ // own 60s timeout, which is what produced duplicates and one lost answer.
96
+ const clock = new FakeClock()
97
+ const fuse = tightestFuse(clock)
98
+ expect(fuse.stats().tightenLevel).toBe(4)
99
+
100
+ let landed = 0
101
+ const replies = Array.from({ length: 3 }, (_, i) =>
102
+ withOutboundClass('critical', () =>
103
+ fuse.apply('sendMessage', { chat_id: CHAT, text: `answer ${i}` }, async () => {
104
+ landed++
105
+ return true
106
+ })))
107
+ // No clock advance at all: the floor must be available immediately, not
108
+ // "eventually, after the window slides".
109
+ await flush()
110
+ expect(landed).toBe(EDIT_FLOOD_FUSE_DEFAULTS.perChatCriticalMinPerWindow)
111
+
112
+ await clock.advance(60_000)
113
+ await Promise.all(replies)
114
+ })
115
+
116
+ it('delivers a critical EDIT (an approval card, an answer finalisation) too', async () => {
117
+ const clock = new FakeClock()
118
+ const fuse = tightestFuse(clock)
119
+ let landed = 0
120
+ const edits = Array.from({ length: 3 }, (_, i) =>
121
+ withOutboundClass('critical', () =>
122
+ fuse.apply('editMessageText', { chat_id: CHAT, message_id: 100 + i }, async () => {
123
+ landed++
124
+ return true
125
+ })))
126
+ await flush()
127
+ expect(landed).toBe(3)
128
+ await clock.advance(60_000)
129
+ await Promise.all(edits)
130
+ })
131
+
132
+ it('delivers a critical reaction — the surface the operator noticed had died', async () => {
133
+ // `setMessageReaction` is neither an edit nor a send: it takes the
134
+ // default-deny path (#3855) straight to the shared per-chat budget, which
135
+ // is where it was being deferred 30s at a time.
136
+ const clock = new FakeClock()
137
+ const fuse = tightestFuse(clock)
138
+ let landed = 0
139
+ const calls = Array.from({ length: 3 }, () =>
140
+ withOutboundClass('critical', () =>
141
+ fuse.apply('setMessageReaction', { chat_id: CHAT, message_id: 7 }, async () => {
142
+ landed++
143
+ return true
144
+ })))
145
+ await flush()
146
+ expect(landed).toBe(3)
147
+ await clock.advance(60_000)
148
+ await Promise.all(calls)
149
+ })
150
+
151
+ it('the critical floor is capped by the operator\'s own base ceiling', async () => {
152
+ // A floor that could RAISE a ceiling above what the operator configured
153
+ // would be a second, hidden rate policy. Base 2 means 2, tightened or not.
154
+ const clock = new FakeClock()
155
+ const fuse = tightestFuse(clock, { perChatTotalMaxPerWindow: 2, perChatReplyReserve: 0 })
156
+ expect(fuse.stats().criticalPerChatTotalCeiling).toBe(2)
157
+ let landed = 0
158
+ const calls = Array.from({ length: 5 }, () =>
159
+ withOutboundClass('critical', () =>
160
+ fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => { landed++; return true })))
161
+ await flush()
162
+ expect(landed).toBe(2)
163
+ await clock.advance(60_000)
164
+ await Promise.all(calls)
165
+ })
166
+ })
167
+
168
+ describe('the reserve is honoured against the EFFECTIVE ceiling, at every level', () => {
169
+ it('critical strictly out-ranks cosmetic on the shared budget at levels 0..4', () => {
170
+ for (let level = 0; level <= 4; level++) {
171
+ const clock = new FakeClock()
172
+ // `tightenFactor^level` with a per-level persisted window is fiddly to
173
+ // stage; drive the level directly by tightening with graded 429s is
174
+ // equally so. Configure `maxTightenLevel: level` and hold a large
175
+ // persisted window: `levelAt` then reports exactly `level`.
176
+ const fuse = createEditFloodFuse({
177
+ clock,
178
+ floodWaitRemainingMs: () => 12_247_000,
179
+ floodProbeIntervalMs: 0,
180
+ perChatTotalMaxPerWindow: 20,
181
+ perChatReplyReserve: 8,
182
+ maxTightenLevel: level,
183
+ tightenFactor: 0.5,
184
+ })
185
+ const s = fuse.stats()
186
+ expect(s.tightenLevel).toBe(level)
187
+ // The property that failed in production: whatever the effective budget
188
+ // is, a reply always has strictly more of it than a repaint, and never
189
+ // less than the floor.
190
+ expect(s.criticalPerChatTotalCeiling).toBeGreaterThan(s.cosmeticPerChatTotalCeiling)
191
+ expect(s.criticalPerChatTotalCeiling)
192
+ .toBeGreaterThanOrEqual(EDIT_FLOOD_FUSE_DEFAULTS.perChatCriticalMinPerWindow)
193
+ }
194
+ })
195
+
196
+ it('level 0 behaviour is unchanged — 8 of 20 reserved, exactly as before', () => {
197
+ const fuse = createEditFloodFuse({
198
+ clock: new FakeClock(),
199
+ perChatTotalMaxPerWindow: 20,
200
+ perChatReplyReserve: 8,
201
+ })
202
+ expect(fuse.stats().tightenLevel).toBe(0)
203
+ expect(fuse.stats().criticalPerChatTotalCeiling).toBe(20)
204
+ expect(fuse.stats().cosmeticPerChatTotalCeiling).toBe(12)
205
+ })
206
+
207
+ it('a zero reserve still means zero — the fix does not invent a reservation', () => {
208
+ const fuse = createEditFloodFuse({
209
+ clock: new FakeClock(),
210
+ floodWaitRemainingMs: () => 12_247_000,
211
+ floodProbeIntervalMs: 0,
212
+ perChatTotalMaxPerWindow: 20,
213
+ perChatReplyReserve: 0,
214
+ maxTightenLevel: 4,
215
+ tightenFactor: 0.5,
216
+ })
217
+ expect(fuse.stats().cosmeticPerChatTotalCeiling).toBe(1)
218
+ })
219
+
220
+ it('cosmetic reaches 0 at the tightest — every remaining slot is the reply\'s', () => {
221
+ const fuse = createEditFloodFuse({
222
+ clock: new FakeClock(),
223
+ floodWaitRemainingMs: () => 12_247_000,
224
+ floodProbeIntervalMs: 0,
225
+ perChatTotalMaxPerWindow: 20,
226
+ perChatReplyReserve: 8,
227
+ maxTightenLevel: 4,
228
+ tightenFactor: 0.5,
229
+ })
230
+ expect(fuse.stats().cosmeticPerChatTotalCeiling).toBe(0)
231
+ })
232
+ })
233
+
234
+ describe('cosmetic traffic is still shed under pressure (#3847 must not regress)', () => {
235
+ it('sheds typing indicators while the chat is at maximum tightening', async () => {
236
+ const clock = new FakeClock()
237
+ const fuse = tightestFuse(clock)
238
+ let landed = 0
239
+ const actions = Array.from({ length: 12 }, () =>
240
+ withOutboundClass('cosmetic', () =>
241
+ fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => {
242
+ landed++
243
+ return true
244
+ })))
245
+ await clock.advance(31_000)
246
+ await Promise.all(actions)
247
+ // A typing bubble expires by itself in ~5s; delivering it late is worse
248
+ // than not delivering it. Under maximum tightening none should reach the
249
+ // wire — the budget belongs to the answer.
250
+ expect(landed).toBe(0)
251
+ expect(fuse.stats().dropped).toBeGreaterThan(0)
252
+ })
253
+
254
+ it('holds a hot cosmetic card far below the cosmetic ceiling when tightened', async () => {
255
+ const clock = new FakeClock()
256
+ const fuse = tightestFuse(clock, { cosmeticPerMessageMaxPerWindow: 4, cosmeticPerChatMaxPerWindow: 6 })
257
+ let landed = 0
258
+ const frames = Array.from({ length: 40 }, () =>
259
+ withOutboundClass('cosmetic', () =>
260
+ fuse.apply('editMessageText', { chat_id: CHAT, message_id: 7 }, async () => {
261
+ landed++
262
+ return true
263
+ })))
264
+ await clock.advance(31_000)
265
+ await Promise.all(frames)
266
+ // 0.5^4 of 4 floors at 1 repaint per window; the late-release budget can
267
+ // add at most `lateReleaseMaxPerWindow` tightened to 1 on top.
268
+ expect(landed).toBeLessThanOrEqual(2)
269
+ })
270
+
271
+ it('a saturated cosmetic surface cannot starve a reply', async () => {
272
+ // The starvation guarantee stated as an outcome: repaints run flat out for
273
+ // a whole window, and the answer still lands.
274
+ const clock = new FakeClock()
275
+ const fuse = tightestFuse(clock)
276
+ const frames = Array.from({ length: 60 }, (_, i) =>
277
+ withOutboundClass('cosmetic', () =>
278
+ fuse.apply('editMessageText', { chat_id: CHAT, message_id: 200 + (i % 6) }, async () => true)))
279
+ await flush()
280
+
281
+ let replied = 0
282
+ const reply = withOutboundClass('critical', () =>
283
+ fuse.apply('sendMessage', { chat_id: CHAT, text: 'the answer' }, async () => {
284
+ replied++
285
+ return true
286
+ }))
287
+ await flush()
288
+ expect(replied).toBe(1)
289
+
290
+ await clock.advance(61_000)
291
+ await Promise.all([...frames, reply])
292
+ })
293
+ })
294
+
295
+ describe('operator knobs for the tightening curve (#3885)', () => {
296
+ it('SWITCHROOM_EDIT_FUSE_MAX_TIGHTEN_LEVEL and _TIGHTEN_FACTOR reach the fuse', async () => {
297
+ const { editFloodFuseConfigFromEnv } = await import('../edit-flood-fuse.js')
298
+ const cfg = editFloodFuseConfigFromEnv({
299
+ SWITCHROOM_EDIT_FUSE_MAX_TIGHTEN_LEVEL: '2',
300
+ SWITCHROOM_EDIT_FUSE_TIGHTEN_FACTOR: '0.75',
301
+ SWITCHROOM_CHAT_CRITICAL_MIN_PER_MIN: '5',
302
+ })
303
+ expect(cfg.maxTightenLevel).toBe(2)
304
+ expect(cfg.tightenFactor).toBe(0.75)
305
+ expect(cfg.perChatCriticalMinPerWindow).toBe(5)
306
+
307
+ // And they BIND: 0.75^2 of 20 is 11, not 0.5^4's 1.
308
+ const fuse = createEditFloodFuse({
309
+ ...cfg, clock: new FakeClock(), floodProbeIntervalMs: 0,
310
+ floodWaitRemainingMs: () => 12_247_000, perChatTotalMaxPerWindow: 20,
311
+ })
312
+ expect(fuse.stats().criticalPerChatTotalCeiling).toBe(11)
313
+ })
314
+
315
+ it('maxTightenLevel=0 disables tightening even with a ban marker on disk', async () => {
316
+ // `tightenStepFor` returns a fixed 1..3 for the smaller severity bands, so
317
+ // the persisted path had to be clamped by `maxTightenLevel` too — otherwise
318
+ // an operator who turned tightening OFF would find the fuse tightening
319
+ // anyway the moment a marker existed.
320
+ const fuse = createEditFloodFuse({
321
+ clock: new FakeClock(),
322
+ floodWaitRemainingMs: () => 12_247_000,
323
+ floodProbeIntervalMs: 0,
324
+ maxTightenLevel: 0,
325
+ perChatTotalMaxPerWindow: 20,
326
+ })
327
+ expect(fuse.stats().persistedFloodOpen).toBe(true)
328
+ expect(fuse.stats().tightenLevel).toBe(0)
329
+ expect(fuse.stats().criticalPerChatTotalCeiling).toBe(20)
330
+ })
331
+
332
+ it('rejects an out-of-range tighten factor rather than silently clamping it', async () => {
333
+ const { editFloodFuseConfigFromEnv } = await import('../edit-flood-fuse.js')
334
+ for (const bad of ['0', '-1', '1.5', 'half', '']) {
335
+ expect(editFloodFuseConfigFromEnv({ SWITCHROOM_EDIT_FUSE_TIGHTEN_FACTOR: bad }).tightenFactor)
336
+ .toBeUndefined()
337
+ }
338
+ expect(editFloodFuseConfigFromEnv({ SWITCHROOM_EDIT_FUSE_TIGHTEN_FACTOR: '1' }).tightenFactor).toBe(1)
339
+ })
340
+ })
@@ -297,9 +297,18 @@ describe('edit-flood fuse — structural: installed where nothing can bypass it'
297
297
  expect(fuse.stats().dropped + fuse.stats().superseded).toBeGreaterThan(0)
298
298
  })
299
299
 
300
- it('passes non-message methods straight through (getUpdates must never be paced)', async () => {
300
+ // #3855: this test used to be titled "passes non-message methods straight
301
+ // through" and stood for the ALLOWLIST — everything outside EDIT/SEND was
302
+ // free, which left 29% of real traffic unmetered. The exemption is now a
303
+ // one-entry list and this test guards exactly that entry, not a category.
304
+ it('getUpdates — the long-poll RECEIVE loop — is the one unmetered method', async () => {
301
305
  const clock = new FakeClock()
302
- const fuse = createEditFloodFuse({ clock, perChatSendMaxPerWindow: 1, perChatWindowMs: 600_000 })
306
+ const fuse = createEditFloodFuse({
307
+ clock, perChatSendMaxPerWindow: 1, perChatWindowMs: 600_000,
308
+ // Deliberately brutal: if getUpdates were metered at all, the 2nd call
309
+ // would block forever on this clock.
310
+ perTokenMaxPerWindow: 1, perTokenWindowMs: 600_000,
311
+ })
303
312
  let calls = 0
304
313
  for (let i = 0; i < 100; i++) {
305
314
  await fuse.apply('getUpdates', {}, async () => { calls++; return [] })