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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -0
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +1382 -1032
- package/telegram-plugin/final-answer-detect.ts +23 -0
- package/telegram-plugin/gateway/gateway.ts +29 -36
- package/telegram-plugin/gateway/outbound-send-path.ts +109 -8
- package/telegram-plugin/gateway/outbox-sweep.ts +282 -0
- package/telegram-plugin/gateway/stream-render.ts +26 -50
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +94 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +279 -0
- package/telegram-plugin/outbox.ts +472 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +91 -0
- package/telegram-plugin/temporal-normalize.ts +347 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +22 -12
- package/telegram-plugin/tests/outbound-send-path.test.ts +191 -0
- package/telegram-plugin/tests/outbox-capture-scan.test.ts +222 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +278 -0
- package/telegram-plugin/tests/outbox-hook-capture.test.ts +112 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +60 -63
- package/telegram-plugin/tests/silent-end.test.ts +21 -28
- package/telegram-plugin/tests/temporal-normalize.test.ts +222 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +23 -18
|
@@ -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
|