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,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
|
+
})
|