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.
- package/dist/agent-scheduler/index.js +5 -2
- package/dist/auth-broker/index.js +129 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +796 -35
- package/dist/host-control/main.js +130 -9
- package/dist/vault/approvals/kernel-server.js +129 -8
- package/dist/vault/broker/server.js +129 -8
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +70 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +568 -49
- package/telegram-plugin/dist/server.js +1 -0
- package/telegram-plugin/edit-flood-fuse.ts +230 -27
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/gateway.ts +9 -2
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/operator-events.ts +38 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/settings.json +1 -1
- 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
|
+
})
|