switchroom 0.18.7 → 0.18.8
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/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +1403 -657
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/gateway.ts +564 -85
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { createDraftStream, type DraftStreamHandle } from './draft-stream.js'
|
|
23
23
|
import { richMessage, isParseEntitiesError } from './rich-send.js'
|
|
24
|
-
import {
|
|
24
|
+
import { renderOutboundChunks } from './render/rich-render.js'
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Minimal bot.api surface the controller needs. Real callers pass grammy's
|
|
@@ -223,81 +223,199 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
223
223
|
// `format:'text'` stream (plain sendMessage, no rich wrapper).
|
|
224
224
|
// sendRichMessage does NOT accept link_preview_options (rich messages
|
|
225
225
|
// control previews via entity detection), so strip it for the rich path.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
226
|
+
// Render the outbound body into 1+ wire-cap-respecting pieces. The common
|
|
227
|
+
// case is a SINGLE piece whose output is identical to the pre-existing
|
|
228
|
+
// `maybeRenderOutbound` path; a body whose markdown-escaping pushed the
|
|
229
|
+
// rendered form past the cap splits into several pieces, each of which fits
|
|
230
|
+
// its own wire cap and never bisects a fenced block / table row (see
|
|
231
|
+
// `renderOutboundChunks`). Flag OFF (default) and a literal `format:'text'`
|
|
232
|
+
// stream both yield a single passthrough piece — no behavioural change.
|
|
233
|
+
const renderPieces = (text: string): { text: string; rich: boolean }[] => {
|
|
234
|
+
if (literalText) return [{ text, rich: false }]
|
|
235
|
+
// A `plain`-mode piece (oversized/unsafe content renderSafe declined to
|
|
236
|
+
// emit as rich) sends WITHOUT the rich wrapper.
|
|
237
|
+
return renderOutboundChunks(text).map((r) => ({ text: r.text, rich: r.mode !== 'plain' }))
|
|
238
|
+
}
|
|
239
|
+
// Send ONE rendered piece. Rich pieces go through sendRichMessage (with
|
|
240
|
+
// link_preview_options stripped — rich messages control previews via entity
|
|
241
|
+
// detection); plain pieces (and literal streams) through sendMessage.
|
|
242
|
+
const sendPiece = (piece: { text: string; rich: boolean }, opts: StreamSendOpts) => {
|
|
243
|
+
if (!piece.rich) return bot.api.sendMessage(chatId, piece.text, opts)
|
|
234
244
|
const richOpts = { ...opts }
|
|
235
245
|
delete richOpts.link_preview_options
|
|
236
|
-
return bot.api.sendRichMessage(chatId, richMessage(
|
|
246
|
+
return bot.api.sendRichMessage(chatId, richMessage(piece.text), richOpts)
|
|
237
247
|
}
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
248
|
+
const editPiece = (id: number, piece: { text: string; rich: boolean }, opts: StreamSendOpts) => {
|
|
249
|
+
if (!piece.rich) return bot.api.editMessageText(chatId, id, piece.text, opts)
|
|
250
|
+
return bot.api.editMessageText(chatId, id, richMessage(piece.text), opts)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Overflow-tail bookkeeping, shared across the send + edit closures for the
|
|
254
|
+
// whole stream lifetime. A body large enough to split into several
|
|
255
|
+
// wire-cap pieces anchors on piece[0] (edited in place by draft-stream) and
|
|
256
|
+
// parks pieces[1..n] as follow-up messages. The draft-stream edit callback
|
|
257
|
+
// fires on EVERY throttled flush as a streamed answer grows, so we MUST NOT
|
|
258
|
+
// re-send those tails each tick (that flooded the chat with duplicates — the
|
|
259
|
+
// blocker this fix closes). Instead we remember each tail's message_id the
|
|
260
|
+
// first time it is emitted and edit it in place on later flushes; a tail that
|
|
261
|
+
// did not exist on a prior flush (the piece count grew) is sent fresh once.
|
|
262
|
+
// End state after finalize: anchor + one message per tail piece, no dupes.
|
|
263
|
+
const tailIds: number[] = []
|
|
264
|
+
const tailLastText: string[] = []
|
|
265
|
+
|
|
266
|
+
// Emit or update a single tail piece (0-based index `ti` = piece index - 1).
|
|
267
|
+
// Sends a fresh message the first time; edits in place (skipping unchanged
|
|
268
|
+
// text) thereafter. A non-parse failure is logged as a partial-delivery
|
|
269
|
+
// warning and swallowed so the remaining tail pieces still get a chance to
|
|
270
|
+
// land — never a silent drop, never an abort of pieces K..N (concern C1).
|
|
271
|
+
const upsertTail = async (ti: number, piece: { text: string; rich: boolean }): Promise<void> => {
|
|
272
|
+
const existingId = tailIds[ti]
|
|
273
|
+
if (existingId != null) {
|
|
274
|
+
if (tailLastText[ti] === piece.text) return // unchanged — skip the API call
|
|
275
|
+
try {
|
|
276
|
+
await retry(() => editPiece(existingId, piece, baseOpts), { threadId, chat_id: chatId })
|
|
277
|
+
tailLastText[ti] = piece.text
|
|
278
|
+
onEdit?.(existingId, piece.text.length)
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (!literalText && piece.rich && isParseEntitiesError(err)) {
|
|
281
|
+
warn?.(
|
|
282
|
+
`stream-controller: tail-piece #${ti + 1} edit parse-entities rejected — retrying same id=${existingId} as plain text (${err instanceof Error ? err.message : String(err)})`,
|
|
283
|
+
)
|
|
284
|
+
await retry(
|
|
285
|
+
() => bot.api.editMessageText(chatId, existingId, piece.text, baseOpts),
|
|
286
|
+
{ threadId, chat_id: chatId },
|
|
287
|
+
)
|
|
288
|
+
tailLastText[ti] = piece.text
|
|
289
|
+
onEdit?.(existingId, piece.text.length)
|
|
290
|
+
} else {
|
|
291
|
+
// Best-effort continue: leave tailLastText[ti] stale so the next
|
|
292
|
+
// flush retries this piece, and surface the partial delivery loudly.
|
|
293
|
+
warn?.(
|
|
294
|
+
`stream-controller: tail-piece #${ti + 1} edit FAILED (id=${existingId}) — partial delivery, this piece may be stale (${err instanceof Error ? err.message : String(err)})`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
// First emission of this tail piece → a fresh follow-up message.
|
|
301
|
+
try {
|
|
302
|
+
const sent = await retry(() => sendPiece(piece, sendOpts), { threadId, chat_id: chatId })
|
|
303
|
+
tailIds[ti] = sent.message_id
|
|
304
|
+
tailLastText[ti] = piece.text
|
|
305
|
+
onSend?.(sent.message_id, piece.text.length)
|
|
306
|
+
} catch (err) {
|
|
307
|
+
if (!literalText && piece.rich && isParseEntitiesError(err)) {
|
|
308
|
+
warn?.(
|
|
309
|
+
`stream-controller: tail-piece #${ti + 1} send parse-entities rejected — sending as plain text (${err instanceof Error ? err.message : String(err)})`,
|
|
310
|
+
)
|
|
311
|
+
const sent = await retry(
|
|
312
|
+
() => bot.api.sendMessage(chatId, piece.text, sendOpts),
|
|
313
|
+
{ threadId, chat_id: chatId },
|
|
314
|
+
)
|
|
315
|
+
tailIds[ti] = sent.message_id
|
|
316
|
+
tailLastText[ti] = piece.text
|
|
317
|
+
onSend?.(sent.message_id, piece.text.length)
|
|
318
|
+
} else {
|
|
319
|
+
// Best-effort continue: no id recorded, so the next flush re-attempts
|
|
320
|
+
// this piece rather than silently dropping pieces K..N (concern C1).
|
|
321
|
+
warn?.(
|
|
322
|
+
`stream-controller: tail-piece #${ti + 1} send FAILED — partial delivery, this and later pieces may be missing this flush (${err instanceof Error ? err.message : String(err)})`,
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
243
326
|
}
|
|
244
327
|
|
|
245
328
|
return createDraftStream(
|
|
246
329
|
async (text) => {
|
|
330
|
+
// Render → 1+ cap-respecting pieces. The FIRST piece's message_id anchors
|
|
331
|
+
// the stream (later edits target it); any overflow pieces are parked as
|
|
332
|
+
// follow-up messages via upsertTail. For the common single-piece case
|
|
333
|
+
// this is exactly one send.
|
|
334
|
+
const pieces = renderPieces(text)
|
|
335
|
+
const head = pieces[0]
|
|
336
|
+
let anchorId: number | undefined
|
|
247
337
|
try {
|
|
248
338
|
const sent = await retry(
|
|
249
|
-
() =>
|
|
339
|
+
() => sendPiece(head, sendOpts),
|
|
250
340
|
{ threadId, chat_id: chatId },
|
|
251
341
|
)
|
|
252
|
-
|
|
253
|
-
return sent.message_id
|
|
342
|
+
anchorId = sent.message_id
|
|
254
343
|
} catch (err) {
|
|
255
|
-
if (!literalText && isParseEntitiesError(err)) {
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// (no rich wrapper, so the parser never runs)
|
|
260
|
-
// recovery — see issue #657. The raw markdown source is itself
|
|
261
|
-
// readable, so we send it verbatim.
|
|
344
|
+
if (!literalText && head.rich && isParseEntitiesError(err)) {
|
|
345
|
+
// Piece rejected because its markdown couldn't be parsed. There is
|
|
346
|
+
// no message_id to edit (the send 400'd before any message was
|
|
347
|
+
// created), so recover with a single fresh PLAIN send of the same
|
|
348
|
+
// body (no rich wrapper, so the parser never runs) — see issue #657.
|
|
262
349
|
warn?.(
|
|
263
350
|
`stream-controller: send parse-entities rejected — retrying once as plain text (${err instanceof Error ? err.message : String(err)})`,
|
|
264
351
|
)
|
|
352
|
+
// Resend the RAW source verbatim (readable, and the exact
|
|
353
|
+
// pre-existing #657 contract) rather than the escaped/rendered
|
|
354
|
+
// body. For a single-piece stream (the common case) this is the
|
|
355
|
+
// whole message; for a rare oversize split the head falls back to
|
|
356
|
+
// its own body and each tail to its own body below.
|
|
357
|
+
const fallbackBody = pieces.length === 1 ? text : head.text
|
|
265
358
|
const sent = await retry(
|
|
266
|
-
() => bot.api.sendMessage(chatId,
|
|
359
|
+
() => bot.api.sendMessage(chatId, fallbackBody, sendOpts),
|
|
267
360
|
{ threadId, chat_id: chatId },
|
|
268
361
|
)
|
|
269
|
-
|
|
270
|
-
|
|
362
|
+
anchorId = sent.message_id
|
|
363
|
+
} else {
|
|
364
|
+
throw err
|
|
271
365
|
}
|
|
272
|
-
throw err
|
|
273
366
|
}
|
|
367
|
+
// C2: report the ACTUAL emitted anchor-piece length, not the full body
|
|
368
|
+
// length — the anchor only holds the head piece, and the tails report
|
|
369
|
+
// their own lengths via upsertTail.
|
|
370
|
+
onSend?.(anchorId as number, head.text.length)
|
|
371
|
+
// Park overflow pieces (first-time send records their ids for reuse).
|
|
372
|
+
for (let pi = 1; pi < pieces.length; pi++) {
|
|
373
|
+
await upsertTail(pi - 1, pieces[pi])
|
|
374
|
+
}
|
|
375
|
+
return anchorId as number
|
|
274
376
|
},
|
|
275
377
|
async (id, text) => {
|
|
378
|
+
const pieces = renderPieces(text)
|
|
379
|
+
const head = pieces[0]
|
|
380
|
+
// Edit the anchor message in place with the FIRST piece.
|
|
276
381
|
try {
|
|
277
382
|
await retry(
|
|
278
|
-
() =>
|
|
383
|
+
() => editPiece(id, head, baseOpts),
|
|
279
384
|
{ threadId, chat_id: chatId },
|
|
280
385
|
)
|
|
281
|
-
|
|
386
|
+
// C2: report the actual head-piece length, not the full body length.
|
|
387
|
+
onEdit?.(id, head.text.length)
|
|
282
388
|
} catch (err) {
|
|
283
|
-
if (!literalText && isParseEntitiesError(err)) {
|
|
389
|
+
if (!literalText && head.rich && isParseEntitiesError(err)) {
|
|
284
390
|
// Edit rejected because the markdown couldn't be parsed — DO NOT
|
|
285
391
|
// send a fresh message. The whole point of issue #657 is that the
|
|
286
392
|
// previous implementation sent a duplicate message every time a
|
|
287
393
|
// parse rejection fired. Retry the edit on the SAME message_id as
|
|
288
|
-
// PLAIN text (no rich wrapper, so the parser never runs).
|
|
289
|
-
// markdown source is itself readable, so we send it verbatim.
|
|
394
|
+
// PLAIN text (no rich wrapper, so the parser never runs).
|
|
290
395
|
warn?.(
|
|
291
396
|
`stream-controller: edit parse-entities rejected — retrying same id=${id} as plain text (${err instanceof Error ? err.message : String(err)})`,
|
|
292
397
|
)
|
|
398
|
+
// Re-edit the SAME id with the RAW source verbatim (the exact #657
|
|
399
|
+
// contract). For a single-piece stream (common case) this is the
|
|
400
|
+
// whole body; a rare oversize split edits the head piece's body.
|
|
401
|
+
const fallbackBody = pieces.length === 1 ? text : head.text
|
|
293
402
|
await retry(
|
|
294
|
-
() => bot.api.editMessageText(chatId, id,
|
|
403
|
+
() => bot.api.editMessageText(chatId, id, fallbackBody, baseOpts),
|
|
295
404
|
{ threadId, chat_id: chatId },
|
|
296
405
|
)
|
|
297
|
-
onEdit?.(id, text.length)
|
|
298
|
-
|
|
406
|
+
onEdit?.(id, head.text.length)
|
|
407
|
+
} else {
|
|
408
|
+
throw err
|
|
299
409
|
}
|
|
300
|
-
|
|
410
|
+
}
|
|
411
|
+
// Oversize tail: the anchor message holds only the first piece. On EVERY
|
|
412
|
+
// edit flush we UPDATE the parked tail messages in place (or send a tail
|
|
413
|
+
// that only just came into existence) — we never re-send tails already
|
|
414
|
+
// emitted on a prior flush. This is the fix for the duplicate-flood
|
|
415
|
+
// blocker: the previous code re-sent pieces[1..n] as brand-new messages
|
|
416
|
+
// on each throttled edit tick.
|
|
417
|
+
for (let pi = 1; pi < pieces.length; pi++) {
|
|
418
|
+
await upsertTail(pi - 1, pieces[pi])
|
|
301
419
|
}
|
|
302
420
|
},
|
|
303
421
|
{
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { describe, it, expect } from 'vitest'
|
|
32
32
|
import {
|
|
33
|
+
BOOT_UNPIN_MAX_ATTEMPTS,
|
|
33
34
|
loadActivityCards,
|
|
34
35
|
persistActivityCards,
|
|
35
36
|
writeActivityCardRecord,
|
|
@@ -314,9 +315,10 @@ describe('runActivityCardBootReaper — transport-boundary outcome tests', () =>
|
|
|
314
315
|
expect(loadActivityCards(PATH, fs)).toEqual([])
|
|
315
316
|
})
|
|
316
317
|
|
|
317
|
-
it('a failing unpin
|
|
318
|
+
it('retry-safe (#3001): a failing unpin is non-fatal, does not re-run the edit, and RETAINS the record for a next-boot unpin retry', async () => {
|
|
318
319
|
const { fs } = memFs()
|
|
319
|
-
|
|
320
|
+
const rec = card()
|
|
321
|
+
writeActivityCardRecord(PATH, fs, rec)
|
|
320
322
|
let editCalls = 0
|
|
321
323
|
const res = await runActivityCardBootReaper({
|
|
322
324
|
path: PATH,
|
|
@@ -328,9 +330,52 @@ describe('runActivityCardBootReaper — transport-boundary outcome tests', () =>
|
|
|
328
330
|
unpinCard: async () => {
|
|
329
331
|
throw new Error('message to unpin not found')
|
|
330
332
|
},
|
|
333
|
+
log: () => {},
|
|
331
334
|
})
|
|
332
335
|
expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 0, total: 1 })
|
|
333
336
|
expect(editCalls).toBe(1)
|
|
337
|
+
// The record is retained (not forfeited) so the next boot retries the
|
|
338
|
+
// idempotent unpin — flagged so it can never re-run the finalize edit.
|
|
339
|
+
expect(loadActivityCards(PATH, fs)).toEqual([
|
|
340
|
+
{ ...rec, finalizeAttempted: true, unpinAttempts: 1 },
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
// Second boot: the retained record retries ONLY the unpin (edit stays
|
|
344
|
+
// at-most-once); on success the record is finally dropped.
|
|
345
|
+
let secondBootEdits = 0
|
|
346
|
+
const res2 = await runActivityCardBootReaper({
|
|
347
|
+
path: PATH,
|
|
348
|
+
fs,
|
|
349
|
+
finalizeCard: async () => {
|
|
350
|
+
secondBootEdits++
|
|
351
|
+
return { ok: true }
|
|
352
|
+
},
|
|
353
|
+
unpinCard: async () => ({ ok: true }),
|
|
354
|
+
log: () => {},
|
|
355
|
+
})
|
|
356
|
+
expect(secondBootEdits).toBe(0)
|
|
357
|
+
expect(res2).toEqual({ finalized: 0, vanished: 0, unpinned: 1, total: 1 })
|
|
358
|
+
expect(loadActivityCards(PATH, fs)).toEqual([])
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('retry-safe (#3001): the unpin retry is forfeited at BOOT_UNPIN_MAX_ATTEMPTS', async () => {
|
|
362
|
+
const { fs } = memFs()
|
|
363
|
+
writeActivityCardRecord(
|
|
364
|
+
PATH,
|
|
365
|
+
fs,
|
|
366
|
+
card({ finalizeAttempted: true, unpinAttempts: BOOT_UNPIN_MAX_ATTEMPTS - 1 }),
|
|
367
|
+
)
|
|
368
|
+
const res = await runActivityCardBootReaper({
|
|
369
|
+
path: PATH,
|
|
370
|
+
fs,
|
|
371
|
+
finalizeCard: async () => ({ ok: true }),
|
|
372
|
+
unpinCard: async () => {
|
|
373
|
+
throw new Error('still no rights')
|
|
374
|
+
},
|
|
375
|
+
log: () => {},
|
|
376
|
+
})
|
|
377
|
+
// Final attempt failed — record forfeited so it cannot re-fail every boot.
|
|
378
|
+
expect(res).toEqual({ finalized: 0, vanished: 0, unpinned: 0, total: 1 })
|
|
334
379
|
expect(loadActivityCards(PATH, fs)).toEqual([])
|
|
335
380
|
})
|
|
336
381
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome-based composition test for the approval/restart continuation
|
|
3
|
+
* contract in `reference/jobs/approve-what-my-agent-can-touch.md`:
|
|
4
|
+
*
|
|
5
|
+
* - "An approval card survives a gateway restart. A tap on a still-valid
|
|
6
|
+
* card after a restart works; a card that expired during the outage is
|
|
7
|
+
* re-offered on the operator's return."
|
|
8
|
+
* - "Cards time out (60-min default) and the timeout wakes the agent as a
|
|
9
|
+
* timeout, not a denial ... An expired card is re-offered when the
|
|
10
|
+
* operator returns."
|
|
11
|
+
*
|
|
12
|
+
* The sibling suites pin each module in isolation (pending-card-store.test.ts,
|
|
13
|
+
* pending-card-expiry.test.ts with mocked deps, approval-timeout-inbound-
|
|
14
|
+
* builders.test.ts) and pending-card-durability-wiring.test.ts pins the
|
|
15
|
+
* gateway wiring at the source-text level. This suite wires the REAL modules
|
|
16
|
+
* together — file-backed durable store + real missed-approvals store + real
|
|
17
|
+
* expiry sweep + real timeout builders — across a simulated gateway bounce
|
|
18
|
+
* (store re-instantiation from the same state dir), asserting the promised
|
|
19
|
+
* OUTCOMES rather than any one module's contract.
|
|
20
|
+
*
|
|
21
|
+
* SCOPE CAVEAT: the expiry predicate / remove / deliver / recordMiss closures
|
|
22
|
+
* below are TEST-SUPPLIED stand-ins for gateway.ts's inline closures (which
|
|
23
|
+
* close over module-level gateway state and can't be imported without a
|
|
24
|
+
* production refactor). This suite therefore proves the modules COMPOSE
|
|
25
|
+
* correctly; that gateway.ts actually wires them this way is pinned
|
|
26
|
+
* separately by pending-card-durability-wiring.test.ts's source greps. The
|
|
27
|
+
* user-visible acceptance twin is
|
|
28
|
+
* `uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
32
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
33
|
+
import { tmpdir } from 'node:os'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
createPendingCardStore,
|
|
38
|
+
type PersistedApprovalCard,
|
|
39
|
+
type PersistedVaultAccessCard,
|
|
40
|
+
} from '../gateway/pending-card-store.js'
|
|
41
|
+
import { createMissedApprovalsStore } from '../gateway/missed-approvals-store.js'
|
|
42
|
+
import { expirePendingCard, sweepExpiredEntries } from '../gateway/pending-card-expiry.js'
|
|
43
|
+
import { buildVaultAccessTimeoutInbound } from '../gateway/approval-timeout-inbound-builders.js'
|
|
44
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
45
|
+
|
|
46
|
+
// Test-local stand-in for the 60-min default the spec names. The production
|
|
47
|
+
// TTL comes from approvalTtlMs() in gateway/permission-timeout.ts; this suite
|
|
48
|
+
// does NOT assert that config plumbing — only how the modules behave around
|
|
49
|
+
// whatever TTL the sweep predicate encodes.
|
|
50
|
+
const TTL_MS = 60 * 60_000
|
|
51
|
+
const NOW = 1_700_000_000_000
|
|
52
|
+
|
|
53
|
+
function accessCard(overrides: Partial<PersistedVaultAccessCard> = {}): PersistedVaultAccessCard {
|
|
54
|
+
return {
|
|
55
|
+
family: 'vault_request_access',
|
|
56
|
+
stageId: 'stage-1',
|
|
57
|
+
agent: 'test-agent',
|
|
58
|
+
chatId: '12345',
|
|
59
|
+
cardMessageId: 777,
|
|
60
|
+
stagedAt: NOW - 60_000, // staged a minute ago — still valid
|
|
61
|
+
key: 'example/api-key',
|
|
62
|
+
scope: 'read',
|
|
63
|
+
ttlSeconds: 30 * 86_400,
|
|
64
|
+
...overrides,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('approval/restart continuation — spec outcomes across a gateway bounce', () => {
|
|
69
|
+
let stateDir: string
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
stateDir = mkdtempSync(join(tmpdir(), 'approval-card-restart-outcome-'))
|
|
73
|
+
})
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('a still-valid card record survives the bounce intact, and removing it clears the durable store', () => {
|
|
79
|
+
// Gateway process #1 stages the card.
|
|
80
|
+
const before = createPendingCardStore(stateDir)
|
|
81
|
+
const card = accessCard()
|
|
82
|
+
before.add(card)
|
|
83
|
+
|
|
84
|
+
// ── gateway restarts: all in-memory maps are gone ──
|
|
85
|
+
const after = createPendingCardStore(stateDir)
|
|
86
|
+
const restored = after.loadAll()
|
|
87
|
+
expect(restored).toHaveLength(1)
|
|
88
|
+
// Metadata roundtrips byte-identical — stagedAt included, so the expiry
|
|
89
|
+
// deadline the sweep computes from it is unchanged by the bounce.
|
|
90
|
+
expect(restored[0]).toEqual(card)
|
|
91
|
+
|
|
92
|
+
// Post-restart resolution path (what a tap handler does with the store):
|
|
93
|
+
// remove() drops the durable record so a later boot can't resurrect it.
|
|
94
|
+
// The tap handler's Telegram/grant side is NOT exercised here — that's
|
|
95
|
+
// the UAT twin's job.
|
|
96
|
+
after.remove(card.stageId)
|
|
97
|
+
expect(after.loadAll()).toHaveLength(0)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('a card that expired during the outage wakes the agent ONCE as timeout-not-denial and records the re-offer', () => {
|
|
101
|
+
// Staged well past the TTL before the bounce.
|
|
102
|
+
const expired = accessCard({ stageId: 'stage-expired', stagedAt: NOW - TTL_MS - 60_000 })
|
|
103
|
+
createPendingCardStore(stateDir).add(expired)
|
|
104
|
+
|
|
105
|
+
// ── restart: restore into the in-memory map, then the reaper sweeps ──
|
|
106
|
+
const store = createPendingCardStore(stateDir)
|
|
107
|
+
const missed = createMissedApprovalsStore(stateDir)
|
|
108
|
+
const map = new Map<string, PersistedVaultAccessCard>(
|
|
109
|
+
store.loadAll().map(e => [e.stageId, e as PersistedVaultAccessCard]),
|
|
110
|
+
)
|
|
111
|
+
expect(map.size).toBe(1)
|
|
112
|
+
|
|
113
|
+
const delivered: InboundMessage[] = []
|
|
114
|
+
const sweep = () =>
|
|
115
|
+
sweepExpiredEntries(
|
|
116
|
+
map,
|
|
117
|
+
(v, now) => v.stagedAt < now - TTL_MS,
|
|
118
|
+
(stageId, v) => {
|
|
119
|
+
expirePendingCard({
|
|
120
|
+
remove: () => {
|
|
121
|
+
map.delete(stageId)
|
|
122
|
+
store.remove(stageId)
|
|
123
|
+
},
|
|
124
|
+
editCard: () => {},
|
|
125
|
+
buildInbound: () =>
|
|
126
|
+
buildVaultAccessTimeoutInbound({
|
|
127
|
+
agent: v.agent,
|
|
128
|
+
chatId: v.chatId,
|
|
129
|
+
stageId,
|
|
130
|
+
timeoutMinutes: Math.round(TTL_MS / 60_000),
|
|
131
|
+
nowMs: NOW,
|
|
132
|
+
key: v.key,
|
|
133
|
+
scope: v.scope,
|
|
134
|
+
}),
|
|
135
|
+
deliver: m => {
|
|
136
|
+
delivered.push(m)
|
|
137
|
+
return true
|
|
138
|
+
},
|
|
139
|
+
recordMiss: () =>
|
|
140
|
+
missed.add({
|
|
141
|
+
requestId: `approval-card:${stageId}`,
|
|
142
|
+
toolName: 'vault_request_access',
|
|
143
|
+
action: `vault access to ${v.key}`,
|
|
144
|
+
chatId: v.chatId,
|
|
145
|
+
timedOutAt: NOW,
|
|
146
|
+
}),
|
|
147
|
+
log: () => {},
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
NOW,
|
|
151
|
+
() => {},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
sweep()
|
|
155
|
+
|
|
156
|
+
// Exactly one wake, and it is a TIMEOUT, never a denial.
|
|
157
|
+
expect(delivered).toHaveLength(1)
|
|
158
|
+
expect(delivered[0].text).toMatch(/TIMEOUT, not a denial/)
|
|
159
|
+
expect(delivered[0].text).toContain('example/api-key')
|
|
160
|
+
expect(delivered[0].meta?.source).toBe('vault_grant_timeout')
|
|
161
|
+
|
|
162
|
+
// The re-offer is durably recorded for the operator's return.
|
|
163
|
+
const pending = missed.listPending()
|
|
164
|
+
expect(pending).toHaveLength(1)
|
|
165
|
+
expect(pending[0].requestId).toBe('approval-card:stage-expired')
|
|
166
|
+
|
|
167
|
+
// Durable record is gone — a later boot can't resurrect the card.
|
|
168
|
+
expect(store.loadAll()).toHaveLength(0)
|
|
169
|
+
|
|
170
|
+
// A second boot's sweep restores from the (now empty) durable store and
|
|
171
|
+
// therefore fires nothing — the remove() step cleared BOTH the in-memory
|
|
172
|
+
// map and the store, so no later process can re-deliver the wake. (The
|
|
173
|
+
// intra-process single-shot ordering guarantee — remove() before any
|
|
174
|
+
// fallible side effect — is pinned behaviorally in
|
|
175
|
+
// pending-card-expiry.test.ts, not here.)
|
|
176
|
+
const rebootMap = new Map<string, PersistedVaultAccessCard>(
|
|
177
|
+
createPendingCardStore(stateDir)
|
|
178
|
+
.loadAll()
|
|
179
|
+
.map(e => [e.stageId, e as PersistedVaultAccessCard]),
|
|
180
|
+
)
|
|
181
|
+
expect(rebootMap.size).toBe(0)
|
|
182
|
+
let secondBootExpiries = 0
|
|
183
|
+
sweepExpiredEntries(
|
|
184
|
+
rebootMap,
|
|
185
|
+
(v, now) => v.stagedAt < now - TTL_MS,
|
|
186
|
+
() => {
|
|
187
|
+
secondBootExpiries += 1
|
|
188
|
+
},
|
|
189
|
+
NOW,
|
|
190
|
+
() => {},
|
|
191
|
+
)
|
|
192
|
+
expect(secondBootExpiries).toBe(0)
|
|
193
|
+
expect(delivered).toHaveLength(1)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('the post-restart sweep never expires a still-valid card (no false timeout on bounce)', () => {
|
|
197
|
+
const valid = accessCard({ stageId: 'stage-valid', stagedAt: NOW - 60_000 })
|
|
198
|
+
createPendingCardStore(stateDir).add(valid)
|
|
199
|
+
|
|
200
|
+
const store = createPendingCardStore(stateDir)
|
|
201
|
+
const map = new Map<string, PersistedApprovalCard>(store.loadAll().map(e => [e.stageId, e]))
|
|
202
|
+
|
|
203
|
+
let expiredCount = 0
|
|
204
|
+
sweepExpiredEntries(
|
|
205
|
+
map,
|
|
206
|
+
(v, now) => v.stagedAt < now - TTL_MS,
|
|
207
|
+
() => {
|
|
208
|
+
expiredCount += 1
|
|
209
|
+
},
|
|
210
|
+
NOW,
|
|
211
|
+
() => {},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(expiredCount).toBe(0)
|
|
215
|
+
expect(map.size).toBe(1) // the card stays live and tappable
|
|
216
|
+
expect(store.loadAll()).toHaveLength(1)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2923 — boot-card flood-wait suppression. When a Telegram per-bot flood ban
|
|
3
|
+
* is active, a restart's boot card is a NON-ESSENTIAL send straight into the
|
|
4
|
+
* open window that can reset/extend the ban. startBootCard must SKIP the send
|
|
5
|
+
* (and log) while the flood-wait is active, and post normally once it lifts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { startBootCard } from '../gateway/boot-card.js'
|
|
13
|
+
import type { BotApiForBootCard } from '../gateway/boot-card.js'
|
|
14
|
+
import { computeFloodWait, writeFloodState, floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
|
|
15
|
+
import { createRetryApiCall } from '../retry-api-call.js'
|
|
16
|
+
import { errors } from './fake-bot-api.js'
|
|
17
|
+
|
|
18
|
+
let dir: string
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), 'boot-card-flood-'))
|
|
22
|
+
})
|
|
23
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
24
|
+
|
|
25
|
+
function makeBot() {
|
|
26
|
+
const sends: string[] = []
|
|
27
|
+
const bot: BotApiForBootCard = {
|
|
28
|
+
sendMessage: async (chatId, _text, _opts) => {
|
|
29
|
+
sends.push(chatId)
|
|
30
|
+
return { message_id: 1001 }
|
|
31
|
+
},
|
|
32
|
+
editMessageText: async () => ({}),
|
|
33
|
+
}
|
|
34
|
+
return { bot, sends }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mkOpts(floodPath: string, nowMs: number, overrides: Record<string, unknown> = {}) {
|
|
38
|
+
return {
|
|
39
|
+
agentName: 'TestAgent',
|
|
40
|
+
agentSlug: 'test-agent',
|
|
41
|
+
version: 'v0.0.0-test',
|
|
42
|
+
agentDir: dir,
|
|
43
|
+
gatewayInfo: { pid: 1, startedAtMs: nowMs },
|
|
44
|
+
restartReason: 'graceful' as const,
|
|
45
|
+
agentLiveWindowMs: 0,
|
|
46
|
+
settleWindowMs: 1_000_000,
|
|
47
|
+
floodStatePath: floodPath,
|
|
48
|
+
nowMs: () => nowMs,
|
|
49
|
+
...overrides,
|
|
50
|
+
} as unknown as Parameters<typeof startBootCard>[3]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it('SUPPRESSES the boot card while a flood-wait is active', async () => {
|
|
54
|
+
const p = floodStatePath(dir)
|
|
55
|
+
const now = 1_000_000
|
|
56
|
+
writeFloodState(p, computeFloodWait(null, 68 * 60, now)) // ~68 min ban
|
|
57
|
+
const logs: string[] = []
|
|
58
|
+
const { bot, sends } = makeBot()
|
|
59
|
+
const handle = await startBootCard('chat1', undefined, bot, mkOpts(p, now), undefined, (l) =>
|
|
60
|
+
logs.push(l),
|
|
61
|
+
)
|
|
62
|
+
expect(sends).toEqual([]) // nothing sent into the open ban window
|
|
63
|
+
expect(handle.messageId).toBe(-1)
|
|
64
|
+
expect(logs.join('')).toMatch(/SUPPRESSED/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('POSTS the boot card once the flood-wait has lifted', async () => {
|
|
68
|
+
const p = floodStatePath(dir)
|
|
69
|
+
const banStart = 1_000_000
|
|
70
|
+
writeFloodState(p, computeFloodWait(null, 60, banStart))
|
|
71
|
+
const afterBan = banStart + 60_000 + 1
|
|
72
|
+
const { bot, sends } = makeBot()
|
|
73
|
+
await startBootCard('chat1', undefined, bot, mkOpts(p, afterBan), undefined, () => {})
|
|
74
|
+
expect(sends).toEqual(['chat1'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('POSTS normally when no flood state file exists (back-compat)', async () => {
|
|
78
|
+
const p = floodStatePath(dir) // never written
|
|
79
|
+
const { bot, sends } = makeBot()
|
|
80
|
+
await startBootCard('chat1', undefined, bot, mkOpts(p, 1_000_000), undefined, () => {})
|
|
81
|
+
expect(sends).toEqual(['chat1'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('INTEGRATION: a 429 through the real retry path suppresses a later boot card', async () => {
|
|
85
|
+
// Wire the two halves exactly as gateway.ts does: the retry wrapper's
|
|
86
|
+
// onFloodWait recorder and the boot card BOTH point at the same file.
|
|
87
|
+
const p = floodStatePath(dir)
|
|
88
|
+
const now = 5_000_000
|
|
89
|
+
const retry = createRetryApiCall({
|
|
90
|
+
sleep: async () => {}, // don't actually wait out the flood
|
|
91
|
+
onFloodWait: makeFloodWaitRecorder(p, () => now),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Drive a real GrammyError 429 through the wrapper (first call floods, then
|
|
95
|
+
// succeeds) — this is what records the window on disk.
|
|
96
|
+
let n = 0
|
|
97
|
+
await retry(async () => {
|
|
98
|
+
if (n++ === 0) throw errors.floodWait(4116) // ~68 min ban
|
|
99
|
+
return 'ok'
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Now a restart's boot card must be suppressed via the SAME file.
|
|
103
|
+
const { bot, sends } = makeBot()
|
|
104
|
+
const logs: string[] = []
|
|
105
|
+
const handle = await startBootCard('chat1', undefined, bot, mkOpts(p, now), undefined, (l) =>
|
|
106
|
+
logs.push(l),
|
|
107
|
+
)
|
|
108
|
+
expect(sends).toEqual([]) // not posted into the open ban window
|
|
109
|
+
expect(handle.messageId).toBe(-1)
|
|
110
|
+
expect(logs.join('')).toMatch(/SUPPRESSED/)
|
|
111
|
+
})
|