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.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +60 -2
- package/dist/auth-broker/index.js +244 -13
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +60 -2
- package/dist/cli/switchroom.js +2843 -1220
- package/dist/host-control/main.js +245 -14
- package/dist/vault/approvals/kernel-server.js +242 -13
- package/dist/vault/broker/server.js +242 -13
- package/package.json +7 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +175 -15
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +94 -1
- package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
- package/telegram-plugin/dist/server.js +97 -1
- package/telegram-plugin/edit-flood-fuse.ts +841 -57
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +67 -70
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +110 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -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/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- 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/scripts/tests/test_recall_min_score.py +464 -0
- 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
|
+
})
|
|
@@ -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
|
-
|
|
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({
|
|
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 [] })
|