switchroom 0.21.3 → 0.21.4
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 +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +360 -102
- package/telegram-plugin/gateway/gateway.ts +3 -1
- package/telegram-plugin/gateway/orphaned-db-sweep.ts +315 -0
- 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/tests/orphaned-db-sweep.test.ts +713 -0
|
@@ -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 {
|
|
@@ -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
|
+
}
|