switchroom 0.19.12 → 0.19.14

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 (26) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +4 -2
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -0
  5. package/profiles/_base/start.sh.hbs +11 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1402 -1032
  7. package/telegram-plugin/final-answer-detect.ts +23 -0
  8. package/telegram-plugin/gateway/gateway.ts +29 -36
  9. package/telegram-plugin/gateway/outbound-send-path.ts +115 -8
  10. package/telegram-plugin/gateway/outbox-sweep.ts +320 -0
  11. package/telegram-plugin/gateway/stream-render.ts +26 -50
  12. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +120 -2
  13. package/telegram-plugin/hooks/silent-end-scan.mjs +311 -0
  14. package/telegram-plugin/outbox.ts +489 -0
  15. package/telegram-plugin/scripts/bun-test-ci.sh +91 -0
  16. package/telegram-plugin/temporal-normalize.ts +347 -0
  17. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +22 -12
  18. package/telegram-plugin/tests/outbound-send-path.test.ts +191 -0
  19. package/telegram-plugin/tests/outbox-capture-scan.test.ts +222 -0
  20. package/telegram-plugin/tests/outbox-delivery.test.ts +278 -0
  21. package/telegram-plugin/tests/outbox-hook-capture.test.ts +112 -0
  22. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
  23. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +68 -63
  24. package/telegram-plugin/tests/silent-end.test.ts +21 -28
  25. package/telegram-plugin/tests/temporal-normalize.test.ts +222 -0
  26. package/telegram-plugin/tests/turn-flush-safety.test.ts +23 -18
