switchroom 0.19.27 → 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 (32) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +129 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +5 -2
  5. package/dist/cli/switchroom.js +796 -35
  6. package/dist/host-control/main.js +130 -9
  7. package/dist/vault/approvals/kernel-server.js +129 -8
  8. package/dist/vault/broker/server.js +129 -8
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +70 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +568 -49
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  27. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  28. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  31. package/vendor/hindsight-memory/settings.json +1 -1
  32. 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
+ })
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Outcome tests for #3891 — `finalizeCallback` must transit the ONE retry /
3
+ * flood policy, and a failed card repaint must not leave a live-looking
4
+ * keyboard.
5
+ *
6
+ * These are deliberately NOT unit tests of a helper. Each one drives the real
7
+ * `createRetryApiCall` + the real `makeFloodWaitRecorder` against a real temp
8
+ * state dir and asserts the observable artefact an operator or `switchroom
9
+ * doctor` would later read:
10
+ *
11
+ * - `429-ledger.json` gains an episode for a 429 earned by a BUTTON TAP.
12
+ * - `flood-wait.json` gains the window.
13
+ * - after a repaint that fails post-retry, the card's keyboard is gone
14
+ * (or, if it cannot be removed, the operator is told in-channel).
15
+ *
16
+ * Pre-fix (both legs calling `ctx.*` raw) every one of these fails: the
17
+ * ledger file is never created, and the keyboard survives untouched.
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
21
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import { GrammyError } from 'grammy'
25
+ import {
26
+ finalizeCallback,
27
+ DEAD_CARD_NOTICE,
28
+ type FinalizeCallbackContext,
29
+ type FinalizeApiCall,
30
+ } from '../inline-keyboard-callbacks.js'
31
+ import { createRetryApiCall } from '../retry-api-call.js'
32
+ import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
33
+ import {
34
+ readFlood429Ledger,
35
+ flood429LedgerPathFromFloodState,
36
+ } from '../flood-429-ledger.js'
37
+
38
+ /** Synthetic chat id — never a real operator chat (see check-no-pii-secrets). */
39
+ const CHAT_ID = -1009900112233
40
+ const MSG_ID = 4242
41
+
42
+ let stateDir: string
43
+ let floodStatePath: string
44
+
45
+ beforeEach(() => {
46
+ stateDir = mkdtempSync(join(tmpdir(), 'sr-3891-'))
47
+ floodStatePath = join(stateDir, 'flood-wait.json')
48
+ })
49
+ afterEach(() => {
50
+ rmSync(stateDir, { recursive: true, force: true })
51
+ })
52
+
53
+ /** A Telegram 429 exactly as grammy surfaces it. */
54
+ function flood429(retryAfter: number, method: string): GrammyError {
55
+ return new GrammyError(
56
+ `Call to '${method}' failed!`,
57
+ { ok: false, error_code: 429, description: 'Too Many Requests: retry after ' + retryAfter, parameters: { retry_after: retryAfter } },
58
+ method,
59
+ {},
60
+ )
61
+ }
62
+
63
+ /** A non-429, non-benign edit failure — the shape seen in the clerk incident. */
64
+ function hardEditFailure(method = 'editMessageText'): GrammyError {
65
+ return new GrammyError(
66
+ `Call to '${method}' failed!`,
67
+ { ok: false, error_code: 400, description: "Bad Request: can't parse entities" },
68
+ method,
69
+ {},
70
+ )
71
+ }
72
+
73
+ /**
74
+ * The production wiring, verbatim in shape: `createRetryApiCall` with BOTH
75
+ * breaker hooks, writing to a temp state dir. No sleeping (fake sleep) so the
76
+ * flood-wait retry path runs at test speed.
77
+ */
78
+ function realPolicy(): FinalizeApiCall {
79
+ const record = makeFloodWaitRecorder(floodStatePath)
80
+ const probe = makeFloodWaitProbe(floodStatePath)
81
+ return createRetryApiCall({
82
+ log: () => {},
83
+ sleep: async () => {},
84
+ onFloodWait: (retryAfterSec) => record(retryAfterSec),
85
+ floodWaitRemainingMs: probe,
86
+ })
87
+ }
88
+
89
+ interface Capture {
90
+ acks: number
91
+ edits: number
92
+ markupEdits: Array<Record<string, unknown>>
93
+ replies: string[]
94
+ }
95
+
96
+ function mkCtx(cap: Capture, behaviour: {
97
+ ack?: () => void
98
+ edit?: () => void
99
+ markup?: () => void
100
+ reply?: () => void
101
+ } = {}): FinalizeCallbackContext {
102
+ return {
103
+ answerCallbackQuery: async () => { cap.acks++; behaviour.ack?.(); return true },
104
+ editMessageText: async () => { cap.edits++; behaviour.edit?.(); return { message_id: MSG_ID } },
105
+ editMessageReplyMarkup: async (opts) => {
106
+ cap.markupEdits.push(opts ?? {})
107
+ behaviour.markup?.()
108
+ return true
109
+ },
110
+ reply: async (text) => { cap.replies.push(text); behaviour.reply?.(); return { message_id: MSG_ID + 1 } },
111
+ callbackQuery: { message: { message_id: MSG_ID, chat: { id: CHAT_ID } } },
112
+ }
113
+ }
114
+
115
+ const mkCap = (): Capture => ({ acks: 0, edits: 0, markupEdits: [], replies: [] })
116
+
117
+ describe('#3891 — tap-path 429s reach the flood ledger', () => {
118
+ it('records a 429 earned by answerCallbackQuery to the 429 ledger AND the window file', async () => {
119
+ const cap = mkCap()
120
+ let ackAttempts = 0
121
+ const ctx = mkCtx(cap, {
122
+ ack: () => { ackAttempts++; if (ackAttempts === 1) throw flood429(7, 'answerCallbackQuery') },
123
+ })
124
+
125
+ await finalizeCallback(ctx, {
126
+ ackText: 'Approved',
127
+ newText: 'resolved',
128
+ apiCall: realPolicy(),
129
+ log: () => {},
130
+ })
131
+ // The ack leg is fire-and-forget; let its retry chain settle.
132
+ await new Promise((r) => setTimeout(r, 0))
133
+
134
+ const ledgerPath = flood429LedgerPathFromFloodState(floodStatePath)
135
+ expect(existsSync(ledgerPath)).toBe(true)
136
+ const episodes = readFlood429Ledger(ledgerPath)
137
+ expect(episodes).toHaveLength(1)
138
+ expect(episodes[0]?.peakRetryAfterSec).toBe(7)
139
+ expect(episodes[0]?.count).toBe(1)
140
+ // The window file the send gate / doctor / watchdog all read.
141
+ expect(existsSync(floodStatePath)).toBe(true)
142
+ // And the tap still succeeded on retry — the policy is not a silencer.
143
+ expect(ackAttempts).toBe(2)
144
+ })
145
+
146
+ it('records a 429 earned by the card repaint, and the repaint still lands on retry', async () => {
147
+ const cap = mkCap()
148
+ let editAttempts = 0
149
+ const ctx = mkCtx(cap, {
150
+ edit: () => { editAttempts++; if (editAttempts === 1) throw flood429(11, 'editMessageText') },
151
+ })
152
+
153
+ await finalizeCallback(ctx, {
154
+ ackText: 'Approved',
155
+ newText: 'resolved',
156
+ apiCall: realPolicy(),
157
+ log: () => {},
158
+ })
159
+
160
+ const episodes = readFlood429Ledger(flood429LedgerPathFromFloodState(floodStatePath))
161
+ expect(episodes).toHaveLength(1)
162
+ expect(episodes[0]?.peakRetryAfterSec).toBe(11)
163
+ expect(editAttempts).toBe(2)
164
+ // Repaint landed on the retry, so no ladder rung was needed.
165
+ expect(cap.markupEdits).toHaveLength(0)
166
+ expect(cap.replies).toHaveLength(0)
167
+ })
168
+
169
+ it('scopes the flood window to the tapped chat, not `global`', async () => {
170
+ const seen: Array<{ chat_id?: string; verb?: string; priorityClass?: string }> = []
171
+ const apiCall: FinalizeApiCall = async (fn, opts) => {
172
+ seen.push({ chat_id: opts?.chat_id, verb: opts?.verb, priorityClass: opts?.priorityClass })
173
+ return fn()
174
+ }
175
+ const cap = mkCap()
176
+ await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'x', apiCall, log: () => {} })
177
+ await new Promise((r) => setTimeout(r, 0))
178
+
179
+ expect(seen).toHaveLength(2)
180
+ for (const s of seen) {
181
+ expect(s.chat_id).toBe(String(CHAT_ID))
182
+ // `critical` is the one class the send gate never sheds. A finalize
183
+ // repaint is the operator's ONLY confirmation their tap resolved a
184
+ // human-in-the-loop decision; shedding it is the incident itself.
185
+ expect(s.priorityClass).toBe('critical')
186
+ }
187
+ expect(seen.map((s) => s.verb)).toEqual(['answerCallbackQuery', 'editMessageText'])
188
+ })
189
+
190
+ it('survives a callbackQuery with no chat (degrades to an unscoped window, never throws)', async () => {
191
+ const seen: Array<string | undefined> = []
192
+ const apiCall: FinalizeApiCall = async (fn, opts) => { seen.push(opts?.chat_id); return fn() }
193
+ const cap = mkCap()
194
+ const ctx = { ...mkCtx(cap), callbackQuery: undefined }
195
+ await expect(
196
+ finalizeCallback(ctx, { ackText: 'ok', newText: 'x', apiCall, log: () => {} }),
197
+ ).resolves.toBeUndefined()
198
+ expect(seen.every((s) => s === undefined)).toBe(true)
199
+ })
200
+ })
201
+
202
+ describe('#3891 — a failed repaint never leaves a live-looking card', () => {
203
+ it('strips the keyboard when the body repaint fails post-retry', async () => {
204
+ const cap = mkCap()
205
+ const logs: string[] = []
206
+ const ctx = mkCtx(cap, { edit: () => { throw hardEditFailure() } })
207
+
208
+ await finalizeCallback(ctx, {
209
+ ackText: 'Approved',
210
+ newText: '**bad _markdown',
211
+ apiCall: realPolicy(),
212
+ log: (l) => logs.push(l),
213
+ })
214
+
215
+ // THE outcome: the card is no longer tappable.
216
+ expect(cap.markupEdits).toHaveLength(1)
217
+ expect(cap.markupEdits[0]?.reply_markup).toEqual({ inline_keyboard: [] })
218
+ // Not silent, either.
219
+ expect(logs.some((l) => l.includes('editMessageText failed'))).toBe(true)
220
+ expect(logs.some((l) => l.includes('no longer tappable'))).toBe(true)
221
+ // No need to shout in-channel — rung 2 fixed it.
222
+ expect(cap.replies).toHaveLength(0)
223
+ })
224
+
225
+ it('tells the operator in-channel when the keyboard cannot be stripped either', async () => {
226
+ const cap = mkCap()
227
+ const logs: string[] = []
228
+ const ctx = mkCtx(cap, {
229
+ edit: () => { throw hardEditFailure() },
230
+ markup: () => { throw hardEditFailure('editMessageReplyMarkup') },
231
+ })
232
+
233
+ await finalizeCallback(ctx, {
234
+ ackText: 'Approved',
235
+ newText: 'resolved',
236
+ apiCall: realPolicy(),
237
+ log: (l) => logs.push(l),
238
+ })
239
+
240
+ expect(cap.replies).toEqual([DEAD_CARD_NOTICE])
241
+ // The notice must name the buttons as stale — that is the whole point.
242
+ expect(DEAD_CARD_NOTICE).toContain('STALE')
243
+ })
244
+
245
+ it('a repaint failure does NOT block the model wake-up (invariant 3 holds)', async () => {
246
+ const cap = mkCap()
247
+ let synthFired = false
248
+ const ctx = mkCtx(cap, {
249
+ edit: () => { throw hardEditFailure() },
250
+ markup: () => { throw hardEditFailure('editMessageReplyMarkup') },
251
+ reply: () => { throw hardEditFailure('sendMessage') },
252
+ })
253
+
254
+ await finalizeCallback(ctx, {
255
+ ackText: 'Approved',
256
+ newText: 'resolved',
257
+ apiCall: realPolicy(),
258
+ synthInbound: () => { synthFired = true },
259
+ log: () => {},
260
+ })
261
+
262
+ expect(synthFired).toBe(true)
263
+ })
264
+
265
+ it('does NOT run the ladder when the repaint succeeds', async () => {
266
+ const cap = mkCap()
267
+ await finalizeCallback(mkCtx(cap), {
268
+ ackText: 'Approved',
269
+ newText: 'resolved',
270
+ apiCall: realPolicy(),
271
+ log: () => {},
272
+ })
273
+ expect(cap.edits).toBe(1)
274
+ expect(cap.markupEdits).toHaveLength(0)
275
+ expect(cap.replies).toHaveLength(0)
276
+ })
277
+
278
+ it('treats the two BENIGN edit failures as done — the retry policy swallows them, no ladder', async () => {
279
+ // MESSAGE_TO_EDIT_NOT_FOUND: the operator deleted the card. There is no
280
+ // keyboard left to disarm, so shouting about a stale card would be noise.
281
+ const cap = mkCap()
282
+ const ctx = mkCtx(cap, {
283
+ edit: () => {
284
+ throw new GrammyError(
285
+ "Call to 'editMessageText' failed!",
286
+ { ok: false, error_code: 400, description: 'Bad Request: message to edit not found' },
287
+ 'editMessageText',
288
+ {},
289
+ )
290
+ },
291
+ })
292
+ await finalizeCallback(ctx, {
293
+ ackText: 'Approved', newText: 'resolved', apiCall: realPolicy(), log: () => {},
294
+ })
295
+ expect(cap.markupEdits).toHaveLength(0)
296
+ expect(cap.replies).toHaveLength(0)
297
+ })
298
+ })