switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createSendGate, type Clock } from './send-gate.js'
|
|
3
|
+
import { isFloodWaitActiveError } from './retry-api-call.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic tests for #3084 PR 2 — priority shedding, degraded mode, and
|
|
7
|
+
* restart-proof flood windows (part3-design §2/§3/§7). Same fake-clock pattern
|
|
8
|
+
* as send-gate.test.ts; every assertion checks an OUTCOME (a send happened /
|
|
9
|
+
* was shed / expired / failed fast; a window survived a round-trip), not a
|
|
10
|
+
* code path.
|
|
11
|
+
*/
|
|
12
|
+
class FakeClock implements Clock {
|
|
13
|
+
private cur = 0
|
|
14
|
+
private seq = 0
|
|
15
|
+
private timers: { at: number; id: number; resolve: () => void }[] = []
|
|
16
|
+
|
|
17
|
+
now(): number {
|
|
18
|
+
return this.cur
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
sleep(ms: number): Promise<void> {
|
|
22
|
+
return new Promise<void>((resolve) => {
|
|
23
|
+
this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async advance(ms: number): Promise<void> {
|
|
28
|
+
// Flush first so any async chain that hasn't yet registered its next sleep
|
|
29
|
+
// (e.g. a critical send suspended on the serialize mutex's microtask) gets
|
|
30
|
+
// a chance to register before we walk time forward.
|
|
31
|
+
await flush()
|
|
32
|
+
const target = this.cur + ms
|
|
33
|
+
for (;;) {
|
|
34
|
+
const due = this.timers
|
|
35
|
+
.filter((t) => t.at <= target)
|
|
36
|
+
.sort((a, b) => a.at - b.at || a.id - b.id)
|
|
37
|
+
if (due.length === 0) break
|
|
38
|
+
const t = due[0]
|
|
39
|
+
this.timers = this.timers.filter((x) => x !== t)
|
|
40
|
+
this.cur = t.at
|
|
41
|
+
t.resolve()
|
|
42
|
+
await flush()
|
|
43
|
+
}
|
|
44
|
+
this.cur = target
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function flush(): Promise<void> {
|
|
49
|
+
return new Promise<void>((r) => setImmediate(r))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function recorder(clock: FakeClock) {
|
|
53
|
+
const calls: { label: string; at: number }[] = []
|
|
54
|
+
const fn = (label: string) => async () => {
|
|
55
|
+
calls.push({ label, at: clock.now() })
|
|
56
|
+
return label
|
|
57
|
+
}
|
|
58
|
+
return { calls, fn }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A future window well beyond the fail-fast ceiling.
|
|
62
|
+
const HOUR = 3_600_000
|
|
63
|
+
|
|
64
|
+
describe('send-gate PR2: cosmetic shedding', () => {
|
|
65
|
+
it('sheds a cosmetic send when a flood window covers its scope', async () => {
|
|
66
|
+
const clock = new FakeClock()
|
|
67
|
+
const { calls, fn } = recorder(clock)
|
|
68
|
+
const gate = createSendGate({
|
|
69
|
+
enabled: true,
|
|
70
|
+
clock,
|
|
71
|
+
initialWindows: [{ scopeKey: 'global', untilTs: HOUR }],
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const res = await gate.gate(fn('cosmetic'), { priorityClass: 'cosmetic' })
|
|
75
|
+
|
|
76
|
+
// Shed resolves undefined; fn never ran; the shed counter moved.
|
|
77
|
+
expect(res).toBeUndefined()
|
|
78
|
+
expect(calls).toHaveLength(0)
|
|
79
|
+
expect(gate.stats().global.shed).toBe(1)
|
|
80
|
+
expect(gate.stats().global.sent).toBe(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('sheds a cosmetic send when no token is immediately free', async () => {
|
|
84
|
+
const clock = new FakeClock()
|
|
85
|
+
const { calls, fn } = recorder(clock)
|
|
86
|
+
// Per-chat bucket burst 1, so the 2nd immediate cosmetic has no token.
|
|
87
|
+
const gate = createSendGate({
|
|
88
|
+
enabled: true,
|
|
89
|
+
clock,
|
|
90
|
+
perChatBurst: 1,
|
|
91
|
+
perChatPerSec: 1,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await gate.gate(fn('a'), { chat_id: '5', priorityClass: 'cosmetic' })
|
|
95
|
+
const shed = await gate.gate(fn('b'), { chat_id: '5', priorityClass: 'cosmetic' })
|
|
96
|
+
|
|
97
|
+
expect(calls.map((c) => c.label)).toEqual(['a'])
|
|
98
|
+
expect(shed).toBeUndefined()
|
|
99
|
+
expect(gate.stats().global.shed).toBe(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('does NOT shed a cosmetic send when a token is free and no window is open', async () => {
|
|
103
|
+
const clock = new FakeClock()
|
|
104
|
+
const { calls, fn } = recorder(clock)
|
|
105
|
+
const gate = createSendGate({ enabled: true, clock })
|
|
106
|
+
|
|
107
|
+
await gate.gate(fn('a'), { priorityClass: 'cosmetic' })
|
|
108
|
+
|
|
109
|
+
expect(calls.map((c) => c.label)).toEqual(['a'])
|
|
110
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
111
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('sheds a cosmetic EDIT while the message-edit window is open', async () => {
|
|
115
|
+
const clock = new FakeClock()
|
|
116
|
+
const { calls, fn } = recorder(clock)
|
|
117
|
+
const gate = createSendGate({
|
|
118
|
+
enabled: true,
|
|
119
|
+
clock,
|
|
120
|
+
// H1: msg-edit scope is keyed `${chat_id}:${messageId}`.
|
|
121
|
+
initialWindows: [{ scopeKey: 'msg-edit:5:42', untilTs: HOUR }],
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const res = await gate.gate(fn('edit'), {
|
|
125
|
+
chat_id: '5',
|
|
126
|
+
messageId: 42,
|
|
127
|
+
editPayload: 'v1',
|
|
128
|
+
priorityClass: 'cosmetic',
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(res).toBeUndefined()
|
|
132
|
+
expect(calls).toHaveLength(0)
|
|
133
|
+
expect(gate.stats().global.shed).toBe(1)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('send-gate PR2: useful TTL expiry', () => {
|
|
138
|
+
it('drops a useful send that cannot be admitted before its TTL', async () => {
|
|
139
|
+
const clock = new FakeClock()
|
|
140
|
+
const { calls, fn } = recorder(clock)
|
|
141
|
+
// Global bucket blocked by a 5-minute window; useful TTL is 120s < window.
|
|
142
|
+
const gate = createSendGate({
|
|
143
|
+
enabled: true,
|
|
144
|
+
clock,
|
|
145
|
+
usefulTtlMs: 120_000,
|
|
146
|
+
initialWindows: [{ scopeKey: 'global', untilTs: 300_000 }],
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const p = gate.gate(fn('useful'), { priorityClass: 'useful' })
|
|
150
|
+
// Walk past the TTL but not past the window.
|
|
151
|
+
await clock.advance(130_000)
|
|
152
|
+
const res = await p
|
|
153
|
+
|
|
154
|
+
expect(res).toBeUndefined()
|
|
155
|
+
expect(calls).toHaveLength(0)
|
|
156
|
+
expect(gate.stats().global.expired).toBe(1)
|
|
157
|
+
expect(gate.stats().global.sent).toBe(0)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('admits a useful send that clears before its TTL', async () => {
|
|
161
|
+
const clock = new FakeClock()
|
|
162
|
+
const { calls, fn } = recorder(clock)
|
|
163
|
+
// Window clears at 30s, inside the 120s useful TTL.
|
|
164
|
+
const gate = createSendGate({
|
|
165
|
+
enabled: true,
|
|
166
|
+
clock,
|
|
167
|
+
usefulTtlMs: 120_000,
|
|
168
|
+
initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const p = gate.gate(fn('useful'), { priorityClass: 'useful' })
|
|
172
|
+
await clock.advance(31_000)
|
|
173
|
+
const res = await p
|
|
174
|
+
|
|
175
|
+
expect(res).toBe('useful')
|
|
176
|
+
expect(calls.map((c) => c.label)).toEqual(['useful'])
|
|
177
|
+
expect(gate.stats().global.expired).toBe(0)
|
|
178
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('send-gate PR2: critical degraded mode', () => {
|
|
183
|
+
it('fails fast with a structured FLOOD_WAIT_ACTIVE when the window exceeds the ceiling', async () => {
|
|
184
|
+
const clock = new FakeClock()
|
|
185
|
+
const { calls, fn } = recorder(clock)
|
|
186
|
+
const gate = createSendGate({
|
|
187
|
+
enabled: true,
|
|
188
|
+
clock,
|
|
189
|
+
criticalFailFastMs: 60_000,
|
|
190
|
+
// 10-minute window >> 60s ceiling.
|
|
191
|
+
initialWindows: [{ scopeKey: 'global', untilTs: 600_000 }],
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
let caught: unknown
|
|
195
|
+
try {
|
|
196
|
+
await gate.gate(fn('critical'), { priorityClass: 'critical' })
|
|
197
|
+
} catch (err) {
|
|
198
|
+
caught = err
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
expect(isFloodWaitActiveError(caught)).toBe(true)
|
|
202
|
+
const e = caught as { untilTs: number; retryAfterSec: number; error_code: number }
|
|
203
|
+
expect(e.error_code).toBe(429)
|
|
204
|
+
expect(e.untilTs).toBe(600_000)
|
|
205
|
+
expect(e.retryAfterSec).toBe(600)
|
|
206
|
+
expect(calls).toHaveLength(0) // never probed the API
|
|
207
|
+
expect(gate.stats().global.failedFast).toBe(1)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('waits out a SHORT window (<= ceiling) then sends the critical, single in-flight with jitter', async () => {
|
|
211
|
+
const clock = new FakeClock()
|
|
212
|
+
const { calls, fn } = recorder(clock)
|
|
213
|
+
const gate = createSendGate({
|
|
214
|
+
enabled: true,
|
|
215
|
+
clock,
|
|
216
|
+
criticalFailFastMs: 60_000,
|
|
217
|
+
criticalJitterMaxMs: 100,
|
|
218
|
+
jitter: () => 0.5, // deterministic 50ms jitter
|
|
219
|
+
// 30s window, under the 60s fail-fast ceiling.
|
|
220
|
+
initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const p = gate.gate(fn('critical'), { priorityClass: 'critical' })
|
|
224
|
+
await clock.advance(31_000)
|
|
225
|
+
const res = await p
|
|
226
|
+
|
|
227
|
+
expect(res).toBe('critical')
|
|
228
|
+
expect(calls).toHaveLength(1)
|
|
229
|
+
// Sent AFTER the window cleared (>= 30s), never during it.
|
|
230
|
+
expect(calls[0]!.at).toBeGreaterThanOrEqual(30_000)
|
|
231
|
+
expect(gate.stats().global.failedFast).toBe(0)
|
|
232
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('never sheds a critical send under mere token pressure (no window)', async () => {
|
|
236
|
+
const clock = new FakeClock()
|
|
237
|
+
const { calls, fn } = recorder(clock)
|
|
238
|
+
const gate = createSendGate({
|
|
239
|
+
enabled: true,
|
|
240
|
+
clock,
|
|
241
|
+
perChatBurst: 1,
|
|
242
|
+
perChatPerSec: 1,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
await gate.gate(fn('a'), { chat_id: '9', priorityClass: 'critical' })
|
|
246
|
+
const p = gate.gate(fn('b'), { chat_id: '9', priorityClass: 'critical' })
|
|
247
|
+
await clock.advance(1000) // one token refills
|
|
248
|
+
const res = await p
|
|
249
|
+
|
|
250
|
+
expect(res).toBe('b')
|
|
251
|
+
expect(calls.map((c) => c.label)).toEqual(['a', 'b'])
|
|
252
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('send-gate PR2: flag-off passthrough of the new machinery', () => {
|
|
257
|
+
it('never sheds / expires / fails fast when disabled, even with an open window', async () => {
|
|
258
|
+
const clock = new FakeClock()
|
|
259
|
+
const { calls, fn } = recorder(clock)
|
|
260
|
+
const gate = createSendGate({
|
|
261
|
+
enabled: false,
|
|
262
|
+
clock,
|
|
263
|
+
initialWindows: [{ scopeKey: 'global', untilTs: HOUR }],
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Cosmetic (would shed), useful (would risk TTL), critical (would fail fast)
|
|
267
|
+
// — all must run immediately at t=0 with zero counter movement.
|
|
268
|
+
const c = await gate.gate(fn('cosmetic'), { priorityClass: 'cosmetic' })
|
|
269
|
+
const u = await gate.gate(fn('useful'), { priorityClass: 'useful' })
|
|
270
|
+
const cr = await gate.gate(fn('critical'), { priorityClass: 'critical' })
|
|
271
|
+
|
|
272
|
+
expect([c, u, cr]).toEqual(['cosmetic', 'useful', 'critical'])
|
|
273
|
+
expect(calls.every((x) => x.at === 0)).toBe(true)
|
|
274
|
+
const s = gate.stats().global
|
|
275
|
+
expect([s.shed, s.expired, s.failedFast, s.sent]).toEqual([0, 0, 0, 0])
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('send-gate PR2: edit call-site wiring (progress/answer-stream shape)', () => {
|
|
280
|
+
it('coalesces rapid cosmetic edits to one message into a single last-write-wins send', async () => {
|
|
281
|
+
const clock = new FakeClock()
|
|
282
|
+
const { calls, fn } = recorder(clock)
|
|
283
|
+
// Buckets ample; behaviour driven by the per-message edit floor.
|
|
284
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
285
|
+
|
|
286
|
+
// v1 sends immediately (cold message). v2, v3 arrive within the floor and
|
|
287
|
+
// coalesce — only the LAST (v3) is sent when the floor clears. This is the
|
|
288
|
+
// exact shape the wired progress-card / answer-stream edit sites produce:
|
|
289
|
+
// messageId + editPayload + priorityClass:'cosmetic'.
|
|
290
|
+
const p1 = gate.gate(fn('v1'), { messageId: 99, editPayload: 'v1', priorityClass: 'cosmetic' })
|
|
291
|
+
await flush()
|
|
292
|
+
const p2 = gate.gate(fn('v2'), { messageId: 99, editPayload: 'v2', priorityClass: 'cosmetic' })
|
|
293
|
+
const p3 = gate.gate(fn('v3'), { messageId: 99, editPayload: 'v3', priorityClass: 'cosmetic' })
|
|
294
|
+
await clock.advance(1600)
|
|
295
|
+
await Promise.all([p1, p2, p3])
|
|
296
|
+
|
|
297
|
+
// Two API calls total: v1 (cold) and v3 (coalesced winner). v2 dropped.
|
|
298
|
+
expect(calls.map((c) => c.label)).toEqual(['v1', 'v3'])
|
|
299
|
+
expect(gate.stats().global.coalesced).toBe(1)
|
|
300
|
+
expect(gate.stats().global.sent).toBe(2)
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe('send-gate PR2: H1 cross-chat message_id isolation', () => {
|
|
305
|
+
it('two chats sharing message_id 100 do NOT collide — both send, no edit lost', async () => {
|
|
306
|
+
const clock = new FakeClock()
|
|
307
|
+
const { calls, fn } = recorder(clock)
|
|
308
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
309
|
+
|
|
310
|
+
// Chat A: v1 (cold → sends now), then v2 within the floor → queued for A.
|
|
311
|
+
const pA1 = gate.gate(fn('A-v1'), { chat_id: 'A', messageId: 100, editPayload: 'A-v1' })
|
|
312
|
+
await flush()
|
|
313
|
+
const pA2 = gate.gate(fn('A-v2'), { chat_id: 'A', messageId: 100, editPayload: 'A-v2' })
|
|
314
|
+
// Chat B edits ITS OWN card, also message_id 100 — must be a SEPARATE state.
|
|
315
|
+
const pB1 = gate.gate(fn('B-v1'), { chat_id: 'B', messageId: 100, editPayload: 'B-v1' })
|
|
316
|
+
|
|
317
|
+
await clock.advance(1600)
|
|
318
|
+
await Promise.all([pA1, pA2, pB1])
|
|
319
|
+
|
|
320
|
+
// With the id-only bug, B-v1 overwrote A's pending (A-v2) → A-v2 silently
|
|
321
|
+
// dropped. Keyed by chat_id+messageId, all three survive.
|
|
322
|
+
expect(calls.map((c) => c.label).sort()).toEqual(['A-v1', 'A-v2', 'B-v1'])
|
|
323
|
+
expect(gate.stats().global.sent).toBe(3)
|
|
324
|
+
expect(gate.stats().global.dropped).toBe(0)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('chat B cold edit is NOT floored by chat A holding the same message_id', async () => {
|
|
328
|
+
const clock = new FakeClock()
|
|
329
|
+
const { calls, fn } = recorder(clock)
|
|
330
|
+
const gate = createSendGate({ enabled: true, clock, editFloorMs: 1500 })
|
|
331
|
+
|
|
332
|
+
// A sends (starts A's floor). B's first edit to the same id must fire at
|
|
333
|
+
// t=0 — B has its own floor, not A's.
|
|
334
|
+
const pA = gate.gate(fn('A'), { chat_id: 'A', messageId: 100, editPayload: 'A' })
|
|
335
|
+
await flush()
|
|
336
|
+
const pB = gate.gate(fn('B'), { chat_id: 'B', messageId: 100, editPayload: 'B' })
|
|
337
|
+
await flush()
|
|
338
|
+
await Promise.all([pA, pB])
|
|
339
|
+
|
|
340
|
+
expect(calls.map((c) => c.label).sort()).toEqual(['A', 'B'])
|
|
341
|
+
expect(calls.every((c) => c.at === 0)).toBe(true)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('a 429 on chat A message 100 does NOT shed chat B message 100 cosmetic edits', async () => {
|
|
345
|
+
const clock = new FakeClock()
|
|
346
|
+
const { calls, fn } = recorder(clock)
|
|
347
|
+
const gate = createSendGate({
|
|
348
|
+
enabled: true,
|
|
349
|
+
clock,
|
|
350
|
+
// Only chat A's msg-edit scope is under a flood window.
|
|
351
|
+
initialWindows: [{ scopeKey: 'msg-edit:A:100', untilTs: HOUR }],
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// Chat A cosmetic edit → shed (its scope window is open).
|
|
355
|
+
const rA = await gate.gate(fn('A-edit'), {
|
|
356
|
+
chat_id: 'A',
|
|
357
|
+
messageId: 100,
|
|
358
|
+
editPayload: 'A-edit',
|
|
359
|
+
priorityClass: 'cosmetic',
|
|
360
|
+
})
|
|
361
|
+
// Chat B cosmetic edit to the SAME message_id → must NOT shed (different scope).
|
|
362
|
+
const rB = await gate.gate(fn('B-edit'), {
|
|
363
|
+
chat_id: 'B',
|
|
364
|
+
messageId: 100,
|
|
365
|
+
editPayload: 'B-edit',
|
|
366
|
+
priorityClass: 'cosmetic',
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
expect(rA).toBeUndefined() // A shed
|
|
370
|
+
expect(rB).toBe('B-edit') // B sent
|
|
371
|
+
expect(calls.map((c) => c.label)).toEqual(['B-edit'])
|
|
372
|
+
expect(gate.stats().global.shed).toBe(1)
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
describe('send-gate PR2: M2 critical wait re-evaluates the window each iteration', () => {
|
|
377
|
+
it('a window extended past the ceiling MID-WAIT converts a critical to fail-fast, not a hang', async () => {
|
|
378
|
+
const clock = new FakeClock()
|
|
379
|
+
const { calls, fn } = recorder(clock)
|
|
380
|
+
const gate = createSendGate({
|
|
381
|
+
enabled: true,
|
|
382
|
+
clock,
|
|
383
|
+
criticalFailFastMs: 60_000,
|
|
384
|
+
jitter: () => 0, // deterministic, no jitter sleep
|
|
385
|
+
// A SHORT 30s window (<= ceiling) → the critical waits it out.
|
|
386
|
+
initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// Attach the rejection handler synchronously (before advancing time) so the
|
|
390
|
+
// eventual fail-fast rejection is never an unhandled rejection.
|
|
391
|
+
const p = gate.gate(fn('critical'), { priorityClass: 'critical' }).catch((e) => e)
|
|
392
|
+
await flush()
|
|
393
|
+
// While it waits, a competing 429 EXTENDS the global window to +10min.
|
|
394
|
+
gate.openFloodWindow('global', 600_000)
|
|
395
|
+
// Walk past the ORIGINAL short window; the loop re-checks the ceiling and,
|
|
396
|
+
// seeing the extended window, fails fast instead of blocking ~10 minutes.
|
|
397
|
+
await clock.advance(31_000)
|
|
398
|
+
|
|
399
|
+
const caught = await p
|
|
400
|
+
expect(isFloodWaitActiveError(caught)).toBe(true)
|
|
401
|
+
expect((caught as { untilTs: number }).untilTs).toBe(600_000)
|
|
402
|
+
expect(calls).toHaveLength(0) // never probed the API
|
|
403
|
+
expect(gate.stats().global.failedFast).toBe(1)
|
|
404
|
+
expect(gate.stats().global.sent).toBe(0)
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
describe('send-gate F4: a CRITICAL edit honours the fail-fast ceiling (no unbounded block)', () => {
|
|
409
|
+
it('fails fast with a structured FLOOD_WAIT_ACTIVE instead of blocking unbounded on the edit driver', async () => {
|
|
410
|
+
const clock = new FakeClock()
|
|
411
|
+
const { calls, fn } = recorder(clock)
|
|
412
|
+
const gate = createSendGate({
|
|
413
|
+
enabled: true,
|
|
414
|
+
clock,
|
|
415
|
+
criticalFailFastMs: 60_000,
|
|
416
|
+
// 10-minute ban >> the 60s ceiling. On the pre-fix edit path this window
|
|
417
|
+
// routed a `critical` card edit through the driver's UNBOUNDED admit and
|
|
418
|
+
// blocked for the whole ban; the fix must fail fast like the non-edit path.
|
|
419
|
+
initialWindows: [{ scopeKey: 'global', untilTs: 600_000 }],
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
let caught: unknown
|
|
423
|
+
try {
|
|
424
|
+
// messageId + editPayload → edit path; priorityClass:'critical' is the case
|
|
425
|
+
// handleEdit previously ignored (only 'cosmetic' was special-cased).
|
|
426
|
+
await gate.gate(fn('critical-edit'), {
|
|
427
|
+
messageId: 42,
|
|
428
|
+
editPayload: 'v1',
|
|
429
|
+
priorityClass: 'critical',
|
|
430
|
+
})
|
|
431
|
+
} catch (err) {
|
|
432
|
+
caught = err
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Fail fast (structured flood-wait), NOT an unbounded block that never
|
|
436
|
+
// settles — the multi-hour reply wedge the send-gate exists to eliminate.
|
|
437
|
+
expect(isFloodWaitActiveError(caught)).toBe(true)
|
|
438
|
+
const e = caught as { untilTs: number; retryAfterSec: number; error_code: number }
|
|
439
|
+
expect(e.error_code).toBe(429)
|
|
440
|
+
expect(e.untilTs).toBe(600_000)
|
|
441
|
+
expect(e.retryAfterSec).toBe(600)
|
|
442
|
+
expect(calls).toHaveLength(0) // never probed the API
|
|
443
|
+
expect(gate.stats().global.failedFast).toBe(1)
|
|
444
|
+
expect(gate.stats().global.sent).toBe(0)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('waits out a SHORT window (<= ceiling) then sends the critical edit — mirrors the non-edit path', async () => {
|
|
448
|
+
const clock = new FakeClock()
|
|
449
|
+
const { calls, fn } = recorder(clock)
|
|
450
|
+
const gate = createSendGate({
|
|
451
|
+
enabled: true,
|
|
452
|
+
clock,
|
|
453
|
+
criticalFailFastMs: 60_000,
|
|
454
|
+
// 30s window, under the 60s ceiling → the critical edit waits it out and
|
|
455
|
+
// then sends (not shed, not failed fast).
|
|
456
|
+
initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const p = gate.gate(fn('critical-edit'), {
|
|
460
|
+
messageId: 7,
|
|
461
|
+
editPayload: 'v1',
|
|
462
|
+
priorityClass: 'critical',
|
|
463
|
+
})
|
|
464
|
+
await clock.advance(31_000)
|
|
465
|
+
const res = await p
|
|
466
|
+
|
|
467
|
+
expect(res).toBe('critical-edit')
|
|
468
|
+
expect(calls).toHaveLength(1)
|
|
469
|
+
expect(calls[0]!.at).toBeGreaterThanOrEqual(30_000)
|
|
470
|
+
expect(gate.stats().global.failedFast).toBe(0)
|
|
471
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
describe('send-gate F2: a CRITICAL edit coalescing onto a non-critical driver still fail-fasts', () => {
|
|
476
|
+
it('a critical edit that coalesces onto a running useful driver rejects FLOOD_WAIT_ACTIVE (does NOT ride the unbounded admit)', async () => {
|
|
477
|
+
const clock = new FakeClock()
|
|
478
|
+
const { calls, fn } = recorder(clock)
|
|
479
|
+
const gate = createSendGate({
|
|
480
|
+
enabled: true,
|
|
481
|
+
clock,
|
|
482
|
+
criticalFailFastMs: 60_000,
|
|
483
|
+
// Large edit floor so a queued useful edit SITS in the driver's floor
|
|
484
|
+
// sleep long enough for a later critical edit to coalesce onto it BEFORE
|
|
485
|
+
// the driver dequeues — the exact race the fix closes.
|
|
486
|
+
editFloorMs: 10_000,
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
// 1. Warm the message so its edit floor is armed (a cold message would fire
|
|
490
|
+
// the first edit at t=0 with no floor sleep, leaving no window to coalesce
|
|
491
|
+
// into). This send happens BEFORE any ban.
|
|
492
|
+
const pWarm = gate.gate(fn('warm'), { messageId: 42, editPayload: 'warm' })
|
|
493
|
+
await flush()
|
|
494
|
+
await pWarm
|
|
495
|
+
expect(calls.map((c) => c.label)).toEqual(['warm'])
|
|
496
|
+
|
|
497
|
+
// 2. A 10-minute flood ban (>> the 60s ceiling) opens on the global scope.
|
|
498
|
+
gate.openFloodWindow('global', 600_000)
|
|
499
|
+
|
|
500
|
+
// 3. A USEFUL edit to the same message starts the driver and enters the
|
|
501
|
+
// floor sleep (floor not yet cleared). It is NOT critical, so the
|
|
502
|
+
// driver-start opts capture priorityClass='useful'.
|
|
503
|
+
const pUseful = gate
|
|
504
|
+
.gate(fn('useful'), { messageId: 42, editPayload: 'u', priorityClass: 'useful' })
|
|
505
|
+
.catch((e) => e)
|
|
506
|
+
await flush()
|
|
507
|
+
|
|
508
|
+
// 4. A CRITICAL edit to the SAME message arrives mid-ban and COALESCES onto
|
|
509
|
+
// the pending useful edit (shared promise). With the driver-start-opts
|
|
510
|
+
// bug this critical work rode the unbounded admit and blocked for the
|
|
511
|
+
// whole 10-minute ban; the fix upgrades the pending edit's priority so
|
|
512
|
+
// the driver reads `critical` and fails fast.
|
|
513
|
+
const pCritical = gate
|
|
514
|
+
.gate(fn('critical'), { messageId: 42, editPayload: 'c', priorityClass: 'critical' })
|
|
515
|
+
.catch((e) => e)
|
|
516
|
+
await flush()
|
|
517
|
+
|
|
518
|
+
// 5. Walk PAST the edit floor but NOT past the ban. On the fixed code the
|
|
519
|
+
// driver wakes, sees the coalesced critical class, and fails fast.
|
|
520
|
+
await clock.advance(10_001)
|
|
521
|
+
|
|
522
|
+
const caughtCritical = await pCritical
|
|
523
|
+
const caughtUseful = await pUseful
|
|
524
|
+
|
|
525
|
+
// The coalesced (shared) promise rejects with a structured flood-wait —
|
|
526
|
+
// it did NOT block unbounded for the remaining ~10 minutes of the ban.
|
|
527
|
+
expect(isFloodWaitActiveError(caughtCritical)).toBe(true)
|
|
528
|
+
expect(isFloodWaitActiveError(caughtUseful)).toBe(true)
|
|
529
|
+
const e = caughtCritical as { untilTs: number; retryAfterSec: number; error_code: number }
|
|
530
|
+
expect(e.error_code).toBe(429)
|
|
531
|
+
expect(e.untilTs).toBe(600_000)
|
|
532
|
+
// Only the warm edit ever probed the API; the coalesced edit never did.
|
|
533
|
+
expect(calls.map((c) => c.label)).toEqual(['warm'])
|
|
534
|
+
expect(gate.stats().global.failedFast).toBe(1)
|
|
535
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
describe('send-gate PR2: opening a window from a 429, and persistence hook', () => {
|
|
540
|
+
it('opens scope windows via onWindowOpen when a non-edit send throws FLOOD_WAIT_ACTIVE', async () => {
|
|
541
|
+
const clock = new FakeClock()
|
|
542
|
+
const opened: { scopeKey: string; untilTs: number }[] = []
|
|
543
|
+
const gate = createSendGate({
|
|
544
|
+
enabled: true,
|
|
545
|
+
clock,
|
|
546
|
+
onWindowOpen: (scopeKey, untilTs) => opened.push({ scopeKey, untilTs }),
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
const floodErr = Object.assign(new Error('FLOOD_WAIT_ACTIVE'), {
|
|
550
|
+
retryAfterSec: 100,
|
|
551
|
+
untilTs: 100_000,
|
|
552
|
+
error_code: 429 as const,
|
|
553
|
+
parameters: { retry_after: 100 },
|
|
554
|
+
original: null,
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
await expect(
|
|
558
|
+
gate.gate(
|
|
559
|
+
async () => {
|
|
560
|
+
throw floodErr
|
|
561
|
+
},
|
|
562
|
+
{ chat_id: '7', chatType: 'supergroup', messageId: 3, priorityClass: 'critical' },
|
|
563
|
+
),
|
|
564
|
+
).rejects.toBe(floodErr)
|
|
565
|
+
|
|
566
|
+
const scopes = opened.map((o) => o.scopeKey).sort()
|
|
567
|
+
// H1: msg-edit scope carries the chat_id (msg-edit:7:3), not the bare id.
|
|
568
|
+
expect(scopes).toEqual(['chat:7', 'global', 'group:7', 'msg-edit:7:3'])
|
|
569
|
+
// After the window opens, a later cosmetic on the same chat sheds.
|
|
570
|
+
const shed = await gate.gate(async () => 'x', { chat_id: '7', priorityClass: 'cosmetic' })
|
|
571
|
+
expect(shed).toBeUndefined()
|
|
572
|
+
expect(gate.stats().global.shed).toBe(1)
|
|
573
|
+
})
|
|
574
|
+
})
|