switchroom 0.19.11 → 0.19.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.
@@ -0,0 +1,472 @@
1
+ /**
2
+ * outbox.ts — durable per-turn outbox for guaranteed final-message delivery.
3
+ *
4
+ * Root defect (2026-07-22 incident, see
5
+ * `work/final-message-delivery/investigation.md`): every content-delivering
6
+ * machine in the gateway is anchored on a `CurrentTurn` created at gateway
7
+ * ENQUEUE of a Telegram inbound. Whole turn CLASSES never wake the gateway —
8
+ * `<task-notification>` sub-agent handbacks, background-worker completions,
9
+ * and any future harness wake type — so a real final answer written as plain
10
+ * transcript prose (never sent via the `reply` tool) was silently lost.
11
+ *
12
+ * The fix collapses delivery to ONE capture path and ONE delivery path:
13
+ *
14
+ * CAPTURE — the Stop hook (`hooks/silent-end-interrupt-stop.mjs`) fires on
15
+ * EVERY main-session turn end regardless of what woke the turn.
16
+ * When the turn ended with substantive undelivered trailing
17
+ * prose, it atomically writes an outbox record here. Capture keys
18
+ * on "a turn ended with unsent trailing prose", NOT on
19
+ * recognising the wake shape, so unknown/future turn classes are
20
+ * covered by construction and fail CLOSED.
21
+ *
22
+ * DELIVER — the gateway heartbeat sweep (`sweepOutbox`, wired on the
23
+ * existing ~15s tick, independent of turn lifecycle) claims each
24
+ * record with a rename-mutex, delivers it exactly once (guarded by
25
+ * a persistent delivered-keys journal keyed by the SAME turnNonce
26
+ * every delivering machine uses, plus the existing text
27
+ * `outboundDedup`), and clears it.
28
+ *
29
+ * Exactly-once (H1): every delivering machine — the sweep AND the legacy
30
+ * turn-flush / captured-prose bridge / exhausted-fallback / reply send — writes
31
+ * the delivered-keys journal under the turnNonce and checks it before sending.
32
+ * One namespace, one nonce end to end → no double-post.
33
+ *
34
+ * Nonce (H2): `deriveTurnNonce` uses the gateway's `deriveTurnId`
35
+ * (`${chatKey}#${messageId}`) shape whenever the anchor envelope carries a real
36
+ * message_id (unique per inbound), else `sha256(anchorTimestampMs + '\n' +
37
+ * content)`. The ms timestamp makes a re-notifying task (byte-identical
38
+ * `<task-notification>` content) collision-free, and serialized concurrent
39
+ * sibling handbacks get distinct enqueue timestamps → distinct nonces.
40
+ *
41
+ * Routing (H3/F2): a record captured off an envelope-less anchor (task-notification
42
+ * handback, chained/background-spawned dispatch) carries no chatId. `resolveOutboxChat`
43
+ * resolves it via a transitive registry-chain lookup first, then the record's OWN
44
+ * stamped per-session origin chat (`originChatId`, captured at Stop from this
45
+ * session's most recent real `<channel>` inbound). It never consults a
46
+ * gateway-global "last chat anyone messaged" fallback (that could leak private
47
+ * content cross-chat), and FAILS CLOSED — holding the record — if neither
48
+ * resolves.
49
+ *
50
+ * Pure cores (`deriveTurnNonce`, `decideOutboxSweep`, `resolveOutboxChat`) are
51
+ * side-effect-free and unit-tested; the IO helpers are thin and best-effort.
52
+ */
53
+
54
+ import { createHash } from 'node:crypto'
55
+ import {
56
+ existsSync,
57
+ mkdirSync,
58
+ readFileSync,
59
+ readdirSync,
60
+ renameSync,
61
+ statSync,
62
+ unlinkSync,
63
+ writeFileSync,
64
+ appendFileSync,
65
+ } from 'node:fs'
66
+ import { join } from 'node:path'
67
+ import { homedir } from 'node:os'
68
+
69
+ /** One captured, not-yet-delivered final message for a single main-session turn. */
70
+ export interface OutboxRecord {
71
+ /** Unique per-turn nonce — the shared delivery key (see `deriveTurnNonce`). */
72
+ turnNonce: string
73
+ /**
74
+ * Destination chat. May be `null` at capture time for an envelope-less anchor
75
+ * (task-notification handback); resolved at sweep via `resolveOutboxChat`.
76
+ */
77
+ chatId: string | null
78
+ /** Optional forum thread id. */
79
+ threadId: number | null
80
+ /** The undelivered final-answer prose. */
81
+ text: string
82
+ /** sha256 of `text` — the text-dedup key, matches the gateway's `outboundDedup`. */
83
+ textSha256: string
84
+ /** Wall-clock ms of capture. */
85
+ createdAt: number
86
+ /** Wake/anchor class: 'channel' | 'task-notification' | 'cron' | 'unknown' | … (diagnostic). */
87
+ source: string
88
+ /**
89
+ * Raw anchor content (the enqueue line's `content`) — kept so the sweep's
90
+ * transitive registry lookup can extract a `<task-id>` for chained-dispatch
91
+ * routing (H3). Absent for envelope-carrying anchors that already routed.
92
+ */
93
+ anchorContent?: string
94
+ /**
95
+ * Per-session ORIGIN chat, stamped at capture time from THIS session's own
96
+ * transcript (its most-recent real Telegram `<channel>` inbound) — the
97
+ * conversation of record for the session that produced this handback. Used as
98
+ * the scoped routing fallback for an envelope-less record whose registry chain
99
+ * fails (H3/F2), REPLACING the old gateway-global "last chat anyone messaged"
100
+ * fallback that could leak private content into an unrelated chat. Absent when
101
+ * the session has no prior channel inbound → the record fails CLOSED (held,
102
+ * never delivered to an arbitrary chat).
103
+ */
104
+ originChatId?: string | null
105
+ /** Forum thread of the per-session origin chat (see `originChatId`). */
106
+ originThreadId?: number | null
107
+ }
108
+
109
+ /** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
110
+ export interface DeliveredEntry {
111
+ turnNonce: string
112
+ textSha256: string
113
+ tgMessageId?: number
114
+ ts: number
115
+ }
116
+
117
+ export function sha256Hex(s: string): string {
118
+ return createHash('sha256').update(s, 'utf8').digest('hex')
119
+ }
120
+
121
+ /**
122
+ * Build the shared per-turn delivery nonce (H1 + H2).
123
+ *
124
+ * - `messageId` present → `${chatKey}#${messageId}` — byte-identical to the
125
+ * gateway's `deriveTurnId`, so the sweep and the legacy flush/bridge journal
126
+ * under the SAME key for a gateway-visible turn (no double-post).
127
+ * - `messageId` absent → `sha256(anchorTimestampMs + '\n' + content)`. The ms
128
+ * timestamp guarantees uniqueness for a re-notifying task (identical content)
129
+ * and for serialized concurrent siblings (distinct enqueue timestamps).
130
+ */
131
+ export function deriveTurnNonce(args: {
132
+ chatId: string | null
133
+ threadId: number | null
134
+ messageId: string | null
135
+ anchorTimestampMs: number
136
+ anchorContent: string
137
+ }): string {
138
+ const { chatId, threadId, messageId, anchorTimestampMs, anchorContent } = args
139
+ if (chatId != null && messageId != null && messageId !== '' && String(messageId) !== '0') {
140
+ const key = `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
141
+ return `${key}#${messageId}`
142
+ }
143
+ return sha256Hex(`${anchorTimestampMs}\n${anchorContent}`)
144
+ }
145
+
146
+ export function resolveStateDir(explicit?: string): string {
147
+ if (explicit != null && explicit !== '') return explicit
148
+ const env = process.env.TELEGRAM_STATE_DIR
149
+ if (env != null && env !== '') return env
150
+ const home = process.env.HOME ?? homedir()
151
+ return join(home, '.claude', 'channels', 'telegram')
152
+ }
153
+
154
+ export function resolveOutboxDir(stateDir?: string): string {
155
+ return join(resolveStateDir(stateDir), 'outbox')
156
+ }
157
+
158
+ const JOURNAL_FILE = 'delivered.jsonl'
159
+ /** Records older than this are delivered with a "(delayed)" prefix, never dropped. */
160
+ export const OUTBOX_MAX_AGE_MS = 30 * 60_000
161
+ /** Quiet period before a record is eligible for sweep — lets a same-turn legacy flush land first. */
162
+ export const OUTBOX_QUIET_MS = 5_000
163
+ /** A `.sending` claim older than this is presumed crashed and re-queued. */
164
+ export const OUTBOX_SENDING_TIMEOUT_MS = 60_000
165
+
166
+ /** Atomically write an outbox record (tmp + rename). Best-effort; never throws. */
167
+ export function writeOutboxRecordAtomic(record: OutboxRecord, stateDir?: string): boolean {
168
+ const dir = resolveOutboxDir(stateDir)
169
+ try {
170
+ mkdirSync(dir, { recursive: true })
171
+ const finalPath = join(dir, `${record.turnNonce}.json`)
172
+ // Idempotent capture: if a record for this nonce already exists (Stop hook
173
+ // re-fired, or gateway already captured), do not clobber — the first
174
+ // capture wins and the sweep is the single deliverer.
175
+ if (existsSync(finalPath)) return true
176
+ const tmpPath = join(dir, `.${record.turnNonce}.${process.pid}.tmp`)
177
+ writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 })
178
+ renameSync(tmpPath, finalPath)
179
+ return true
180
+ } catch {
181
+ return false
182
+ }
183
+ }
184
+
185
+ /** List pending (unclaimed) record filenames — `*.json` excluding the journal. */
186
+ export function listPendingRecords(stateDir?: string): string[] {
187
+ const dir = resolveOutboxDir(stateDir)
188
+ try {
189
+ return readdirSync(dir).filter(
190
+ (f) => f.endsWith('.json') && f !== JOURNAL_FILE && !f.startsWith('.'),
191
+ )
192
+ } catch {
193
+ return []
194
+ }
195
+ }
196
+
197
+ /** Read + parse a record file. Null on missing/corrupt. */
198
+ export function readOutboxRecord(fileName: string, stateDir?: string): OutboxRecord | null {
199
+ try {
200
+ const raw = readFileSync(join(resolveOutboxDir(stateDir), fileName), 'utf8')
201
+ return JSON.parse(raw) as OutboxRecord
202
+ } catch {
203
+ return null
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Claim a record by renaming `<nonce>.json` → `<nonce>.sending`. Rename is the
209
+ * mutex: two concurrent sweeps cannot both claim the same record. Returns the
210
+ * claimed path, or null if the claim lost the race.
211
+ */
212
+ export function claimRecord(nonce: string, stateDir?: string): string | null {
213
+ const dir = resolveOutboxDir(stateDir)
214
+ const from = join(dir, `${nonce}.json`)
215
+ const to = join(dir, `${nonce}.sending`)
216
+ try {
217
+ renameSync(from, to)
218
+ return to
219
+ } catch {
220
+ return null
221
+ }
222
+ }
223
+
224
+ /** Release a lost/failed claim back to pending (`<nonce>.sending` → `<nonce>.json`). */
225
+ export function releaseClaim(nonce: string, stateDir?: string): void {
226
+ const dir = resolveOutboxDir(stateDir)
227
+ try {
228
+ renameSync(join(dir, `${nonce}.sending`), join(dir, `${nonce}.json`))
229
+ } catch {
230
+ /* best-effort */
231
+ }
232
+ }
233
+
234
+ /** Delete a delivered record's claimed file. */
235
+ export function removeClaimed(nonce: string, stateDir?: string): void {
236
+ try {
237
+ unlinkSync(join(resolveOutboxDir(stateDir), `${nonce}.sending`))
238
+ } catch {
239
+ /* best-effort */
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Re-queue `.sending` claims older than the crash timeout — covers a crash
245
+ * after claim but before send. Idempotent; safe to run every sweep.
246
+ */
247
+ export function reclaimStaleSending(stateDir?: string, now: number = Date.now()): void {
248
+ const dir = resolveOutboxDir(stateDir)
249
+ let files: string[]
250
+ try {
251
+ files = readdirSync(dir).filter((f) => f.endsWith('.sending'))
252
+ } catch {
253
+ return
254
+ }
255
+ for (const f of files) {
256
+ try {
257
+ const st = statSync(join(dir, f))
258
+ if (now - st.mtimeMs > OUTBOX_SENDING_TIMEOUT_MS) {
259
+ renameSync(join(dir, f), join(dir, f.replace(/\.sending$/, '.json')))
260
+ }
261
+ } catch {
262
+ /* best-effort */
263
+ }
264
+ }
265
+ }
266
+
267
+ /** Read the set of already-delivered turnNonces from the journal. */
268
+ export function readDeliveredNonces(stateDir?: string): Set<string> {
269
+ const set = new Set<string>()
270
+ const path = join(resolveOutboxDir(stateDir), JOURNAL_FILE)
271
+ if (!existsSync(path)) return set
272
+ try {
273
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
274
+ if (!line) continue
275
+ try {
276
+ const e = JSON.parse(line) as DeliveredEntry
277
+ if (e.turnNonce) set.add(e.turnNonce)
278
+ } catch {
279
+ /* skip corrupt line */
280
+ }
281
+ }
282
+ } catch {
283
+ /* best-effort */
284
+ }
285
+ return set
286
+ }
287
+
288
+ /** True iff `nonce` is already journaled as delivered. */
289
+ export function outboxAlreadyDelivered(nonce: string, stateDir?: string): boolean {
290
+ return readDeliveredNonces(stateDir).has(nonce)
291
+ }
292
+
293
+ /**
294
+ * Max delivered-keys kept in the journal. On exceeding `JOURNAL_ROTATE_AT` lines
295
+ * the journal is compacted down to the newest `JOURNAL_KEEP` entries — bounding
296
+ * on-disk growth and per-tick read cost. A duplicate arriving after its nonce
297
+ * has aged out of the kept window is still caught by the record having been
298
+ * deleted at delivery (the pending file is gone), so compaction never
299
+ * reintroduces a double-post for any live record.
300
+ */
301
+ export const JOURNAL_KEEP = 2_000
302
+ export const JOURNAL_ROTATE_AT = 4_000
303
+
304
+ /**
305
+ * Append a delivered entry to the journal (intent/outcome). Called by EVERY
306
+ * delivering machine (sweep, legacy flush, captured-prose bridge, exhausted
307
+ * fallback, final-answer reply send) under the SAME nonce — the shared
308
+ * exactly-once namespace (H1). Best-effort; never throws. Compacts the journal
309
+ * in place once it grows past `JOURNAL_ROTATE_AT` lines.
310
+ */
311
+ export function appendDelivered(entry: DeliveredEntry, stateDir?: string): void {
312
+ const dir = resolveOutboxDir(stateDir)
313
+ const path = join(dir, JOURNAL_FILE)
314
+ try {
315
+ mkdirSync(dir, { recursive: true })
316
+ appendFileSync(path, JSON.stringify(entry) + '\n', { mode: 0o600 })
317
+ compactJournalIfLarge(path)
318
+ } catch {
319
+ /* best-effort */
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Rewrite the journal keeping only its newest `JOURNAL_KEEP` non-empty lines
325
+ * once it exceeds `JOURNAL_ROTATE_AT`. Atomic (tmp + rename); best-effort.
326
+ */
327
+ function compactJournalIfLarge(path: string): void {
328
+ try {
329
+ const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.length > 0)
330
+ if (lines.length <= JOURNAL_ROTATE_AT) return
331
+ const kept = lines.slice(lines.length - JOURNAL_KEEP)
332
+ const tmp = `${path}.${process.pid}.compact`
333
+ writeFileSync(tmp, kept.join('\n') + '\n', { mode: 0o600 })
334
+ renameSync(tmp, path)
335
+ } catch {
336
+ /* best-effort */
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Delete any pending outbox record for `nonce` (the reply-path clear-by-nonce,
342
+ * plus the journal write so a race-in-flight sweep also skips). Called when a
343
+ * genuine reply/flush delivered this turn's answer, so the sweep never
344
+ * re-sends. Best-effort.
345
+ */
346
+ export function clearOutboxRecord(nonce: string, stateDir?: string): void {
347
+ const dir = resolveOutboxDir(stateDir)
348
+ for (const suffix of ['.json', '.sending']) {
349
+ try {
350
+ unlinkSync(join(dir, `${nonce}${suffix}`))
351
+ } catch {
352
+ /* best-effort */
353
+ }
354
+ }
355
+ }
356
+
357
+ export type OutboxSweepAction =
358
+ | 'send'
359
+ | 'send-delayed'
360
+ | 'skip-journaled'
361
+ | 'skip-quiet'
362
+ | 'skip-dedup'
363
+ | 'skip-unroutable'
364
+
365
+ export interface OutboxSweepDecision {
366
+ action: OutboxSweepAction
367
+ /** The text to deliver (with any "(delayed)"/"(from background task)" prefix). */
368
+ text?: string
369
+ }
370
+
371
+ /**
372
+ * Pure sweep decision for one record. No IO — the caller injects the journal
373
+ * set, the text-dedup verdict, and the resolved chat.
374
+ *
375
+ * skip-journaled — nonce already delivered (exactly-once, H1).
376
+ * skip-quiet — inside the quiet period; let a same-turn legacy flush land.
377
+ * skip-dedup — identical text already delivered (the in-memory
378
+ * `outboundDedup` cache; NOT a persistent/SQLite store —
379
+ * the durable exactly-once guard is the delivered-keys
380
+ * journal keyed by turnNonce, checked above).
381
+ * skip-unroutable — no chat could be resolved (H3 exhausted) — keep the record.
382
+ * send — deliver now.
383
+ * send-delayed — older than max-age; deliver with a "(delayed)" prefix, never drop.
384
+ */
385
+ export function decideOutboxSweep(input: {
386
+ record: Pick<OutboxRecord, 'turnNonce' | 'text' | 'createdAt'>
387
+ now: number
388
+ deliveredNonces: Set<string>
389
+ textAlreadyDelivered: boolean
390
+ routable: boolean
391
+ routePrefix?: string
392
+ quietMs?: number
393
+ maxAgeMs?: number
394
+ }): OutboxSweepDecision {
395
+ const {
396
+ record,
397
+ now,
398
+ deliveredNonces,
399
+ textAlreadyDelivered,
400
+ routable,
401
+ routePrefix = '',
402
+ quietMs = OUTBOX_QUIET_MS,
403
+ maxAgeMs = OUTBOX_MAX_AGE_MS,
404
+ } = input
405
+ if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
406
+ const age = now - record.createdAt
407
+ if (age < quietMs) return { action: 'skip-quiet' }
408
+ if (textAlreadyDelivered) return { action: 'skip-dedup' }
409
+ if (!routable) return { action: 'skip-unroutable' }
410
+ const delayed = age > maxAgeMs
411
+ const prefix = (delayed ? '(delayed) ' : '') + routePrefix
412
+ return { action: delayed ? 'send-delayed' : 'send', text: prefix + record.text }
413
+ }
414
+
415
+ export interface ResolvedChat {
416
+ chatId: string
417
+ threadId: number | null
418
+ /** How the chat was resolved — 'anchor' (envelope), 'registry' (H3 chain), 'origin' (per-session fallback). */
419
+ via: 'anchor' | 'registry' | 'origin'
420
+ }
421
+
422
+ /**
423
+ * Resolve the destination chat for a record (H3 / F2).
424
+ *
425
+ * 1. anchor — the record already carries a chatId (envelope-bearing turn).
426
+ * 2. registry — transitive `<task-id>` → registry-row → originating chatKey
427
+ * lookup, recursing up a chained/background-spawned dispatch.
428
+ * 3. origin — the record's OWN stamped per-session origin chat
429
+ * (`originChatId`), captured at Stop from this session's most
430
+ * recent real `<channel>` inbound. This is SCOPED to the record
431
+ * (F2): it can never route to "whatever chat messaged the
432
+ * gateway last" the way the retired global last-inbound file
433
+ * could, so a DM-origin handback can never leak into an
434
+ * unrelated group. The caller adds a "(from background task)"
435
+ * prefix for this route.
436
+ *
437
+ * FAIL CLOSED: if none of the three resolves, returns null — the sweep HOLDS the
438
+ * record (skip-unroutable) rather than delivering to an arbitrary chat.
439
+ *
440
+ * Pure — the caller injects `registryChainLookup`.
441
+ */
442
+ export function resolveOutboxChat(
443
+ record: Pick<OutboxRecord, 'chatId' | 'threadId' | 'anchorContent' | 'originChatId' | 'originThreadId'>,
444
+ deps: {
445
+ registryChainLookup?: (anchorContent: string) => { chatId: string; threadId: number | null } | null
446
+ },
447
+ ): ResolvedChat | null {
448
+ if (record.chatId != null && record.chatId !== '') {
449
+ return { chatId: record.chatId, threadId: record.threadId ?? null, via: 'anchor' }
450
+ }
451
+ if (record.anchorContent && deps.registryChainLookup) {
452
+ const hit = deps.registryChainLookup(record.anchorContent)
453
+ if (hit != null) return { chatId: hit.chatId, threadId: hit.threadId, via: 'registry' }
454
+ }
455
+ if (record.originChatId != null && record.originChatId !== '') {
456
+ return { chatId: record.originChatId, threadId: record.originThreadId ?? null, via: 'origin' }
457
+ }
458
+ return null
459
+ }
460
+
461
+ /**
462
+ * Extract a `<task-id>` (or `task_id="…"`/`taskId`) from a task-notification
463
+ * anchor's content, for the H3 registry-chain lookup. Null if none.
464
+ */
465
+ export function extractTaskId(anchorContent: string): string | null {
466
+ if (typeof anchorContent !== 'string') return null
467
+ const m =
468
+ anchorContent.match(/<task-id>\s*([^<\s]+)\s*<\/task-id>/) ??
469
+ anchorContent.match(/task[_-]?id="([^"]+)"/i) ??
470
+ anchorContent.match(/task[_-]?id:\s*([^\s,}"']+)/i)
471
+ return m ? m[1] : null
472
+ }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # bun-test-ci.sh — hang-watchdog + automatic retry wrapper for the
4
+ # telegram-plugin Bun test suite (the `bun-test-run` CI job).
5
+ #
6
+ # WHY THIS EXISTS
7
+ # ---------------
8
+ # The `telegram-plugin/tests/subagent-watcher*` tests exercise the real
9
+ # Node/Bun `fs.watch` API. Under CPU contention (the hosted 2-vCPU GitHub
10
+ # runner) Bun's runtime intermittently DEADLOCKS inside `fs.watch` — the
11
+ # `bun` process wedges in `futex_do_wait` with ZERO child processes, so it
12
+ # is not a stuck subprocess or a leaked user handle but an internal Bun
13
+ # scheduler deadlock. Symptom: the suite prints a few thousand of ~8970
14
+ # tests, then goes idle for minutes until the job's hard timeout cancels
15
+ # the whole run — turning an unrelated flake into a red required check on
16
+ # every telegram-plugin PR. It reproduces locally ~1-in-7 runs under
17
+ # `taskset -c 0`; it is NOT an assertion failure (zero `(fail)` lines).
18
+ #
19
+ # Bun's own `--timeout` does NOT preempt it (the deadlocked thread IS the
20
+ # scheduler), so the recovery has to happen one level up: an external
21
+ # watchdog that KILLS a wedged attempt and retries. Because the deadlock
22
+ # is nondeterministic, a retry almost always clears it.
23
+ #
24
+ # HANG vs REAL FAILURE
25
+ # --------------------
26
+ # We must never mask a genuine test failure by retrying it. A real Bun
27
+ # assertion failure exits non-zero WITHOUT the watchdog having to kill
28
+ # anything, so `timeout` reports the command's own exit code and we do NOT
29
+ # retry. Only a watchdog-initiated kill (GNU `timeout` exit status 124, or
30
+ # 128+signal when `--kill-after` escalates to SIGKILL) is treated as a
31
+ # hang and retried.
32
+ #
33
+ # Env overrides (defaults tuned for the 2-vCPU runner; healthy run ~50s):
34
+ # BUN_TEST_ATTEMPT_TIMEOUT per-attempt wall-clock budget, seconds (180)
35
+ # BUN_TEST_KILL_AFTER grace before SIGKILL escalation, seconds (30)
36
+ # BUN_TEST_MAX_ATTEMPTS total attempts before giving up (3)
37
+ set -uo pipefail
38
+
39
+ ATTEMPT_TIMEOUT="${BUN_TEST_ATTEMPT_TIMEOUT:-180}"
40
+ KILL_AFTER="${BUN_TEST_KILL_AFTER:-30}"
41
+ MAX_ATTEMPTS="${BUN_TEST_MAX_ATTEMPTS:-3}"
42
+
43
+ # Same explicit dir list as the Buildkite/CI invocation — running `bun
44
+ # test` with no args recurses into telegram-plugin/uat/scenarios/ which
45
+ # need live Telegram creds. Keep this in sync with ci-tests-plugin.yml if
46
+ # the surface changes. BUN_TEST_TARGETS (space-separated) overrides the
47
+ # list — used only to exercise the watchdog against a narrow subset.
48
+ if [ -n "${BUN_TEST_TARGETS:-}" ]; then
49
+ # shellcheck disable=SC2206
50
+ BUN_TEST_ARGS=(${BUN_TEST_TARGETS})
51
+ else
52
+ BUN_TEST_ARGS=(
53
+ admin-commands gateway registry secret-detect tests
54
+ channel-envelope-safety.test.ts
55
+ )
56
+ fi
57
+
58
+ attempt=1
59
+ while :; do
60
+ echo "::group::bun test — attempt ${attempt}/${MAX_ATTEMPTS} (per-attempt timeout ${ATTEMPT_TIMEOUT}s, kill-after ${KILL_AFTER}s)"
61
+ set +e
62
+ timeout --kill-after="${KILL_AFTER}s" "${ATTEMPT_TIMEOUT}s" \
63
+ bun test "${BUN_TEST_ARGS[@]}"
64
+ rc=$?
65
+ set -e
66
+ echo "::endgroup::"
67
+
68
+ if [ "$rc" -eq 0 ]; then
69
+ echo "bun test passed on attempt ${attempt}/${MAX_ATTEMPTS}"
70
+ exit 0
71
+ fi
72
+
73
+ # GNU timeout: 124 = timed out (SIGTERM sent); 137 = 128+9, SIGKILL from
74
+ # --kill-after escalation; 143 = 128+15, killed by SIGTERM. All three
75
+ # mean the watchdog had to kill a wedged attempt → a hang, not a failure.
76
+ case "$rc" in
77
+ 124 | 137 | 143)
78
+ echo "::warning::bun test attempt ${attempt} HUNG (watchdog killed after ${ATTEMPT_TIMEOUT}s) — known pre-existing Bun fs.watch deadlock in subagent-watcher tests. See telegram-plugin/scripts/bun-test-ci.sh."
79
+ if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
80
+ echo "::error::bun test hung on all ${MAX_ATTEMPTS} attempts — not a clean pass; failing the job."
81
+ exit 1
82
+ fi
83
+ attempt=$((attempt + 1))
84
+ continue
85
+ ;;
86
+ *)
87
+ echo "::error::bun test failed on attempt ${attempt} with exit code ${rc} — a real test failure (not a hang), NOT retrying."
88
+ exit "$rc"
89
+ ;;
90
+ esac
91
+ done