switchroom 0.21.3 → 0.21.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/handoff-briefing.sh +23 -1
- package/dist/cli/switchroom.js +79 -16
- package/dist/host-control/main.js +1 -1
- package/package.json +4 -2
- package/telegram-plugin/dist/gateway/gateway.js +406 -121
- package/telegram-plugin/gateway/gateway.ts +24 -22
- package/telegram-plugin/gateway/inbound-router.ts +77 -26
- package/telegram-plugin/gateway/orphaned-db-sweep.ts +315 -0
- package/telegram-plugin/gateway/system-message-observer.ts +25 -6
- package/telegram-plugin/history.ts +328 -62
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -4
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -2
- package/telegram-plugin/shared/bot-runtime.ts +119 -2
- package/telegram-plugin/tests/card-history-lane.test.ts +171 -2
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +721 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +209 -4
- package/telegram-plugin/tests/system-message-observer.test.ts +84 -1
|
@@ -302,7 +302,7 @@ import {
|
|
|
302
302
|
import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-fuse.js'
|
|
303
303
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
304
304
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
305
|
-
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
|
|
305
|
+
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags, withTgSendContext, installSystemMessageObserver } from '../shared/bot-runtime.js'
|
|
306
306
|
import { installSentTextCapture } from '../shared/sent-text-capture.js'
|
|
307
307
|
import {
|
|
308
308
|
floodStatePath,
|
|
@@ -396,8 +396,9 @@ import {
|
|
|
396
396
|
pruneMessagesOlderThanDays,
|
|
397
397
|
hasOutboundDeliveredSince,
|
|
398
398
|
hasOutboundWithText,
|
|
399
|
-
recordSystemOutbound, updateSystemOutboundText,
|
|
399
|
+
recordSystemOutbound, updateSystemOutboundText, reopenHistory, getHistoryReopenFailure,
|
|
400
400
|
} from '../history.js'
|
|
401
|
+
import { startOrphanedDbSweep } from './orphaned-db-sweep.js'
|
|
401
402
|
import { makeSystemMessageObserver } from './system-message-observer.js'
|
|
402
403
|
import {
|
|
403
404
|
runRegistryReaper,
|
|
@@ -2162,6 +2163,7 @@ if (isGatewayMain) runHistoryReaperNow('boot')
|
|
|
2162
2163
|
if (isGatewayMain && !STATIC) {
|
|
2163
2164
|
setInterval(() => runHistoryReaperNow('periodic'), REGISTRY_REAPER_INTERVAL_MS).unref()
|
|
2164
2165
|
}
|
|
2166
|
+
if (isGatewayMain && !STATIC) startOrphanedDbSweep({ stateDir: STATE_DIR, reopenHistory: HISTORY_ENABLED ? () => reopenHistory(STATE_DIR, HISTORY_ACCESS.historyRetentionDays ?? 30) : undefined, historyReopenFailure: HISTORY_ENABLED ? getHistoryReopenFailure : undefined, log: (l) => process.stderr.write(l) }) // own 5-min tick, NOT the 6h reaper: bounds silent data loss from a deleted-inode DB handle to one interval (gateway/orphaned-db-sweep.ts). Runs regardless of HISTORY_ENABLED — registry.db opens whenever isGatewayMain.
|
|
2165
2167
|
|
|
2166
2168
|
// ─── Approval polling ─────────────────────────────────────────────────────
|
|
2167
2169
|
function checkApprovals(): void {
|
|
@@ -5601,28 +5603,25 @@ const rawRobustApiCall = createRetryApiCall({
|
|
|
5601
5603
|
floodWaitRemainingMs: probeFloodWaitRemainingMs,
|
|
5602
5604
|
})
|
|
5603
5605
|
|
|
5604
|
-
// #4571 — card/system-surface history lane
|
|
5605
|
-
//
|
|
5606
|
-
//
|
|
5607
|
-
//
|
|
5608
|
-
//
|
|
5609
|
-
// system-message-observer.ts for the send-vs-edit
|
|
5610
|
-
// Gated
|
|
5611
|
-
// process never opens the DB, so an observer there would be pure noise.
|
|
5612
|
-
// The empty-body alarm (#4576) is the observer's own default — see
|
|
5613
|
-
// `defaultEmptyCardTextWarning` in system-message-observer.ts.
|
|
5606
|
+
// #4571 — card/system-surface history lane: every card the gateway posts must leave
|
|
5607
|
+
// a row so its id resolves when the operator quote-replies to it. #4599 moved the
|
|
5608
|
+
// hook off `robustApiCall` (which the `ctx.replyWithRichMessage` slash-command card
|
|
5609
|
+
// path bypasses entirely) onto the grammy transformer layer — see
|
|
5610
|
+
// `installSystemMessageObserver`, wired at bot construction, and
|
|
5611
|
+
// system-message-observer.ts for the send-vs-edit / throttling / alarm contract.
|
|
5612
|
+
// Gated as `initHistory` is: a non-main gateway never opens the DB.
|
|
5614
5613
|
const observeSentMessage = isGatewayMain && HISTORY_ENABLED
|
|
5615
5614
|
? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
|
|
5616
5615
|
: undefined
|
|
5617
5616
|
|
|
5617
|
+
// `withTgSendContext` publishes {chat_id, threadId, verb} down to the transformer layer
|
|
5618
|
+
// where the observer runs — how a card keeps its `kind`. Sends outside this wrapper
|
|
5619
|
+
// still record, just without a verb.
|
|
5618
5620
|
const robustApiCall = <T>(
|
|
5619
5621
|
fn: () => Promise<T>,
|
|
5620
5622
|
opts?: Parameters<typeof rawRobustApiCall<T>>[1],
|
|
5621
|
-
): Promise<T> =>
|
|
5622
|
-
|
|
5623
|
-
if (observeSentMessage == null) return p
|
|
5624
|
-
return p.then((res) => { observeSentMessage(res, opts); return res })
|
|
5625
|
-
}
|
|
5623
|
+
): Promise<T> =>
|
|
5624
|
+
sendGate.gate(() => withTgSendContext(opts, () => rawRobustApiCall(fn, opts)), opts)
|
|
5626
5625
|
|
|
5627
5626
|
// Fire-and-forget wrapper for outbound surfaces that previously had
|
|
5628
5627
|
// `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
|
|
@@ -15059,11 +15058,11 @@ export async function handleInbound(
|
|
|
15059
15058
|
} = replyForwardCtx
|
|
15060
15059
|
|
|
15061
15060
|
// Reply-to buffer fallback (post-reset continuity, resolveReplyToFromBuffer).
|
|
15062
|
-
// On a native reply to
|
|
15063
|
-
// reply_to_message.message_id but NOT its .text
|
|
15064
|
-
//
|
|
15065
|
-
//
|
|
15066
|
-
//
|
|
15061
|
+
// On a native reply to a PLAIN bot-sent message, Telegram delivers
|
|
15062
|
+
// reply_to_message.message_id but NOT its .text (a RICH parent — every card —
|
|
15063
|
+
// carries rich_message and is resolved LIVE upstream since #4598), so the live
|
|
15064
|
+
// reply text is empty even though we persisted it (role='assistant', 30-day
|
|
15065
|
+
// retention). Recover it so it survives a reset. Fills replyToText (raw, so the
|
|
15067
15066
|
// recordInbound write below persists it — envelope-only would leave the row
|
|
15068
15067
|
// NULL and starve future briefings), replyToTextEscaped (channel meta), and
|
|
15069
15068
|
// replyToRole (the reply_to_role attribute). Only when the live text is
|
|
@@ -22885,6 +22884,9 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22885
22884
|
// on the resolved Message for the shapes a response can't supply. After the fmt
|
|
22886
22885
|
// guard so it composes OUTSIDE it. See sent-text-capture.ts.
|
|
22887
22886
|
installSentTextCapture(bot)
|
|
22887
|
+
// #4599 card-history lane: AFTER sent-text-capture so it composes outside it and
|
|
22888
|
+
// can read the request-side stamp. The seam `ctx.replyWithRichMessage` can't bypass.
|
|
22889
|
+
if (observeSentMessage != null) installSystemMessageObserver(bot, observeSentMessage)
|
|
22888
22890
|
// #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
|
|
22889
22891
|
// outbound call can bypass (grammY has no route to the network that skips the
|
|
22890
22892
|
// transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from './inbound-interceptors.js'
|
|
36
36
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
37
37
|
import { deriveTurnId } from './derive-turn-id.js'
|
|
38
|
+
import { extractRichMessageText } from './rich-message-handler.js'
|
|
38
39
|
import { formatReplyToText } from '../steering.js'
|
|
39
40
|
import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
|
|
40
41
|
import { safeResolvePersonName, type PersonDirectory } from './resolve-person.js'
|
|
@@ -160,10 +161,33 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
|
|
|
160
161
|
// gateway runs when neither is present. Accessed defensively in case the
|
|
161
162
|
// installed grammy/@grammyjs/types predate `TextQuote`.
|
|
162
163
|
const quoteText = p.ctx.message?.quote?.text
|
|
164
|
+
// Rich-message parents (#4598). Every card the gateway posts goes out via
|
|
165
|
+
// Bot API 10.1 `sendRichMessage`, so a native reply to one delivers a
|
|
166
|
+
// `reply_to_message` whose body lives under `rich_message.blocks` with
|
|
167
|
+
// `text` / `caption` ABSENT. Reading only `.text ?? .caption` therefore
|
|
168
|
+
// yielded `undefined` for 100% of card parents and pushed every such reply
|
|
169
|
+
// onto the history-buffer fallback — which cannot help when no row was ever
|
|
170
|
+
// recorded (a send while the gateway was down, or one that bypassed the
|
|
171
|
+
// recording chokepoint).
|
|
172
|
+
//
|
|
173
|
+
// Measured on the wire, not inferred: a user reply to a `sendRichMessage`
|
|
174
|
+
// card yields `reply_to_message` keys
|
|
175
|
+
// `[message_id, from, chat, date, rich_message]`, `text`/`caption` absent,
|
|
176
|
+
// `rich_message.blocks` fully populated. The older "Telegram omits the
|
|
177
|
+
// parent body" comments on this path predate rich messages.
|
|
178
|
+
//
|
|
179
|
+
// Flattened by the SAME renderer the inbound rich-message handler and the
|
|
180
|
+
// outbound card observer use, so all three agree on what a card "says".
|
|
181
|
+
// Returns `undefined` (never `''`) for an unrenderable block tree, so
|
|
182
|
+
// `resolveReplyToFromBuffer`'s `liveTextEmpty` gate still falls through to
|
|
183
|
+
// the buffer instead of pinning an empty antecedent.
|
|
184
|
+
const richParentText = extractRichMessageText(
|
|
185
|
+
(replyToMsg as { rich_message?: unknown } | undefined)?.rich_message,
|
|
186
|
+
)
|
|
163
187
|
const replyToTextRaw = (quoteText != null && quoteText.length > 0)
|
|
164
188
|
? quoteText
|
|
165
189
|
: replyToMsg
|
|
166
|
-
? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
|
|
190
|
+
? (replyToMsg.text ?? replyToMsg.caption ?? richParentText ?? undefined)
|
|
167
191
|
: undefined
|
|
168
192
|
const replyToText = replyToTextRaw != null
|
|
169
193
|
? (replyToTextRaw.length > p.replyToTextMax
|
|
@@ -198,8 +222,11 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
|
|
|
198
222
|
export interface ReplyToBufferFallbackParams {
|
|
199
223
|
/** From {@link buildReplyForwardContext}. */
|
|
200
224
|
replyToMessageId: number | undefined
|
|
201
|
-
/** Raw reply text off the live update (for the SQLite write)
|
|
202
|
-
*
|
|
225
|
+
/** Raw reply text off the live update (for the SQLite write). Empty only
|
|
226
|
+
* when the live update carried NO readable parent body at all — a plain
|
|
227
|
+
* `sendMessage` parent (Telegram omits `.text` on the bot's own plain
|
|
228
|
+
* messages), or a rich parent whose blocks render to nothing. A normal
|
|
229
|
+
* card parent now arrives populated off `rich_message` (#4598). */
|
|
203
230
|
replyToText: string | undefined
|
|
204
231
|
/** XML-escaped reply text for the channel meta; empty in the same case. */
|
|
205
232
|
replyToTextEscaped: string | undefined
|
|
@@ -219,21 +246,36 @@ export interface ReplyToBufferFallbackParams {
|
|
|
219
246
|
}
|
|
220
247
|
|
|
221
248
|
/**
|
|
222
|
-
* Reply-to buffer fallback (post-reset continuity). On a native reply to
|
|
223
|
-
*
|
|
224
|
-
* its `.text` — so the live reply text
|
|
225
|
-
* authored (and, via `recordOutbound`,
|
|
226
|
-
* the antecedent from the local history
|
|
227
|
-
* (`resume_mode: handoff`), where the
|
|
249
|
+
* Reply-to buffer fallback (post-reset continuity). On a native reply to a
|
|
250
|
+
* PLAIN message the bot itself sent, Telegram delivers
|
|
251
|
+
* `reply_to_message.message_id` but NOT its `.text` — so the live reply text
|
|
252
|
+
* is empty even though the gateway authored (and, via `recordOutbound`,
|
|
253
|
+
* persisted) that message. This recovers the antecedent from the local history
|
|
254
|
+
* buffer so it survives a session reset (`resume_mode: handoff`), where the
|
|
255
|
+
* transcript is gone.
|
|
256
|
+
*
|
|
257
|
+
* Since #4598 this is the SECOND line of defence FOR THE TEXT, not the first:
|
|
258
|
+
* a reply to a RICH parent (every card) now carries its body on
|
|
259
|
+
* `reply_to_message.rich_message` and is resolved live in
|
|
260
|
+
* {@link buildReplyForwardContext}, so a live-resolved body is never
|
|
261
|
+
* overwritten from the buffer.
|
|
262
|
+
*
|
|
263
|
+
* The LOOKUP itself, however, still runs on every reply while history is on.
|
|
264
|
+
* `replyToRole` and `replyToKind` have no live-update source at all, so
|
|
265
|
+
* skipping the lookup whenever the live text resolved would strip
|
|
266
|
+
* `reply_to_role` / `reply_to_kind` from precisely the recorded-card replies
|
|
267
|
+
* the #4571 kind lane was built for.
|
|
228
268
|
*
|
|
229
269
|
* Returns updated `replyToText` (raw, for the SQLite `recordInbound` write —
|
|
230
270
|
* envelope-only would leave the row NULL and starve future handoff briefings),
|
|
231
271
|
* `replyToTextEscaped` (for the channel-meta `reply_to_text`), and the
|
|
232
272
|
* recovered `replyToRole` ('assistant' = the bot's own message, disambiguating
|
|
233
273
|
* the "you're replying to yourself" case). Pure except for the injected
|
|
234
|
-
* `lookup`. Only fills in when the live reply text is empty — never
|
|
235
|
-
* a non-empty live value (a partial-quote
|
|
236
|
-
*
|
|
274
|
+
* `lookup`. Only fills in the TEXT when the live reply text is empty — never
|
|
275
|
+
* overwrites a non-empty live value (a partial-quote, a reply to a person's
|
|
276
|
+
* message, or a live-rendered rich parent). Role and kind are filled in from
|
|
277
|
+
* any buffer hit regardless. Degrades silently to the id-only inputs on any
|
|
278
|
+
* lookup failure.
|
|
237
279
|
*/
|
|
238
280
|
export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
|
|
239
281
|
replyToText: string | undefined
|
|
@@ -248,23 +290,32 @@ export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
|
|
|
248
290
|
let replyToRole: 'user' | 'assistant' | 'system' | undefined
|
|
249
291
|
let replyToKind: string | undefined
|
|
250
292
|
const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0
|
|
251
|
-
if (p.historyEnabled && p.replyToMessageId != null
|
|
293
|
+
if (p.historyEnabled && p.replyToMessageId != null) {
|
|
252
294
|
try {
|
|
253
295
|
const recovered = p.lookup(p.replyToMessageId)
|
|
254
|
-
if (recovered != null
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
replyToRole = recovered.role
|
|
264
|
-
} else if (recovered) {
|
|
265
|
-
// Row exists (authorship known) but text is empty/redacted — still
|
|
266
|
-
// surface the role so the model knows whose message it is replying to.
|
|
296
|
+
if (recovered != null) {
|
|
297
|
+
// Role and kind are produced ONLY here — there is no live-update
|
|
298
|
+
// source for either. So the lookup runs on EVERY reply, not just the
|
|
299
|
+
// ones whose text the live update failed to carry: gating it on
|
|
300
|
+
// `liveTextEmpty` would mean a full reply to a recorded card resolved
|
|
301
|
+
// its body live (#4598) and silently lost `reply_to_role="system"` +
|
|
302
|
+
// `reply_to_kind` (#4571) — exactly the case the kind lane exists for,
|
|
303
|
+
// and the thing #4599 goes to AsyncLocalStorage lengths to record.
|
|
304
|
+
// Costs one SQLite point-read per reply.
|
|
267
305
|
replyToRole = recovered.role
|
|
306
|
+
if (recovered.role === 'system' && recovered.kind) {
|
|
307
|
+
replyToKind = recovered.kind
|
|
308
|
+
}
|
|
309
|
+
// The TEXT is still second line of defence: never overwrite a
|
|
310
|
+
// non-empty live value (a partial quote, a person's message, or a
|
|
311
|
+
// rich parent rendered off the wire).
|
|
312
|
+
if (liveTextEmpty && recovered.text.length > 0) {
|
|
313
|
+
replyToText =
|
|
314
|
+
recovered.text.length > p.replyToTextMax
|
|
315
|
+
? recovered.text.slice(0, p.replyToTextMax - 1) + '…'
|
|
316
|
+
: recovered.text
|
|
317
|
+
replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax)
|
|
318
|
+
}
|
|
268
319
|
}
|
|
269
320
|
} catch {
|
|
270
321
|
// History disabled mid-run / requireDb throws / row missing — degrade
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphaned-DB-fd sweep — detect and recover from a SQLite handle that is
|
|
3
|
+
* still writing into DELETED inodes.
|
|
4
|
+
*
|
|
5
|
+
* THE FAILURE MODE
|
|
6
|
+
* ----------------
|
|
7
|
+
* The gateway holds `history.db` (bun:sqlite, WAL) open for the process
|
|
8
|
+
* lifetime. A FOREIGN process rw-opened the same DB, and on exit — as the
|
|
9
|
+
* last connection — SQLite checkpointed and UNLINKED `history.db-wal` and
|
|
10
|
+
* `history.db-shm`. Our long-lived connection kept the deleted inodes mapped
|
|
11
|
+
* and kept writing into them for 3h06m, logging success on every insert. The
|
|
12
|
+
* rows were never on disk; they vanished at the next restart. The signature is
|
|
13
|
+
* visible from the process itself:
|
|
14
|
+
*
|
|
15
|
+
* /proc/<pid>/fd/13 -> /…/history.db-wal (deleted)
|
|
16
|
+
*
|
|
17
|
+
* Nothing in the write path can notice this: `INSERT` returns success, the
|
|
18
|
+
* boot-time `verifyHistoryWritable()` self-check already ran hours earlier, and
|
|
19
|
+
* a WAL checkpoint through the stale mapping "succeeds" too. The only in-process
|
|
20
|
+
* evidence is the fd table, so that is what we poll.
|
|
21
|
+
*
|
|
22
|
+
* WHAT THIS DOES
|
|
23
|
+
* --------------
|
|
24
|
+
* Every 5 minutes, walk `/proc/self/fd` and look for a link whose target is a
|
|
25
|
+
* `*.db` (or `-wal`/`-shm`/`-journal` sidecar) file inside the gateway's state
|
|
26
|
+
* dir marked `(deleted)`. On a hit:
|
|
27
|
+
*
|
|
28
|
+
* - Log LOUDLY that rows written since the last checkpoint are LOST. Silent
|
|
29
|
+
* data loss becomes a visible operator signal bounded to one sweep interval.
|
|
30
|
+
* - `history.db`: hard-close-drop-reopen via `reopenHistory()`. Read the long
|
|
31
|
+
* note there before touching it — a plain `close()` is NOT enough (it does
|
|
32
|
+
* not release the fds, and the reopened connection then throws on the first
|
|
33
|
+
* WRITE), and there is deliberately no salvage checkpoint through the
|
|
34
|
+
* orphaned handle. If the reopen throws, we say so and ask for a restart
|
|
35
|
+
* rather than claim a recovery that did not happen.
|
|
36
|
+
* - `registry.db`: alarm only, RESTART REQUIRED. The registry handle
|
|
37
|
+
* (`turnsDb`) is captured BY VALUE into long-lived wiring in `gateway.ts`
|
|
38
|
+
* (the subagent-watcher options object among others), so a close-and-
|
|
39
|
+
* reassign would leave those consumers holding a CLOSED handle — strictly
|
|
40
|
+
* worse than the orphaned one. Detection without action is the honest
|
|
41
|
+
* behaviour here; the operator restarts.
|
|
42
|
+
* - anything else `*.db` in the state dir: alarm only, RESTART REQUIRED. No
|
|
43
|
+
* lane owns it, so the honest answer is to name the file and say so rather
|
|
44
|
+
* than raise a data-loss alarm with no instruction attached.
|
|
45
|
+
*
|
|
46
|
+
* AND ONE THING THAT IS NOT FD-DRIVEN
|
|
47
|
+
* -----------------------------------
|
|
48
|
+
* A reopen that hard-closes the old handle and then fails to re-init leaves
|
|
49
|
+
* history NULL and the orphaned fds GONE — so fd detection can never fire
|
|
50
|
+
* again, and a "FAILED to reopen" line printed once at 03:00 would be the only
|
|
51
|
+
* record of a permanent outage. Every tick therefore also checks the sticky
|
|
52
|
+
* `getHistoryReopenFailure()` flag, alarms on it, and retries the reopen,
|
|
53
|
+
* independently of what the fd table says.
|
|
54
|
+
*
|
|
55
|
+
* WHY THE SCAN IS ASYNC
|
|
56
|
+
* ---------------------
|
|
57
|
+
* The walk is O(open fds) — measured at 10-13ms against a live gateway holding
|
|
58
|
+
* 3366 fds, and `RLIMIT_NOFILE` on the fleet is 524288, so the cost is
|
|
59
|
+
* unbounded by anything we control. A synchronous readdir+readlink loop of that
|
|
60
|
+
* shape blocks the event loop, i.e. stalls inbound Telegram handling, for a
|
|
61
|
+
* check that finds nothing 99.99% of the time. `fs/promises` `opendir` +
|
|
62
|
+
* `readlink` yields between entries, so a long scan costs latency on the sweep
|
|
63
|
+
* (which nobody is waiting for) instead of on the gateway. The alternative —
|
|
64
|
+
* probing only the known DB paths — was rejected: it cannot tell "the file is
|
|
65
|
+
* gone" from "we still hold the gone file open", which is the entire signal.
|
|
66
|
+
*
|
|
67
|
+
* Dependency-free on purpose (`fs` + `path` only) so it loads identically under
|
|
68
|
+
* `bun test` and vitest, and so the recovery path cannot itself be broken by a
|
|
69
|
+
* transitive import that touches the DB.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
import { realpathSync } from 'fs'
|
|
73
|
+
import { opendir, readlink } from 'fs/promises'
|
|
74
|
+
import { basename } from 'path'
|
|
75
|
+
|
|
76
|
+
/** A `/proc/self/fd` entry pointing at a deleted DB file in the state dir. */
|
|
77
|
+
export interface OrphanedFd {
|
|
78
|
+
fd: number
|
|
79
|
+
/** The raw readlink target, including the trailing ` (deleted)` marker. */
|
|
80
|
+
target: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Linux marks an unlinked-but-open fd's readlink target with this suffix. */
|
|
84
|
+
const DELETED_SUFFIX = ' (deleted)'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A SQLite database file or one of its sidecars, and nothing else.
|
|
88
|
+
*
|
|
89
|
+
* The previous `basename.includes('.db')` test also matched `notes.dbg`,
|
|
90
|
+
* `dump.dbf`, `history.db.bak` and `x.dbus` — an unrelated deleted temp file in
|
|
91
|
+
* the state dir would have raised a "rows are LOST" alarm. Anchored to the end
|
|
92
|
+
* of the basename so only a real `*.db` / `*.db-wal` / `*.db-shm` /
|
|
93
|
+
* `*.db-journal` matches.
|
|
94
|
+
*/
|
|
95
|
+
const DB_BASENAME_RE = /\.db(-wal|-shm|-journal)?$/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Canonicalise the state dir for prefix matching against `/proc` targets.
|
|
99
|
+
*
|
|
100
|
+
* `/proc/self/fd` targets are ALWAYS fully resolved, so comparing them against
|
|
101
|
+
* a `TELEGRAM_STATE_DIR` that is a symlink (or relative) makes every
|
|
102
|
+
* `startsWith` fail and disables detection permanently — with no error, no log,
|
|
103
|
+
* and a sweep that reports "healthy" forever. Returns null when the path cannot
|
|
104
|
+
* be resolved at all, which callers surface as a warning rather than silence.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveStateDirPrefix(stateDir: string): string | null {
|
|
107
|
+
let resolved: string
|
|
108
|
+
try {
|
|
109
|
+
resolved = realpathSync(stateDir)
|
|
110
|
+
} catch {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
return resolved.endsWith('/') ? resolved : resolved + '/'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Scan `/proc/self/fd` for handles onto deleted DB files under `stateDir`.
|
|
118
|
+
*
|
|
119
|
+
* Returns `[]` — never throws — on non-Linux (no `/proc`), on an unreadable or
|
|
120
|
+
* unresolvable `stateDir`, on an unreadable `/proc/self/fd`, and for any
|
|
121
|
+
* individual fd that races closed between the directory read and the
|
|
122
|
+
* `readlink`.
|
|
123
|
+
*
|
|
124
|
+
* A match requires ALL THREE of:
|
|
125
|
+
* 1. the target is inside the CANONICAL `stateDir` (so an unrelated deleted
|
|
126
|
+
* DB elsewhere on the box is not our problem),
|
|
127
|
+
* 2. the target ends with ` (deleted)` (a healthy open WAL is NOT an orphan),
|
|
128
|
+
* 3. the basename is a SQLite file or sidecar (an unrelated deleted temp file
|
|
129
|
+
* in the state dir must not raise a data-loss alarm).
|
|
130
|
+
*/
|
|
131
|
+
export async function detectOrphanedDbFds(stateDir: string): Promise<OrphanedFd[]> {
|
|
132
|
+
if (process.platform !== 'linux') return []
|
|
133
|
+
const prefix = resolveStateDirPrefix(stateDir)
|
|
134
|
+
if (prefix == null) return []
|
|
135
|
+
const found: OrphanedFd[] = []
|
|
136
|
+
try {
|
|
137
|
+
const dir = await opendir('/proc/self/fd')
|
|
138
|
+
for await (const entry of dir) {
|
|
139
|
+
let target: string
|
|
140
|
+
try {
|
|
141
|
+
target = await readlink(`/proc/self/fd/${entry.name}`)
|
|
142
|
+
} catch {
|
|
143
|
+
// The fd closed underneath us (including the directory handle's own
|
|
144
|
+
// fd). Not an orphan; just gone.
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
// Parse FIRST, then filter. Doing the `slice` inside the deleted-check
|
|
148
|
+
// would make the basename test below silently do the deleted-check's job
|
|
149
|
+
// too (slicing 10 chars off a healthy `…/history.db-wal` yields `…/hist`,
|
|
150
|
+
// which fails the DB test by accident) — and an accidental guard is one
|
|
151
|
+
// nobody can mutation-test or safely refactor.
|
|
152
|
+
const deleted = target.endsWith(DELETED_SUFFIX)
|
|
153
|
+
const bare = deleted ? target.slice(0, -DELETED_SUFFIX.length) : target
|
|
154
|
+
if (!bare.startsWith(prefix)) continue
|
|
155
|
+
if (!deleted) continue
|
|
156
|
+
if (!DB_BASENAME_RE.test(basename(bare))) continue
|
|
157
|
+
found.push({ fd: Number(entry.name), target })
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
return found
|
|
161
|
+
}
|
|
162
|
+
return found
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Strip the ` (deleted)` marker and return the bare file name. */
|
|
166
|
+
function orphanBasename(target: string): string {
|
|
167
|
+
return basename(target.endsWith(DELETED_SUFFIX) ? target.slice(0, -DELETED_SUFFIX.length) : target)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface OrphanedDbSweepOptions {
|
|
171
|
+
/** The gateway state dir whose DB files we own. */
|
|
172
|
+
stateDir: string
|
|
173
|
+
/**
|
|
174
|
+
* Recovery for `history.db`. Omit when history is disabled — detection and
|
|
175
|
+
* the loud log still run, only the reopen is skipped.
|
|
176
|
+
*/
|
|
177
|
+
reopenHistory?: () => void
|
|
178
|
+
/**
|
|
179
|
+
* The sticky "history is dead" flag (`history.getHistoryReopenFailure`).
|
|
180
|
+
* Checked on EVERY tick, not only when an orphaned fd is found: once a
|
|
181
|
+
* reopen has closed the old handle the fds are released, so fd detection can
|
|
182
|
+
* no longer see the outage it caused. Omit only where history is not wired.
|
|
183
|
+
*/
|
|
184
|
+
historyReopenFailure?: () => string | null
|
|
185
|
+
/** Log sink. The gateway passes `(l) => process.stderr.write(l)`. */
|
|
186
|
+
log: (line: string) => void
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One sweep tick: detect, alarm, and recover. Returns the orphans found (empty
|
|
191
|
+
* on the healthy path, which is every tick but the incident one).
|
|
192
|
+
*
|
|
193
|
+
* Never throws — a failed reopen is logged and the next tick retries. That
|
|
194
|
+
* promise is only meaningful because of the sticky-failure lane below: a reopen
|
|
195
|
+
* whose close succeeded but whose re-init failed releases the very fds that
|
|
196
|
+
* would have triggered the next retry.
|
|
197
|
+
*/
|
|
198
|
+
export async function runOrphanedDbSweepTick(
|
|
199
|
+
opts: OrphanedDbSweepOptions,
|
|
200
|
+
): Promise<OrphanedFd[]> {
|
|
201
|
+
if (process.platform === 'linux' && resolveStateDirPrefix(opts.stateDir) == null) {
|
|
202
|
+
opts.log(
|
|
203
|
+
`telegram gateway: orphaned-db-sweep cannot resolve stateDir=${opts.stateDir} —`
|
|
204
|
+
+ ` deleted-inode DB detection is DISABLED until it exists and is readable.\n`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
const orphans = await detectOrphanedDbFds(opts.stateDir)
|
|
208
|
+
let historyHandled = false
|
|
209
|
+
|
|
210
|
+
if (orphans.length > 0) {
|
|
211
|
+
const names = orphans.map((o) => orphanBasename(o.target))
|
|
212
|
+
opts.log(
|
|
213
|
+
`telegram gateway: orphaned-db-sweep DETECTED ${orphans.length} deleted-inode DB handle(s): `
|
|
214
|
+
+ orphans.map((o) => `fd=${o.fd} ${o.target}`).join(', ')
|
|
215
|
+
+ ` — another process unlinked these files while we held them open; every row written`
|
|
216
|
+
+ ` since the last checkpoint is LOST and further writes would be lost too.\n`,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if (names.some((n) => n.startsWith('history.db'))) {
|
|
220
|
+
historyHandled = true
|
|
221
|
+
if (opts.reopenHistory) {
|
|
222
|
+
attemptHistoryReopen(opts, 'reopened history.db')
|
|
223
|
+
} else {
|
|
224
|
+
opts.log(
|
|
225
|
+
`telegram gateway: orphaned-db-sweep found an orphaned history.db handle but no reopen`
|
|
226
|
+
+ ` is wired (history disabled) — RESTART the gateway to recover.\n`,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (names.some((n) => n.startsWith('registry.db'))) {
|
|
232
|
+
opts.log(
|
|
233
|
+
`telegram gateway: orphaned-db-sweep found an orphaned registry.db handle. An in-process`
|
|
234
|
+
+ ` reopen is NOT safe here — the turnsDb handle is captured by value into long-lived`
|
|
235
|
+
+ ` wiring, so closing it would leave those consumers on a closed handle. RESTART the`
|
|
236
|
+
+ ` gateway to recover; subagent/turn rows written since the last checkpoint are LOST.\n`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Anything else under the state dir has no lane. Say so explicitly: an
|
|
241
|
+
// unnamed data-loss alarm with no recovery instruction is worse than none.
|
|
242
|
+
const unowned = [...new Set(names.filter(
|
|
243
|
+
(n) => !n.startsWith('history.db') && !n.startsWith('registry.db'),
|
|
244
|
+
))]
|
|
245
|
+
if (unowned.length > 0) {
|
|
246
|
+
opts.log(
|
|
247
|
+
`telegram gateway: orphaned-db-sweep found orphaned handle(s) on ${unowned.join(', ')},`
|
|
248
|
+
+ ` which no recovery lane owns — the gateway cannot reopen them in place. RESTART the`
|
|
249
|
+
+ ` gateway to recover; rows written to those files since the last checkpoint are LOST.\n`,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The fd table cannot see a history DB that is already closed and failed to
|
|
255
|
+
// re-open, so this lane runs whether or not anything was detected.
|
|
256
|
+
if (!historyHandled) {
|
|
257
|
+
const stuck = opts.historyReopenFailure?.()
|
|
258
|
+
if (stuck != null && stuck !== '') {
|
|
259
|
+
opts.log(
|
|
260
|
+
`telegram gateway: orphaned-db-sweep history.db is CLOSED and a previous reopen failed`
|
|
261
|
+
+ ` (${stuck}) — every history read and write is dead and no fd evidence remains.`
|
|
262
|
+
+ ` Retrying the reopen; RESTART the gateway if this keeps repeating.\n`,
|
|
263
|
+
)
|
|
264
|
+
if (opts.reopenHistory) attemptHistoryReopen(opts, 'recovered history.db')
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return orphans
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Run the wired reopen, logging honestly either way. `successVerb` distinguishes
|
|
273
|
+
* the first-detection recovery from the sticky-failure retry in the log.
|
|
274
|
+
*/
|
|
275
|
+
function attemptHistoryReopen(opts: OrphanedDbSweepOptions, successVerb: string): void {
|
|
276
|
+
try {
|
|
277
|
+
opts.reopenHistory?.()
|
|
278
|
+
opts.log(
|
|
279
|
+
`telegram gateway: orphaned-db-sweep ${successVerb} — writes are durable again`
|
|
280
|
+
+ ` (proved by the post-reopen writer self-check); rows written since the last`
|
|
281
|
+
+ ` checkpoint are NOT recoverable.\n`,
|
|
282
|
+
)
|
|
283
|
+
} catch (err) {
|
|
284
|
+
opts.log(
|
|
285
|
+
`telegram gateway: orphaned-db-sweep FAILED to reopen history.db: ${(err as Error).message}`
|
|
286
|
+
+ ` — history writes are NOT durable; RESTART the gateway.\n`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Default cadence: bounds silent loss to 5 minutes without polling cost. */
|
|
292
|
+
const DEFAULT_INTERVAL_MS = 5 * 60_000
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Start the periodic sweep. Returns a `stop()` so tests can tear it down;
|
|
296
|
+
* production never stops it (matches every sibling gateway interval).
|
|
297
|
+
*
|
|
298
|
+
* `unref()`s so the timer cannot hold the process alive past shutdown. Ticks do
|
|
299
|
+
* not overlap: the scan is async, so a slow `/proc` walk on a process holding
|
|
300
|
+
* hundreds of thousands of fds must not stack up behind itself.
|
|
301
|
+
*/
|
|
302
|
+
export function startOrphanedDbSweep(
|
|
303
|
+
opts: OrphanedDbSweepOptions & { intervalMs?: number },
|
|
304
|
+
): () => void {
|
|
305
|
+
let running = false
|
|
306
|
+
const timer = setInterval(() => {
|
|
307
|
+
if (running) return
|
|
308
|
+
running = true
|
|
309
|
+
void runOrphanedDbSweepTick(opts)
|
|
310
|
+
.catch(() => { /* a sweep must never take the gateway down; next tick retries */ })
|
|
311
|
+
.finally(() => { running = false })
|
|
312
|
+
}, opts.intervalMs ?? DEFAULT_INTERVAL_MS)
|
|
313
|
+
timer.unref?.()
|
|
314
|
+
return () => clearInterval(timer)
|
|
315
|
+
}
|
|
@@ -24,10 +24,29 @@
|
|
|
24
24
|
* Rather than add a `recordSystemOutbound(...)` call to each of the ~110 raw
|
|
25
25
|
* send sites (which is exactly the kind of per-call-site discipline that
|
|
26
26
|
* decays — the `reply` path was the only site anyone remembered), this hooks
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `
|
|
27
|
+
* ONE chokepoint and observes the resolved response there.
|
|
28
|
+
*
|
|
29
|
+
* WHICH chokepoint took two goes, and the first answer was wrong (#4599). This
|
|
30
|
+
* docblock used to say the hook sat on `gateway.ts`'s `robustApiCall` — "the
|
|
31
|
+
* ONE chokepoint every gateway outbound already goes through … enforced by the
|
|
32
|
+
* `check-bot-api-wrapping` lint guard". Both halves were false from the day it
|
|
33
|
+
* merged. grammY's `ctx.*` sugar builds the payload and calls `bot.api.*`
|
|
34
|
+
* itself, so `switchroomReply` — the helper every SLASH-COMMAND card answers
|
|
35
|
+
* through (`/usage`, `/model`, `/auth`, `/approvals`, `/start`, `/help`) — sends
|
|
36
|
+
* via `ctx.replyWithRichMessage` and never touches `robustApiCall`; and the
|
|
37
|
+
* lint guard could not have caught that, because its verb pattern matched only
|
|
38
|
+
* `(bot|lockedBot|ctx)\.api\.<verb>`, never mentioned `sendRichMessage`, and
|
|
39
|
+
* structurally cannot match a `ctx.replyWith*` call at all. Measured, not
|
|
40
|
+
* inferred: a live agent's `/usage` card at id 20938 left NO row while the
|
|
41
|
+
* `tg-post` transformer logged its `sendRichMessage` POST.
|
|
42
|
+
*
|
|
43
|
+
* The hook therefore lives at the grammy API TRANSFORMER layer
|
|
44
|
+
* (`installSystemMessageObserver` in `shared/bot-runtime.ts`), which grammy
|
|
45
|
+
* resolves immediately around the HTTP POST — below every helper, `ctx.*`
|
|
46
|
+
* shorthand, `lockedBot`, and `bot.api.raw`. No call shape reaches Telegram
|
|
47
|
+
* without passing through it, so no future verb can opt out the way
|
|
48
|
+
* `switchroomReply` silently did. `robustApiCall` still publishes its `verb`
|
|
49
|
+
* down to that layer (`withTgSendContext`) so cards keep their `kind`.
|
|
31
50
|
*
|
|
32
51
|
* The observer reads the Telegram RESPONSE, which buys three things for free:
|
|
33
52
|
* - the real `message_id` (the only thing a reply can point at),
|
|
@@ -90,7 +109,7 @@ export interface SentMessageLike {
|
|
|
90
109
|
rich_message?: unknown
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
/** The
|
|
112
|
+
/** The call-site metadata the observer reads (`bot-runtime.ts`'s `TgSendContext`). */
|
|
94
113
|
export interface ObservedCallOpts {
|
|
95
114
|
chat_id?: string
|
|
96
115
|
threadId?: number
|
|
@@ -293,7 +312,7 @@ type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number; storedLe
|
|
|
293
312
|
|
|
294
313
|
/**
|
|
295
314
|
* Build the observer. The returned function is called with the RESOLVED result
|
|
296
|
-
* of every
|
|
315
|
+
* of every outbound Bot API call (from the transformer layer) and never throws.
|
|
297
316
|
*/
|
|
298
317
|
export function makeSystemMessageObserver(
|
|
299
318
|
deps: SystemMessageObserverDeps,
|