@@ -0,0 +1,489 @@
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
+ * #3510 instrumentation: was a qualifying reply already delivered through
109
+ * the gateway in the turn that produced this record? Stamped by the Stop
110
+ * hook from the SAME boolean that gates its capture-vs-election branch.
111
+ * After #3510 this is always `false` for a written record (a `true` routes
112
+ * to the single-writer election instead of the outbox), so a `true` here —
113
+ * or in a sweep journal entry — is direct evidence of a regression.
114
+ */
115
+ replyAlreadyDeliveredThisTurn?: boolean
116
+ }
117
+
118
+ /** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
119
+ export interface DeliveredEntry {
120
+ turnNonce: string
121
+ textSha256: string
122
+ tgMessageId?: number
123
+ ts: number
124
+ /**
125
+ * #3510 instrumentation: which machine delivered — the outbox sweep, or the
126
+ * gateway reply-path machinery (reply/stream_reply send, silent-anchor edit,
127
+ * captured-prose bridge). Absent on pre-#3510 journal lines.
128
+ */
129
+ deliverySource?: 'sweep' | 'reply-tool'
130
+ /** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
131
+ replyAlreadyDeliveredThisTurn?: boolean
132
+ }
133
+
134
+ export function sha256Hex(s: string): string {
135
+ return createHash('sha256').update(s, 'utf8').digest('hex')
136
+ }
137
+
138
+ /**
139
+ * Build the shared per-turn delivery nonce (H1 + H2).
140
+ *
141
+ * - `messageId` present → `${chatKey}#${messageId}` — byte-identical to the
142
+ * gateway's `deriveTurnId`, so the sweep and the legacy flush/bridge journal
143
+ * under the SAME key for a gateway-visible turn (no double-post).
144
+ * - `messageId` absent → `sha256(anchorTimestampMs + '\n' + content)`. The ms
145
+ * timestamp guarantees uniqueness for a re-notifying task (identical content)
146
+ * and for serialized concurrent siblings (distinct enqueue timestamps).
147
+ */
148
+ export function deriveTurnNonce(args: {
149
+ chatId: string | null
150
+ threadId: number | null
151
+ messageId: string | null
152
+ anchorTimestampMs: number
153
+ anchorContent: string
154
+ }): string {
155
+ const { chatId, threadId, messageId, anchorTimestampMs, anchorContent } = args
156
+ if (chatId != null && messageId != null && messageId !== '' && String(messageId) !== '0') {
157
+ const key = `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
158
+ return `${key}#${messageId}`
159
+ }
160
+ return sha256Hex(`${anchorTimestampMs}\n${anchorContent}`)
161
+ }
162
+
163
+ export function resolveStateDir(explicit?: string): string {
164
+ if (explicit != null && explicit !== '') return explicit
165
+ const env = process.env.TELEGRAM_STATE_DIR
166
+ if (env != null && env !== '') return env
167
+ const home = process.env.HOME ?? homedir()
168
+ return join(home, '.claude', 'channels', 'telegram')
169
+ }
170
+
171
+ export function resolveOutboxDir(stateDir?: string): string {
172
+ return join(resolveStateDir(stateDir), 'outbox')
173
+ }
174
+
175
+ const JOURNAL_FILE = 'delivered.jsonl'
176
+ /** Records older than this are delivered with a "(delayed)" prefix, never dropped. */
177
+ export const OUTBOX_MAX_AGE_MS = 30 * 60_000
178
+ /** Quiet period before a record is eligible for sweep — lets a same-turn legacy flush land first. */
179
+ export const OUTBOX_QUIET_MS = 5_000
180
+ /** A `.sending` claim older than this is presumed crashed and re-queued. */
181
+ export const OUTBOX_SENDING_TIMEOUT_MS = 60_000
182
+
183
+ /** Atomically write an outbox record (tmp + rename). Best-effort; never throws. */
184
+ export function writeOutboxRecordAtomic(record: OutboxRecord, stateDir?: string): boolean {
185
+ const dir = resolveOutboxDir(stateDir)
186
+ try {
187
+ mkdirSync(dir, { recursive: true })
188
+ const finalPath = join(dir, `${record.turnNonce}.json`)
189
+ // Idempotent capture: if a record for this nonce already exists (Stop hook
190
+ // re-fired, or gateway already captured), do not clobber — the first
191
+ // capture wins and the sweep is the single deliverer.
192
+ if (existsSync(finalPath)) return true
193
+ const tmpPath = join(dir, `.${record.turnNonce}.${process.pid}.tmp`)
194
+ writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 })
195
+ renameSync(tmpPath, finalPath)
196
+ return true
197
+ } catch {
198
+ return false
199
+ }
200
+ }
201
+
202
+ /** List pending (unclaimed) record filenames — `*.json` excluding the journal. */
203
+ export function listPendingRecords(stateDir?: string): string[] {
204
+ const dir = resolveOutboxDir(stateDir)
205
+ try {
206
+ return readdirSync(dir).filter(
207
+ (f) => f.endsWith('.json') && f !== JOURNAL_FILE && !f.startsWith('.'),
208
+ )
209
+ } catch {
210
+ return []
211
+ }
212
+ }
213
+
214
+ /** Read + parse a record file. Null on missing/corrupt. */
215
+ export function readOutboxRecord(fileName: string, stateDir?: string): OutboxRecord | null {
216
+ try {
217
+ const raw = readFileSync(join(resolveOutboxDir(stateDir), fileName), 'utf8')
218
+ return JSON.parse(raw) as OutboxRecord
219
+ } catch {
220
+ return null
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Claim a record by renaming `<nonce>.json` → `<nonce>.sending`. Rename is the
226
+ * mutex: two concurrent sweeps cannot both claim the same record. Returns the
227
+ * claimed path, or null if the claim lost the race.
228
+ */
229
+ export function claimRecord(nonce: string, stateDir?: string): string | null {
230
+ const dir = resolveOutboxDir(stateDir)
231
+ const from = join(dir, `${nonce}.json`)
232
+ const to = join(dir, `${nonce}.sending`)
233
+ try {
234
+ renameSync(from, to)
235
+ return to
236
+ } catch {
237
+ return null
238
+ }
239
+ }
240
+
241
+ /** Release a lost/failed claim back to pending (`<nonce>.sending` → `<nonce>.json`). */
242
+ export function releaseClaim(nonce: string, stateDir?: string): void {
243
+ const dir = resolveOutboxDir(stateDir)
244
+ try {
245
+ renameSync(join(dir, `${nonce}.sending`), join(dir, `${nonce}.json`))
246
+ } catch {
247
+ /* best-effort */
248
+ }
249
+ }
250
+
251
+ /** Delete a delivered record's claimed file. */
252
+ export function removeClaimed(nonce: string, stateDir?: string): void {
253
+ try {
254
+ unlinkSync(join(resolveOutboxDir(stateDir), `${nonce}.sending`))
255
+ } catch {
256
+ /* best-effort */
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Re-queue `.sending` claims older than the crash timeout — covers a crash
262
+ * after claim but before send. Idempotent; safe to run every sweep.
263
+ */
264
+ export function reclaimStaleSending(stateDir?: string, now: number = Date.now()): void {
265
+ const dir = resolveOutboxDir(stateDir)
266
+ let files: string[]
267
+ try {
268
+ files = readdirSync(dir).filter((f) => f.endsWith('.sending'))
269
+ } catch {
270
+ return
271
+ }
272
+ for (const f of files) {
273
+ try {
274
+ const st = statSync(join(dir, f))
275
+ if (now - st.mtimeMs > OUTBOX_SENDING_TIMEOUT_MS) {
276
+ renameSync(join(dir, f), join(dir, f.replace(/\.sending$/, '.json')))
277
+ }
278
+ } catch {
279
+ /* best-effort */
280
+ }
281
+ }
282
+ }
283
+
284
+ /** Read the set of already-delivered turnNonces from the journal. */
285
+ export function readDeliveredNonces(stateDir?: string): Set<string> {
286
+ const set = new Set<string>()
287
+ const path = join(resolveOutboxDir(stateDir), JOURNAL_FILE)
288
+ if (!existsSync(path)) return set
289
+ try {
290
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
291
+ if (!line) continue
292
+ try {
293
+ const e = JSON.parse(line) as DeliveredEntry
294
+ if (e.turnNonce) set.add(e.turnNonce)
295
+ } catch {
296
+ /* skip corrupt line */
297
+ }
298
+ }
299
+ } catch {
300
+ /* best-effort */
301
+ }
302
+ return set
303
+ }
304
+
305
+ /** True iff `nonce` is already journaled as delivered. */
306
+ export function outboxAlreadyDelivered(nonce: string, stateDir?: string): boolean {
307
+ return readDeliveredNonces(stateDir).has(nonce)
308
+ }
309
+
310
+ /**
311
+ * Max delivered-keys kept in the journal. On exceeding `JOURNAL_ROTATE_AT` lines
312
+ * the journal is compacted down to the newest `JOURNAL_KEEP` entries — bounding
313
+ * on-disk growth and per-tick read cost. A duplicate arriving after its nonce
314
+ * has aged out of the kept window is still caught by the record having been
315
+ * deleted at delivery (the pending file is gone), so compaction never
316
+ * reintroduces a double-post for any live record.
317
+ */
318
+ export const JOURNAL_KEEP = 2_000
319
+ export const JOURNAL_ROTATE_AT = 4_000
320
+
321
+ /**
322
+ * Append a delivered entry to the journal (intent/outcome). Called by EVERY
323
+ * delivering machine (sweep, legacy flush, captured-prose bridge, exhausted
324
+ * fallback, final-answer reply send) under the SAME nonce — the shared
325
+ * exactly-once namespace (H1). Best-effort; never throws. Compacts the journal
326
+ * in place once it grows past `JOURNAL_ROTATE_AT` lines.
327
+ */
328
+ export function appendDelivered(entry: DeliveredEntry, stateDir?: string): void {
329
+ const dir = resolveOutboxDir(stateDir)
330
+ const path = join(dir, JOURNAL_FILE)
331
+ try {
332
+ mkdirSync(dir, { recursive: true })
333
+ appendFileSync(path, JSON.stringify(entry) + '\n', { mode: 0o600 })
334
+ compactJournalIfLarge(path)
335
+ } catch {
336
+ /* best-effort */
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Rewrite the journal keeping only its newest `JOURNAL_KEEP` non-empty lines
342
+ * once it exceeds `JOURNAL_ROTATE_AT`. Atomic (tmp + rename); best-effort.
343
+ */
344
+ function compactJournalIfLarge(path: string): void {
345
+ try {
346
+ const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.length > 0)
347
+ if (lines.length <= JOURNAL_ROTATE_AT) return
348
+ const kept = lines.slice(lines.length - JOURNAL_KEEP)
349
+ const tmp = `${path}.${process.pid}.compact`
350
+ writeFileSync(tmp, kept.join('\n') + '\n', { mode: 0o600 })
351
+ renameSync(tmp, path)
352
+ } catch {
353
+ /* best-effort */
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Delete any pending outbox record for `nonce` (the reply-path clear-by-nonce,
359
+ * plus the journal write so a race-in-flight sweep also skips). Called when a
360
+ * genuine reply/flush delivered this turn's answer, so the sweep never
361
+ * re-sends. Best-effort.
362
+ */
363
+ export function clearOutboxRecord(nonce: string, stateDir?: string): void {
364
+ const dir = resolveOutboxDir(stateDir)
365
+ for (const suffix of ['.json', '.sending']) {
366
+ try {
367
+ unlinkSync(join(dir, `${nonce}${suffix}`))
368
+ } catch {
369
+ /* best-effort */
370
+ }
371
+ }
372
+ }
373
+
374
+ export type OutboxSweepAction =
375
+ | 'send'
376
+ | 'send-delayed'
377
+ | 'skip-journaled'
378
+ | 'skip-quiet'
379
+ | 'skip-dedup'
380
+ | 'skip-unroutable'
381
+
382
+ export interface OutboxSweepDecision {
383
+ action: OutboxSweepAction
384
+ /** The text to deliver (with any "(delayed)"/"(from background task)" prefix). */
385
+ text?: string
386
+ }
387
+
388
+ /**
389
+ * Pure sweep decision for one record. No IO — the caller injects the journal
390
+ * set, the text-dedup verdict, and the resolved chat.
391
+ *
392
+ * skip-journaled — nonce already delivered (exactly-once, H1).
393
+ * skip-quiet — inside the quiet period; let a same-turn legacy flush land.
394
+ * skip-dedup — identical text already delivered (the in-memory
395
+ * `outboundDedup` cache; NOT a persistent/SQLite store —
396
+ * the durable exactly-once guard is the delivered-keys
397
+ * journal keyed by turnNonce, checked above).
398
+ * skip-unroutable — no chat could be resolved (H3 exhausted) — keep the record.
399
+ * send — deliver now.
400
+ * send-delayed — older than max-age; deliver with a "(delayed)" prefix, never drop.
401
+ */
402
+ export function decideOutboxSweep(input: {
403
+ record: Pick<OutboxRecord, 'turnNonce' | 'text' | 'createdAt'>
404
+ now: number
405
+ deliveredNonces: Set<string>
406
+ textAlreadyDelivered: boolean
407
+ routable: boolean
408
+ routePrefix?: string
409
+ quietMs?: number
410
+ maxAgeMs?: number
411
+ }): OutboxSweepDecision {
412
+ const {
413
+ record,
414
+ now,
415
+ deliveredNonces,
416
+ textAlreadyDelivered,
417
+ routable,
418
+ routePrefix = '',
419
+ quietMs = OUTBOX_QUIET_MS,
420
+ maxAgeMs = OUTBOX_MAX_AGE_MS,
421
+ } = input
422
+ if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
423
+ const age = now - record.createdAt
424
+ if (age < quietMs) return { action: 'skip-quiet' }
425
+ if (textAlreadyDelivered) return { action: 'skip-dedup' }
426
+ if (!routable) return { action: 'skip-unroutable' }
427
+ const delayed = age > maxAgeMs
428
+ const prefix = (delayed ? '(delayed) ' : '') + routePrefix
429
+ return { action: delayed ? 'send-delayed' : 'send', text: prefix + record.text }
430
+ }
431
+
432
+ export interface ResolvedChat {
433
+ chatId: string
434
+ threadId: number | null
435
+ /** How the chat was resolved — 'anchor' (envelope), 'registry' (H3 chain), 'origin' (per-session fallback). */
436
+ via: 'anchor' | 'registry' | 'origin'
437
+ }
438
+
439
+ /**
440
+ * Resolve the destination chat for a record (H3 / F2).
441
+ *
442
+ * 1. anchor — the record already carries a chatId (envelope-bearing turn).
443
+ * 2. registry — transitive `<task-id>` → registry-row → originating chatKey
444
+ * lookup, recursing up a chained/background-spawned dispatch.
445
+ * 3. origin — the record's OWN stamped per-session origin chat
446
+ * (`originChatId`), captured at Stop from this session's most
447
+ * recent real `<channel>` inbound. This is SCOPED to the record
448
+ * (F2): it can never route to "whatever chat messaged the
449
+ * gateway last" the way the retired global last-inbound file
450
+ * could, so a DM-origin handback can never leak into an
451
+ * unrelated group. The caller adds a "(from background task)"
452
+ * prefix for this route.
453
+ *
454
+ * FAIL CLOSED: if none of the three resolves, returns null — the sweep HOLDS the
455
+ * record (skip-unroutable) rather than delivering to an arbitrary chat.
456
+ *
457
+ * Pure — the caller injects `registryChainLookup`.
458
+ */
459
+ export function resolveOutboxChat(
460
+ record: Pick<OutboxRecord, 'chatId' | 'threadId' | 'anchorContent' | 'originChatId' | 'originThreadId'>,
461
+ deps: {
462
+ registryChainLookup?: (anchorContent: string) => { chatId: string; threadId: number | null } | null
463
+ },
464
+ ): ResolvedChat | null {
465
+ if (record.chatId != null && record.chatId !== '') {
466
+ return { chatId: record.chatId, threadId: record.threadId ?? null, via: 'anchor' }
467
+ }
468
+ if (record.anchorContent && deps.registryChainLookup) {
469
+ const hit = deps.registryChainLookup(record.anchorContent)
470
+ if (hit != null) return { chatId: hit.chatId, threadId: hit.threadId, via: 'registry' }
471
+ }
472
+ if (record.originChatId != null && record.originChatId !== '') {
473
+ return { chatId: record.originChatId, threadId: record.originThreadId ?? null, via: 'origin' }
474
+ }
475
+ return null
476
+ }
477
+
478
+ /**
479
+ * Extract a `<task-id>` (or `task_id="…"`/`taskId`) from a task-notification
480
+ * anchor's content, for the H3 registry-chain lookup. Null if none.
481
+ */
482
+ export function extractTaskId(anchorContent: string): string | null {
483
+ if (typeof anchorContent !== 'string') return null
484
+ const m =
485
+ anchorContent.match(/<task-id>\s*([^<\s]+)\s*<\/task-id>/) ??
486
+ anchorContent.match(/task[_-]?id="([^"]+)"/i) ??
487
+ anchorContent.match(/task[_-]?id:\s*([^\s,}"']+)/i)
488
+ return m ? m[1] : null
489
+ }
@@ -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