switchroom 0.19.26 → 0.19.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,418 @@
1
+ /**
2
+ * A user-facing reply refused by an open Telegram flood window must be
3
+ * DURABLY QUEUED and delivered when the window closes — never discarded
4
+ * (#3861).
5
+ *
6
+ * ── The bug these tests guard ────────────────────────────────────────────
7
+ * Reproduced live on 2026-07-28 during overlord's flood ban. The reply tool
8
+ * returned:
9
+ *
10
+ * reply failed after 0 of 1 chunk(s) sent: FLOOD_WAIT_ACTIVE
11
+ *
12
+ * and `/host-home/.switchroom/agents/overlord/telegram/outbox/` contained NO
13
+ * record for it. `FLOOD_WAIT_ACTIVE` is a purely LOCAL pre-call fail-fast
14
+ * (`retry-api-call.ts:198`, `:252`) that propagated to the caller as an error;
15
+ * nothing enqueued the composed answer, so it was lost. The durable-outbox
16
+ * work-loss guarantee held for the outbox-delivered paths and was FALSE for
17
+ * the interactive reply path — during a 4.4h ban that means every answer the
18
+ * agent wrote for the operator.
19
+ *
20
+ * The bar these tests meet (set by the audit, verbatim): "a test that opens a
21
+ * flood window, issues a user-facing send, and asserts the content is durably
22
+ * queued and delivered after the window closes — not merely that an error was
23
+ * returned." `end-to-end` below does exactly that, driving the REAL
24
+ * `sweepOutbox` with the REAL persisted breaker probe.
25
+ */
26
+
27
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
28
+ import { mkdtempSync, rmSync, readFileSync, readdirSync } from 'node:fs'
29
+ import { tmpdir } from 'node:os'
30
+ import { join } from 'node:path'
31
+
32
+ import {
33
+ queueFloodBlockedReply,
34
+ isFloodRejection,
35
+ floodRetryAfterSec,
36
+ floodQueueNonce,
37
+ FLOOD_QUEUED_SOURCE,
38
+ } from '../gateway/flood-reply-queue.js'
39
+ import { sweepOutbox } from '../gateway/outbox-sweep.js'
40
+ import { listPendingRecords, resolveOutboxDir, type OutboxRecord } from '../outbox.js'
41
+ import {
42
+ floodStatePath,
43
+ makeFloodWaitProbe,
44
+ writeFloodState,
45
+ } from '../flood-circuit-breaker.js'
46
+ import { GrammyError } from 'grammy'
47
+
48
+ /** The exact error `retryApiCall` throws for an over-ceiling flood window. */
49
+ function floodWaitActiveError(retryAfterSec: number): Error {
50
+ return Object.assign(new Error('FLOOD_WAIT_ACTIVE'), {
51
+ retryAfterSec,
52
+ untilTs: Date.now() + retryAfterSec * 1000,
53
+ error_code: 429 as const,
54
+ parameters: { retry_after: retryAfterSec },
55
+ })
56
+ }
57
+
58
+ /** The reply path's own partial-failure wrapper around the marker. */
59
+ function wrappedReplyFailure(): Error {
60
+ return new Error('reply failed after 0 of 1 chunk(s) sent: FLOOD_WAIT_ACTIVE')
61
+ }
62
+
63
+ function readRecords(dir: string): OutboxRecord[] {
64
+ const outbox = resolveOutboxDir(dir)
65
+ return readdirSync(outbox)
66
+ .filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl' && !f.startsWith('.'))
67
+ .map((f) => JSON.parse(readFileSync(join(outbox, f), 'utf8')) as OutboxRecord)
68
+ }
69
+
70
+ describe('isFloodRejection', () => {
71
+ it('recognises every shape the flood rejection actually arrives in', () => {
72
+ // The marker itself, as thrown by retryApiCall.
73
+ expect(isFloodRejection(floodWaitActiveError(15908))).toBe(true)
74
+ // The reply path's partial-failure wrapper — a plain Error whose message
75
+ // merely CONTAINS the marker. This is the shape that reached the operator.
76
+ expect(isFloodRejection(wrappedReplyFailure())).toBe(true)
77
+ // A raw Telegram 429 that was never wrapped.
78
+ expect(
79
+ isFloodRejection(
80
+ new GrammyError(
81
+ 'Call to sendMessage failed!',
82
+ { ok: false, error_code: 429, description: 'Too Many Requests: retry after 30', parameters: { retry_after: 30 } },
83
+ 'sendMessage',
84
+ {},
85
+ ),
86
+ ),
87
+ ).toBe(true)
88
+ // A duck-typed 429 with no message at all.
89
+ expect(isFloodRejection({ error_code: 429 })).toBe(true)
90
+ })
91
+
92
+ it('does NOT swallow non-flood failures — those must still throw', () => {
93
+ expect(isFloodRejection(new Error('THREAD_NOT_FOUND'))).toBe(false)
94
+ expect(isFloodRejection(new Error('message is not modified'))).toBe(false)
95
+ expect(isFloodRejection(new Error('retryApiCall: max retries exceeded'))).toBe(false)
96
+ expect(isFloodRejection(new Error('ENOSPC: no space left on device'))).toBe(false)
97
+ expect(isFloodRejection(null)).toBe(false)
98
+ expect(isFloodRejection(undefined)).toBe(false)
99
+ })
100
+ })
101
+
102
+ describe('floodRetryAfterSec', () => {
103
+ it('reads the magnitude off the marker and off a raw 429 description', () => {
104
+ expect(floodRetryAfterSec(floodWaitActiveError(15908))).toBe(15908)
105
+ expect(floodRetryAfterSec(new Error('Too Many Requests: retry after 3'))).toBe(3)
106
+ expect(floodRetryAfterSec(new Error('THREAD_NOT_FOUND'))).toBeNull()
107
+ })
108
+ })
109
+
110
+ describe('queueFloodBlockedReply', () => {
111
+ let dir: string
112
+ beforeEach(() => {
113
+ dir = mkdtempSync(join(tmpdir(), 'flood-reply-'))
114
+ })
115
+ afterEach(() => {
116
+ rmSync(dir, { recursive: true, force: true })
117
+ })
118
+
119
+ it('writes the undelivered answer to the durable outbox', () => {
120
+ const text = 'the answer the operator is waiting for'
121
+ const res = queueFloodBlockedReply({
122
+ err: floodWaitActiveError(15908),
123
+ chatId: '12345',
124
+ threadId: null,
125
+ text,
126
+ priorityClass: 'critical',
127
+ stateDir: dir,
128
+ })
129
+
130
+ expect(res).not.toBeNull()
131
+ const records = readRecords(dir)
132
+ expect(records).toHaveLength(1)
133
+ // THE outcome: the content is on disk, verbatim.
134
+ expect(records[0]!.text).toBe(text)
135
+ expect(records[0]!.chatId).toBe('12345')
136
+ expect(records[0]!.source).toBe(FLOOD_QUEUED_SOURCE)
137
+ // And the caller is told it is queued, not delivered, and told not to resend.
138
+ expect(res!.notice).toMatch(/QUEUED/)
139
+ expect(res!.notice).toMatch(/not yet visible/i)
140
+ expect(res!.notice).toMatch(/Do NOT resend/i)
141
+ expect(res!.retryAfterSec).toBe(15908)
142
+ })
143
+
144
+ it('queues the wrapped `reply failed after 0 of 1 chunk(s) sent` error too', () => {
145
+ const res = queueFloodBlockedReply({
146
+ err: wrappedReplyFailure(),
147
+ chatId: '111',
148
+ threadId: null,
149
+ text: 'answer',
150
+ priorityClass: 'critical',
151
+ stateDir: dir,
152
+ })
153
+ expect(res).not.toBeNull()
154
+ expect(readRecords(dir)).toHaveLength(1)
155
+ })
156
+
157
+ it('does NOT queue a cosmetic frame — a stale card edit must not land hours later', () => {
158
+ const res = queueFloodBlockedReply({
159
+ err: floodWaitActiveError(15908),
160
+ chatId: '111',
161
+ threadId: null,
162
+ text: '⏳ working… 42s',
163
+ priorityClass: 'cosmetic',
164
+ stateDir: dir,
165
+ })
166
+ expect(res).toBeNull()
167
+ expect(listPendingRecords(dir)).toHaveLength(0)
168
+ })
169
+
170
+ it('queues `useful` and untagged sends (only cosmetic is droppable)', () => {
171
+ expect(
172
+ queueFloodBlockedReply({
173
+ err: floodWaitActiveError(60),
174
+ chatId: '111',
175
+ threadId: null,
176
+ text: 'a worker handback',
177
+ priorityClass: 'useful',
178
+ stateDir: dir,
179
+ }),
180
+ ).not.toBeNull()
181
+ expect(
182
+ queueFloodBlockedReply({
183
+ err: floodWaitActiveError(60),
184
+ chatId: '111',
185
+ threadId: null,
186
+ text: 'an untagged send',
187
+ stateDir: dir,
188
+ }),
189
+ ).not.toBeNull()
190
+ expect(readRecords(dir)).toHaveLength(2)
191
+ })
192
+
193
+ it('returns null for a non-flood failure so the caller still throws', () => {
194
+ const res = queueFloodBlockedReply({
195
+ err: new Error('THREAD_NOT_FOUND'),
196
+ chatId: '111',
197
+ threadId: null,
198
+ text: 'answer',
199
+ priorityClass: 'critical',
200
+ stateDir: dir,
201
+ })
202
+ expect(res).toBeNull()
203
+ expect(listPendingRecords(dir)).toHaveLength(0)
204
+ })
205
+
206
+ it('returns null (caller throws) when the disk write fails — never a false "queued"', () => {
207
+ const res = queueFloodBlockedReply({
208
+ err: floodWaitActiveError(60),
209
+ chatId: '111',
210
+ threadId: null,
211
+ text: 'answer',
212
+ priorityClass: 'critical',
213
+ stateDir: dir,
214
+ write: () => false,
215
+ })
216
+ expect(res).toBeNull()
217
+ })
218
+
219
+ it('is idempotent under caller retry — same content queues exactly one record', () => {
220
+ const args = {
221
+ err: floodWaitActiveError(15908),
222
+ chatId: '111',
223
+ threadId: null,
224
+ text: 'the same answer, recomposed',
225
+ priorityClass: 'critical' as const,
226
+ stateDir: dir,
227
+ }
228
+ const a = queueFloodBlockedReply(args)
229
+ const b = queueFloodBlockedReply(args)
230
+ expect(a!.turnNonce).toBe(b!.turnNonce)
231
+ expect(readRecords(dir)).toHaveLength(1)
232
+ })
233
+
234
+ it('derives a nonce from CONTENT, not the turn — a second answer in the same turn queues separately', () => {
235
+ // Why this matters: keying on `turn.turnId` would make a flood-blocked
236
+ // answer `skip-journaled` (silently dropped) whenever an earlier reply in
237
+ // the same turn had already journaled that nonce as delivered.
238
+ expect(floodQueueNonce('111', null, 'a')).not.toBe(floodQueueNonce('111', null, 'b'))
239
+ expect(floodQueueNonce('111', null, 'a')).not.toBe(floodQueueNonce('222', null, 'a'))
240
+ expect(floodQueueNonce('111', 7, 'a')).not.toBe(floodQueueNonce('111', null, 'a'))
241
+ expect(floodQueueNonce('111', null, 'a')).toBe(floodQueueNonce('111', null, 'a'))
242
+ })
243
+ })
244
+
245
+ describe('end-to-end: queued during the window, delivered after it closes', () => {
246
+ let dir: string
247
+ beforeEach(() => {
248
+ dir = mkdtempSync(join(tmpdir(), 'flood-e2e-'))
249
+ })
250
+ afterEach(() => {
251
+ rmSync(dir, { recursive: true, force: true })
252
+ })
253
+
254
+ it('survives an open window and lands verbatim once the window closes', async () => {
255
+ const answer = 'Yes — the deploy is green; here is the full breakdown the operator asked for.'
256
+ const t0 = 1_000_000
257
+
258
+ // 1. OPEN a real flood window in the real persisted breaker state.
259
+ writeFloodState(
260
+ floodStatePath(dir),
261
+ { untilTs: t0 + 15_908_000, retryAfterSec: 15908, recordedTs: t0 },
262
+ () => {},
263
+ )
264
+ const probe = makeFloodWaitProbe(floodStatePath(dir), () => t0)
265
+
266
+ // 2. The user-facing send is refused by that window.
267
+ const queued = queueFloodBlockedReply({
268
+ err: floodWaitActiveError(15908),
269
+ chatId: '12345',
270
+ threadId: null,
271
+ text: answer,
272
+ priorityClass: 'critical',
273
+ stateDir: dir,
274
+ createdAt: t0,
275
+ })
276
+ expect(queued).not.toBeNull()
277
+
278
+ // 3. It is DURABLY queued (this is what was missing on 2026-07-28).
279
+ expect(readRecords(dir).map((r) => r.text)).toEqual([answer])
280
+
281
+ // 4. Sweeping WHILE the window is open must not touch the wire, and must
282
+ // not lose the record.
283
+ const send = vi.fn(async () => 4242)
284
+ const during = await sweepOutbox({
285
+ stateDir: dir,
286
+ send,
287
+ textAlreadyDelivered: () => false,
288
+ floodWaitRemainingMs: probe,
289
+ now: () => t0 + 60_000,
290
+ })
291
+ expect(during.floodDeferred).toBe(true)
292
+ expect(send).not.toHaveBeenCalled()
293
+ expect(listPendingRecords(dir)).toHaveLength(1)
294
+
295
+ // 5. The window CLOSES.
296
+ const after = t0 + 15_909_000
297
+ const openProbe = makeFloodWaitProbe(floodStatePath(dir), () => after)
298
+ expect(openProbe()).toBe(0)
299
+
300
+ const closed = await sweepOutbox({
301
+ stateDir: dir,
302
+ send,
303
+ textAlreadyDelivered: () => false,
304
+ floodWaitRemainingMs: openProbe,
305
+ now: () => after,
306
+ })
307
+
308
+ // 6. THE outcome: the operator receives the answer. (Prefixed "(delayed)"
309
+ // because it is older than OUTBOX_MAX_AGE_MS — delayed, never dropped.)
310
+ expect(closed.delivered).toBe(1)
311
+ expect(send).toHaveBeenCalledTimes(1)
312
+ const [chatId, threadId, delivered] = send.mock.calls[0]! as unknown as [string, number | null, string]
313
+ expect(chatId).toBe('12345')
314
+ expect(threadId).toBeNull()
315
+ expect(delivered).toContain(answer)
316
+
317
+ // 7. And nothing is left behind to re-send on the next tick.
318
+ expect(listPendingRecords(dir)).toHaveLength(0)
319
+ })
320
+
321
+ it('does not double-deliver when the caller retries after the window closed', async () => {
322
+ const answer = 'the one answer'
323
+ const t0 = 1_000_000
324
+ queueFloodBlockedReply({
325
+ err: floodWaitActiveError(30),
326
+ chatId: '111',
327
+ threadId: null,
328
+ text: answer,
329
+ priorityClass: 'critical',
330
+ stateDir: dir,
331
+ createdAt: t0,
332
+ })
333
+
334
+ const send = vi.fn(async () => 1)
335
+ await sweepOutbox({
336
+ stateDir: dir,
337
+ send,
338
+ textAlreadyDelivered: () => false,
339
+ now: () => t0 + 10_000,
340
+ })
341
+ expect(send).toHaveBeenCalledTimes(1)
342
+
343
+ // The model retries the same reply; it is re-queued under the same
344
+ // content nonce, which the delivered-keys journal already holds.
345
+ queueFloodBlockedReply({
346
+ err: floodWaitActiveError(30),
347
+ chatId: '111',
348
+ threadId: null,
349
+ text: answer,
350
+ priorityClass: 'critical',
351
+ stateDir: dir,
352
+ createdAt: t0 + 20_000,
353
+ })
354
+ await sweepOutbox({
355
+ stateDir: dir,
356
+ send,
357
+ textAlreadyDelivered: () => false,
358
+ now: () => t0 + 40_000,
359
+ })
360
+
361
+ // THE outcome: still exactly one delivery, and the duplicate record is gone.
362
+ expect(send).toHaveBeenCalledTimes(1)
363
+ expect(listPendingRecords(dir)).toHaveLength(0)
364
+ })
365
+
366
+ it('delivers a backlog in CAPTURE order, not filesystem order', async () => {
367
+ const t0 = 1_000_000
368
+ // Contents chosen so their content-hash nonces do NOT sort in capture
369
+ // order — readdir order on a hashed filesystem is effectively nonce order,
370
+ // which is what the pre-fix sweep followed.
371
+ const parts = ['first thing I said', 'second thing I said', 'third thing I said']
372
+ parts.forEach((text, i) => {
373
+ queueFloodBlockedReply({
374
+ err: floodWaitActiveError(300),
375
+ chatId: '111',
376
+ threadId: null,
377
+ text,
378
+ priorityClass: 'critical',
379
+ stateDir: dir,
380
+ createdAt: t0 + i * 1000,
381
+ })
382
+ })
383
+
384
+ const send = vi.fn(async () => 1)
385
+ await sweepOutbox({
386
+ stateDir: dir,
387
+ send,
388
+ textAlreadyDelivered: () => false,
389
+ now: () => t0 + 60_000,
390
+ })
391
+
392
+ expect(send.mock.calls.map((c) => c[2])).toEqual(parts)
393
+ })
394
+ })
395
+
396
+ describe('the reply path is actually wired to the queue', () => {
397
+ it("sendReply's chunk-loop catch queues before it throws", async () => {
398
+ // A source-level guard, deliberately: `sendReply` takes ~50 injected
399
+ // gateway deps and is not constructible in a unit test, so without this
400
+ // the wiring — the one line that makes all of the above reach production —
401
+ // would be unguarded. #3849 shipped exactly that failure mode: a complete
402
+ // choke point claimed while one call site sat unwired.
403
+ const src = readFileSync(
404
+ new URL('../gateway/outbound-send-path.ts', import.meta.url),
405
+ 'utf8',
406
+ )
407
+ const catchIdx = src.indexOf('reply failed after ${sentIds.length} of ${chunks.length}')
408
+ expect(catchIdx).toBeGreaterThan(0)
409
+ const block = src.slice(Math.max(0, catchIdx - 2500), catchIdx)
410
+ expect(block).toContain('queueFloodBlockedReply({')
411
+ // ...and the queued branch returns instead of falling through to the throw.
412
+ expect(block).toMatch(/if \(queued != null\) \{/)
413
+ expect(block).toContain('queued.notice')
414
+ // ...and discharges the obligation, so the tracker stops re-prompting for
415
+ // a reply that is already durably queued.
416
+ expect(block).toContain('closeObligationOnSubstantiveReply(')
417
+ })
418
+ })
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The outbox sweep must not hammer a flood ban it can already see (#3853).
3
+ *
4
+ * ── The bug these tests guard ────────────────────────────────────────────
5
+ * `startOutboxSweep` built its retry policy as `createRetryApiCall({ log })` —
6
+ * the ONE wiring in the tree with neither `floodWaitRemainingMs` (read the
7
+ * persisted breaker) nor `onFloodWait` (write to it). Combined with a fixed
8
+ * 5s tick and a catch that released the claim with no backoff, the sweep
9
+ * re-issued a single undeliverable `sendMessage` every 5 seconds for the whole
10
+ * of overlord's 15908s (4.4h) ban on 2026-07-27: 228 requests into a window
11
+ * the breaker had recorded and could have answered in one read.
12
+ *
13
+ * The gateway log is the proof and the shape of the outcome assertion:
14
+ * grep -c 'flood window still open' gateway-supervisor.log → 0
15
+ * grep -c 'flood ban of' gateway-supervisor.log → 230
16
+ * grep -c 'outbox-sweep: send failed' → 228
17
+ *
18
+ * Every test below asserts an OUTCOME — whether the wire was touched and
19
+ * whether the record survived — not that a code path executed.
20
+ */
21
+
22
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
23
+ import { mkdtempSync, rmSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { join } from 'node:path'
26
+
27
+ import {
28
+ writeOutboxRecordAtomic,
29
+ listPendingRecords,
30
+ sha256Hex,
31
+ type OutboxRecord,
32
+ } from '../outbox.js'
33
+ import {
34
+ sweepOutbox,
35
+ createSweepBackoff,
36
+ OUTBOX_SWEEP_INTERVAL_MS,
37
+ OUTBOX_SWEEP_BACKOFF_MAX_MS,
38
+ } from '../gateway/outbox-sweep.js'
39
+
40
+ function rec(over: Partial<OutboxRecord> = {}): OutboxRecord {
41
+ const text = over.text ?? 'the answer the operator is waiting for'
42
+ return {
43
+ turnNonce: over.turnNonce ?? 'nonce-1',
44
+ chatId: over.chatId ?? '111',
45
+ threadId: over.threadId ?? null,
46
+ text,
47
+ textSha256: sha256Hex(text),
48
+ createdAt: over.createdAt ?? 0,
49
+ source: over.source ?? 'channel',
50
+ }
51
+ }
52
+
53
+ describe('outbox sweep vs an open flood window', () => {
54
+ let dir: string
55
+ beforeEach(() => {
56
+ dir = mkdtempSync(join(tmpdir(), 'outbox-flood-'))
57
+ })
58
+ afterEach(() => {
59
+ rmSync(dir, { recursive: true, force: true })
60
+ })
61
+
62
+ it('does not hit the wire while a flood window is open, and keeps the record', async () => {
63
+ writeOutboxRecordAtomic(rec(), dir)
64
+ const send = vi.fn(async () => 1)
65
+ const log = vi.fn()
66
+
67
+ const summary = await sweepOutbox({
68
+ stateDir: dir,
69
+ log,
70
+ send,
71
+ textAlreadyDelivered: () => false,
72
+ // 12247s remaining — the exact shape of the window the 2026-07-27 sweep
73
+ // ignored 228 times.
74
+ floodWaitRemainingMs: () => 12_247_000,
75
+ now: () => 10 * 60_000,
76
+ })
77
+
78
+ // THE outcome: nothing reached Telegram.
79
+ expect(send).not.toHaveBeenCalled()
80
+ expect(summary.floodDeferred).toBe(true)
81
+ expect(summary.floodRemainingMs).toBe(12_247_000)
82
+ expect(summary.delivered).toBe(0)
83
+ // And nothing was lost — the record is still pending for the next sweep.
84
+ expect(listPendingRecords(dir)).toHaveLength(1)
85
+ // The deferral is NOT logged per-tick — a 4.4h ban is ~3181 ticks, and one
86
+ // line each would relocate the flood from the wire to the disk. The tick
87
+ // loop reports it at most once per OUTBOX_SWEEP_DEFER_LOG_INTERVAL_MS.
88
+ expect(log).not.toHaveBeenCalled()
89
+ })
90
+
91
+ it('delivers the SAME record once the window closes', async () => {
92
+ writeOutboxRecordAtomic(rec(), dir)
93
+ const send = vi.fn(async () => 42)
94
+ let remaining = 12_247_000
95
+
96
+ const deferred = await sweepOutbox({
97
+ stateDir: dir,
98
+ send,
99
+ textAlreadyDelivered: () => false,
100
+ floodWaitRemainingMs: () => remaining,
101
+ now: () => 10 * 60_000,
102
+ })
103
+ expect(deferred.floodDeferred).toBe(true)
104
+ expect(send).not.toHaveBeenCalled()
105
+
106
+ remaining = 0
107
+ const after = await sweepOutbox({
108
+ stateDir: dir,
109
+ send,
110
+ textAlreadyDelivered: () => false,
111
+ floodWaitRemainingMs: () => remaining,
112
+ now: () => 20 * 60_000,
113
+ })
114
+
115
+ expect(send).toHaveBeenCalledTimes(1)
116
+ expect(send.mock.calls[0]?.[2]).toContain('the answer the operator is waiting for')
117
+ expect(after.delivered).toBe(1)
118
+ expect(listPendingRecords(dir)).toHaveLength(0)
119
+ })
120
+
121
+ it('sweeps normally when no window is open', async () => {
122
+ writeOutboxRecordAtomic(rec(), dir)
123
+ const send = vi.fn(async () => 7)
124
+ const summary = await sweepOutbox({
125
+ stateDir: dir,
126
+ send,
127
+ textAlreadyDelivered: () => false,
128
+ floodWaitRemainingMs: () => 0,
129
+ now: () => 10 * 60_000,
130
+ })
131
+ expect(send).toHaveBeenCalledTimes(1)
132
+ expect(summary.delivered).toBe(1)
133
+ expect(summary.floodDeferred).toBeUndefined()
134
+ })
135
+
136
+ it('FAILS OPEN: a throwing probe must never strand the outbox', async () => {
137
+ writeOutboxRecordAtomic(rec(), dir)
138
+ const send = vi.fn(async () => 7)
139
+ const summary = await sweepOutbox({
140
+ stateDir: dir,
141
+ send,
142
+ textAlreadyDelivered: () => false,
143
+ floodWaitRemainingMs: () => {
144
+ throw new Error('EACCES: flood-wait.json owned by another uid')
145
+ },
146
+ now: () => 10 * 60_000,
147
+ })
148
+ expect(send).toHaveBeenCalledTimes(1)
149
+ expect(summary.delivered).toBe(1)
150
+ })
151
+
152
+ it('reports send failures so the tick can back off', async () => {
153
+ writeOutboxRecordAtomic(rec(), dir)
154
+ const send = vi.fn(async () => {
155
+ throw new Error('FLOOD_WAIT_ACTIVE')
156
+ })
157
+ const summary = await sweepOutbox({
158
+ stateDir: dir,
159
+ send,
160
+ textAlreadyDelivered: () => false,
161
+ floodWaitRemainingMs: () => 0,
162
+ now: () => 10 * 60_000,
163
+ })
164
+ expect(summary.sendFailures).toBe(1)
165
+ expect(summary.delivered).toBe(0)
166
+ // Claim released — the record is still pending, never lost.
167
+ expect(listPendingRecords(dir)).toHaveLength(1)
168
+ })
169
+ })
170
+
171
+ describe('createSweepBackoff — the fixed-5s-forever regression', () => {
172
+ it('rides the normal tick on the FIRST failure, then doubles', () => {
173
+ const b = createSweepBackoff()
174
+ expect(b.ready(0)).toBe(true)
175
+
176
+ expect(b.noteFailure(0)).toBe(OUTBOX_SWEEP_INTERVAL_MS) // 5s
177
+ expect(b.ready(4_999)).toBe(false)
178
+ expect(b.ready(5_000)).toBe(true)
179
+
180
+ expect(b.noteFailure(5_000)).toBe(10_000)
181
+ expect(b.noteFailure(15_000)).toBe(20_000)
182
+ expect(b.noteFailure(35_000)).toBe(40_000)
183
+ })
184
+
185
+ it('caps the delay so the sweep never stops entirely', () => {
186
+ const b = createSweepBackoff()
187
+ let last = 0
188
+ for (let i = 0; i < 40; i++) last = b.noteFailure(i * 1_000_000)
189
+ expect(last).toBe(OUTBOX_SWEEP_BACKOFF_MAX_MS)
190
+ })
191
+
192
+ it('the 4.4h-ban scenario: ≤ 60 sweeps, not 228', () => {
193
+ // Replay the incident's shape — a persistently failing send across a
194
+ // 15908s window — and count how many sweeps the pacer would permit.
195
+ const b = createSweepBackoff()
196
+ const banMs = 15_908_000
197
+ let now = 0
198
+ let sweeps = 0
199
+ while (now < banMs) {
200
+ if (b.ready(now)) {
201
+ sweeps++
202
+ b.noteFailure(now)
203
+ }
204
+ now += OUTBOX_SWEEP_INTERVAL_MS
205
+ }
206
+ // Fixed 5s ticking would be 3181 attempts; the real sweep issued 228
207
+ // before the operator intervened. Exponential + cap keeps it under 60.
208
+ expect(sweeps).toBeLessThan(60)
209
+ expect(sweeps).toBeGreaterThan(0)
210
+ })
211
+
212
+ it('a successful sweep clears the backoff', () => {
213
+ const b = createSweepBackoff()
214
+ b.noteFailure(0)
215
+ b.noteFailure(5_000)
216
+ expect(b.ready(6_000)).toBe(false)
217
+ b.noteSuccess()
218
+ expect(b.ready(6_000)).toBe(true)
219
+ expect(b.failures()).toBe(0)
220
+ })
221
+ })