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.
@@ -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. Every card the gateway posts
5605
- // (activity card, status pin, approval/boot/issues/worker-feed cards, progress
5606
- // lines, notices) goes through `robustApiCall`, so observing its RESOLVED
5607
- // result is the one chokepoint that makes every posted message id resolvable
5608
- // when the operator quote-replies to it. Never throws; see
5609
- // system-message-observer.ts for the send-vs-edit and throttling contract.
5610
- // Gated on the SAME condition as `initHistory` above — a non-main gateway
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
- const p = sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
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 the BOT's OWN message, Telegram delivers
15063
- // reply_to_message.message_id but NOT its .text — so the live reply text is
15064
- // empty even though we authored (and, via recordOutbound, persisted) that
15065
- // message to history.db (role='assistant', 30-day retention). Recover it so
15066
- // the antecedent survives a session reset. Fills replyToText (raw, so the
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); empty when the
202
- * reply target is the bot's own message (Telegram omits its text). */
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 the
223
- * BOT's OWN message, Telegram delivers `reply_to_message.message_id` but NOT
224
- * its `.text` — so the live reply text is empty even though the gateway
225
- * authored (and, via `recordOutbound`, persisted) that message. This recovers
226
- * the antecedent from the local history buffer so it survives a session reset
227
- * (`resume_mode: handoff`), where the transcript is gone.
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 overwrites
235
- * a non-empty live value (a partial-quote or a reply to a person's message).
236
- * Degrades silently to the id-only inputs on any lookup failure.
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 && liveTextEmpty) {
293
+ if (p.historyEnabled && p.replyToMessageId != null) {
252
294
  try {
253
295
  const recovered = p.lookup(p.replyToMessageId)
254
- if (recovered != null && recovered.role === 'system' && recovered.kind) {
255
- replyToKind = recovered.kind
256
- }
257
- if (recovered && recovered.text.length > 0) {
258
- replyToText =
259
- recovered.text.length > p.replyToTextMax
260
- ? recovered.text.slice(0, p.replyToTextMax - 1) + '…'
261
- : recovered.text
262
- replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax)
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
- * the ONE chokepoint every gateway outbound already goes through:
28
- * `gateway.ts`'s `robustApiCall` (chat-lock → send-gate → retry policy). Every
29
- * card send in the gateway is routed through it, enforced by the
30
- * `check-bot-api-wrapping` lint guard.
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 subset of `robustApiCall`'s opts the observer reads. */
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 `robustApiCall` and never throws.
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,