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
|
@@ -45,15 +45,34 @@ import { redact } from './secret-detect/redact.js'
|
|
|
45
45
|
* The only Database APIs we touch are constructor(path, opts), exec,
|
|
46
46
|
* prepare, transaction, close — all stable across bun:sqlite versions.
|
|
47
47
|
*/
|
|
48
|
+
type SqliteStatement = {
|
|
49
|
+
run(...params: unknown[]): unknown
|
|
50
|
+
all(...params: unknown[]): unknown[]
|
|
51
|
+
get(...params: unknown[]): unknown
|
|
52
|
+
/**
|
|
53
|
+
* Release the underlying `sqlite3_stmt` NOW. This is the deterministic
|
|
54
|
+
* counterpart to waiting for GC: an un-finalized statement is exactly what
|
|
55
|
+
* makes bun:sqlite's close a deferred no-op, so {@link reopenHistory}
|
|
56
|
+
* finalizes every cached statement before closing. Optional in the type
|
|
57
|
+
* because a test double need not implement it.
|
|
58
|
+
*/
|
|
59
|
+
finalize?(): void
|
|
60
|
+
}
|
|
48
61
|
type SqliteDatabase = {
|
|
49
62
|
exec(sql: string): void
|
|
50
|
-
prepare(sql: string):
|
|
51
|
-
run(...params: unknown[]): unknown
|
|
52
|
-
all(...params: unknown[]): unknown[]
|
|
53
|
-
get(...params: unknown[]): unknown
|
|
54
|
-
}
|
|
63
|
+
prepare(sql: string): SqliteStatement
|
|
55
64
|
transaction(fn: (...args: unknown[]) => unknown): (...args: unknown[]) => unknown
|
|
56
|
-
|
|
65
|
+
/**
|
|
66
|
+
* `throwOnError` is bun:sqlite's "hard close" switch. The default
|
|
67
|
+
* (`close()`) is a SOFT close that silently defers the real
|
|
68
|
+
* `sqlite3_close` while any un-finalized `Statement` still references the
|
|
69
|
+
* connection. `close(true)` instead THROWS (`database is locked`) when the
|
|
70
|
+
* close did not actually happen, which is the only way to know. Every
|
|
71
|
+
* statement this module creates goes through {@link prep} and is explicitly
|
|
72
|
+
* finalized before the close, so the close is deterministic rather than
|
|
73
|
+
* GC-dependent. Load-bearing for {@link reopenHistory}; see the note there.
|
|
74
|
+
*/
|
|
75
|
+
close(throwOnError?: boolean): void
|
|
57
76
|
}
|
|
58
77
|
type SqliteDatabaseConstructor = new (path: string, opts?: { create?: boolean }) => SqliteDatabase
|
|
59
78
|
|
|
@@ -174,6 +193,79 @@ const MAX_LIMIT = 50
|
|
|
174
193
|
let db: SqliteDatabase | null = null
|
|
175
194
|
let dbPath: string | null = null
|
|
176
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Module-level prepared-statement cache — the DETERMINISTIC half of
|
|
198
|
+
* {@link reopenHistory}.
|
|
199
|
+
*
|
|
200
|
+
* WHY THIS EXISTS (it is not a performance cache, though it is also that)
|
|
201
|
+
* ----------------------------------------------------------------------
|
|
202
|
+
* bun:sqlite's `close()` is a soft close and `close(true)` throws for as long
|
|
203
|
+
* as ANY `Statement` created from the connection is still un-finalized. Every
|
|
204
|
+
* statement in this module used to be created per-call (`requireDb().prepare(…)`)
|
|
205
|
+
* and dropped on the floor, so nothing in the process held a reference we could
|
|
206
|
+
* finalize — the only way to release them was to wait for the JS GC to collect
|
|
207
|
+
* the wrappers. `Bun.gc(true)` was therefore load-bearing AND non-deterministic:
|
|
208
|
+
* measured on bun 1.3.13, a single `gc(true)` + `close(true)` pair failed 13/20
|
|
209
|
+
* times against an empty heap and 30/30 with an arithmetic loop between the last
|
|
210
|
+
* write and the gc. A recovery path that works "most of the time" is not a
|
|
211
|
+
* recovery path.
|
|
212
|
+
*
|
|
213
|
+
* With every statement routed through {@link prep}, the module OWNS a reference
|
|
214
|
+
* to each live statement and can call `.finalize()` on it explicitly. The close
|
|
215
|
+
* then succeeds because nothing is outstanding — no collection required, no
|
|
216
|
+
* heap-shape dependence.
|
|
217
|
+
*
|
|
218
|
+
* Keyed by SQL text. The set of distinct SQL strings this module can produce is
|
|
219
|
+
* small and structural (a handful of `WHERE` shapes built from booleans), not
|
|
220
|
+
* user-derived — parameters are always bound, never interpolated. {@link MAX_CACHED_STATEMENTS}
|
|
221
|
+
* is defence-in-depth against a future caller that builds SQL in a loop.
|
|
222
|
+
*/
|
|
223
|
+
const stmtCache = new Map<string, SqliteStatement>()
|
|
224
|
+
|
|
225
|
+
/** Cap on live cached statements; oldest is finalized and evicted past this. */
|
|
226
|
+
const MAX_CACHED_STATEMENTS = 128
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Prepare (or reuse) a statement against the current connection.
|
|
230
|
+
*
|
|
231
|
+
* Every SQL string in this module goes through here. Do NOT call
|
|
232
|
+
* `db.prepare(...)` directly: a statement outside the cache is one
|
|
233
|
+
* {@link reopenHistory} cannot finalize, which silently re-introduces the
|
|
234
|
+
* GC dependence this cache exists to remove.
|
|
235
|
+
*/
|
|
236
|
+
function prep(sql: string): SqliteStatement {
|
|
237
|
+
const cached = stmtCache.get(sql)
|
|
238
|
+
if (cached != null) return cached
|
|
239
|
+
const stmt = requireDb().prepare(sql)
|
|
240
|
+
if (stmtCache.size >= MAX_CACHED_STATEMENTS) {
|
|
241
|
+
// Map iteration is insertion-ordered, so this evicts the oldest entry.
|
|
242
|
+
const oldestKey = stmtCache.keys().next().value
|
|
243
|
+
if (oldestKey != null) {
|
|
244
|
+
const oldest = stmtCache.get(oldestKey)
|
|
245
|
+
stmtCache.delete(oldestKey)
|
|
246
|
+
try { oldest?.finalize?.() } catch { /* already gone; nothing to release */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
stmtCache.set(sql, stmt)
|
|
250
|
+
return stmt
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Finalize and forget every cached statement.
|
|
255
|
+
*
|
|
256
|
+
* MUST run before any `close()` of the connection they were prepared against,
|
|
257
|
+
* and MUST leave the cache empty: a statement prepared against the OLD
|
|
258
|
+
* connection would otherwise be handed out after the reopen and operate on a
|
|
259
|
+
* dead handle. Individual `finalize()` failures are swallowed — the close is
|
|
260
|
+
* the authority on whether the release actually happened.
|
|
261
|
+
*/
|
|
262
|
+
function finalizeCachedStatements(): void {
|
|
263
|
+
for (const stmt of stmtCache.values()) {
|
|
264
|
+
try { stmt.finalize?.() } catch { /* best-effort; close(true) reports the truth */ }
|
|
265
|
+
}
|
|
266
|
+
stmtCache.clear()
|
|
267
|
+
}
|
|
268
|
+
|
|
177
269
|
/**
|
|
178
270
|
* Loud, unconditional failure logging for the history writer.
|
|
179
271
|
*
|
|
@@ -300,9 +392,9 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
300
392
|
// (`thread_id IS NULL` / `thread_id = ?`) is unchanged.
|
|
301
393
|
const LOGICAL_KEY_INDEX = 'idx_messages_logical_key'
|
|
302
394
|
const logicalKeyIndexExists =
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
395
|
+
prep(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`).get(
|
|
396
|
+
LOGICAL_KEY_INDEX,
|
|
397
|
+
) != null
|
|
306
398
|
if (!logicalKeyIndexExists) {
|
|
307
399
|
// De-dupe rows an earlier (pre-fix) build already appended, keeping the
|
|
308
400
|
// NEWEST row per logical key (highest ts, then highest rowid = the last
|
|
@@ -346,7 +438,7 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
346
438
|
|
|
347
439
|
if (retentionDays > 0) {
|
|
348
440
|
const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400
|
|
349
|
-
|
|
441
|
+
prep('DELETE FROM messages WHERE ts < ?').run(cutoff)
|
|
350
442
|
}
|
|
351
443
|
|
|
352
444
|
// Boot-time writer self-check (2026-07-16 incident hardening). "history
|
|
@@ -391,15 +483,15 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
|
|
|
391
483
|
const sentinelId = Date.now()
|
|
392
484
|
try {
|
|
393
485
|
// Clear any stale sentinel from a prior crashed self-check first.
|
|
394
|
-
|
|
395
|
-
|
|
486
|
+
prep('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
|
|
487
|
+
prep(
|
|
396
488
|
`INSERT OR REPLACE INTO messages
|
|
397
489
|
(chat_id, thread_id, message_id, role, ts, text)
|
|
398
490
|
VALUES (?, NULL, ?, 'assistant', ?, ?)`,
|
|
399
491
|
).run(SENTINEL_CHAT, sentinelId, Math.floor(Date.now() / 1000), 'selfcheck')
|
|
400
|
-
const row =
|
|
401
|
-
|
|
402
|
-
|
|
492
|
+
const row = prep(
|
|
493
|
+
'SELECT text FROM messages WHERE chat_id = ? AND message_id = ?',
|
|
494
|
+
).get(SENTINEL_CHAT, sentinelId) as { text?: string } | undefined
|
|
403
495
|
if (row?.text !== 'selfcheck') {
|
|
404
496
|
return { ok: false, error: 'sentinel row not read back after insert' }
|
|
405
497
|
}
|
|
@@ -409,7 +501,7 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
|
|
|
409
501
|
} finally {
|
|
410
502
|
// Never leave the sentinel behind, even if the SELECT/assert path threw.
|
|
411
503
|
try {
|
|
412
|
-
|
|
504
|
+
prep('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
|
|
413
505
|
} catch {
|
|
414
506
|
/* best-effort cleanup */
|
|
415
507
|
}
|
|
@@ -424,21 +516,40 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
|
|
|
424
516
|
* empty instead of throwing. Do NOT use for writes — every write path goes
|
|
425
517
|
* through the record* functions above so redaction and validity checks
|
|
426
518
|
* cannot be bypassed.
|
|
519
|
+
*
|
|
520
|
+
* The returned `prepare` routes through the module statement cache rather than
|
|
521
|
+
* handing out the raw `Database`. That is deliberate: a statement prepared
|
|
522
|
+
* directly off the connection by an outside caller is one `reopenHistory`
|
|
523
|
+
* cannot finalize, and a single such statement is enough to make the hard close
|
|
524
|
+
* throw. Keeping the seam inside the cache keeps the reopen deterministic no
|
|
525
|
+
* matter who else reads.
|
|
427
526
|
*/
|
|
428
527
|
export function getHistoryDbForBriefing(): {
|
|
429
528
|
prepare(sql: string): { all(...params: unknown[]): unknown[] }
|
|
430
529
|
} | null {
|
|
431
|
-
return
|
|
530
|
+
if (db == null) return null
|
|
531
|
+
return {
|
|
532
|
+
prepare(sql: string) {
|
|
533
|
+
return { all: (...params: unknown[]) => prep(sql).all(...params) }
|
|
534
|
+
},
|
|
535
|
+
}
|
|
432
536
|
}
|
|
433
537
|
|
|
434
538
|
/**
|
|
435
539
|
* For tests — close the singleton and forget it. Production code never
|
|
436
540
|
* needs this; the DB is held open for the lifetime of the process.
|
|
541
|
+
*
|
|
542
|
+
* Uses the SAME deterministic hard close as {@link reopenHistory}: a soft
|
|
543
|
+
* `close()` here leaked three fds per test (`h.db`, `-wal`, `-shm`), which is
|
|
544
|
+
* how the state-dir prefix guard in the sweep test came to look covered when it
|
|
545
|
+
* was not — the leaked fds from the previous test were doing the filtering.
|
|
437
546
|
*/
|
|
438
547
|
export function _resetForTests(): void {
|
|
548
|
+
historyReopenFailure = null
|
|
439
549
|
if (db != null) {
|
|
440
|
-
db
|
|
550
|
+
const current = db
|
|
441
551
|
db = null
|
|
552
|
+
hardCloseDb(current)
|
|
442
553
|
}
|
|
443
554
|
}
|
|
444
555
|
|
|
@@ -458,7 +569,7 @@ export function _resetForTests(): void {
|
|
|
458
569
|
export function checkpointWal(): boolean {
|
|
459
570
|
if (db == null) return false
|
|
460
571
|
try {
|
|
461
|
-
|
|
572
|
+
prep('PRAGMA wal_checkpoint(TRUNCATE)').run()
|
|
462
573
|
// Re-apply permissions after WAL truncation (SQLite may recreate -wal/-shm)
|
|
463
574
|
if (dbPath) {
|
|
464
575
|
for (const suffix of ['-shm', '-wal']) {
|
|
@@ -475,6 +586,152 @@ export function checkpointWal(): boolean {
|
|
|
475
586
|
}
|
|
476
587
|
}
|
|
477
588
|
|
|
589
|
+
/**
|
|
590
|
+
* Drop the current history handle and re-open the DB from its path.
|
|
591
|
+
*
|
|
592
|
+
* THE FAILURE MODE THIS RECOVERS
|
|
593
|
+
* ------------------------------
|
|
594
|
+
* A FOREIGN process rw-opens `history.db`, and on exit — as the last
|
|
595
|
+
* connection — checkpoints and UNLINKS `history.db-wal` / `-shm`. Our
|
|
596
|
+
* long-lived connection keeps the deleted inodes mapped and keeps writing into
|
|
597
|
+
* them; every INSERT still reports success and the rows are simply gone at the
|
|
598
|
+
* next restart. Signature: `/proc/<pid>/fd/N -> …/history.db-wal (deleted)`.
|
|
599
|
+
* A container restart heals it; this is that restart, in-process. Detection and
|
|
600
|
+
* the call site live in `gateway/orphaned-db-sweep.ts`.
|
|
601
|
+
*
|
|
602
|
+
* WHY `close()` IS NOT ENOUGH (measured, not assumed)
|
|
603
|
+
* ---------------------------------------------------
|
|
604
|
+
* bun:sqlite's default `close()` is a SOFT close: it defers the real
|
|
605
|
+
* `sqlite3_close` while any un-finalized `Statement` still holds the
|
|
606
|
+
* connection. Measured on bun 1.3.13: after a plain `db.close()` the process
|
|
607
|
+
* STILL held `h.db`, `h.db-wal (deleted)` and `h.db-shm (deleted)` in
|
|
608
|
+
* `/proc/self/fd`, and the reopen then produced a connection that LOOKS
|
|
609
|
+
* healthy — `new Database(...)` and `PRAGMA journal_mode = WAL` both SUCCEED —
|
|
610
|
+
* and throws on the first WRITE instead (`SQLITE_IOERR_SHORT_READ`, errno 522),
|
|
611
|
+
* because SQLite's unix VFS keeps per-inode WAL-index state alive for as long
|
|
612
|
+
* as any connection to that inode is open. A naive close-and-reopen therefore
|
|
613
|
+
* turns silent row loss into a TOTAL history outage, and does it at a point
|
|
614
|
+
* where the reopen has already "succeeded". (The same DB file opened from a
|
|
615
|
+
* SEPARATE process reads fine, which is how we know the on-disk DB is not
|
|
616
|
+
* corrupt and the poisoning is in-process.)
|
|
617
|
+
*
|
|
618
|
+
* So the close has to be a HARD one — {@link hardCloseDb}, in two steps that
|
|
619
|
+
* are both load-bearing:
|
|
620
|
+
* 1. FINALIZE every outstanding statement, explicitly. Every statement in
|
|
621
|
+
* this module is created through {@link prep} and held in `stmtCache`
|
|
622
|
+
* precisely so this step can exist: `finalizeCachedStatements()` releases
|
|
623
|
+
* each `sqlite3_stmt` synchronously. This is the determinism. The earlier
|
|
624
|
+
* version of this code had no cache and leaned on `Bun.gc(true)` to
|
|
625
|
+
* collect the per-call wrappers, which is a coin flip: 13/20 failures with
|
|
626
|
+
* an empty heap and 30/30 with an arithmetic loop before the gc.
|
|
627
|
+
* `Bun.gc(true)` is still called, but only as belt-and-braces for a
|
|
628
|
+
* statement some future caller creates outside the cache — correctness no
|
|
629
|
+
* longer depends on it, and the test suite proves that by asserting the
|
|
630
|
+
* reopen under the exact heap shape that made the gc-only version fail
|
|
631
|
+
* every time.
|
|
632
|
+
* 2. `close(true)`, which THROWS rather than silently deferring. If we cannot
|
|
633
|
+
* actually close we must NOT null `db` and pretend to have recovered — we
|
|
634
|
+
* rethrow so the sweep logs the failure and asks for a restart, leaving
|
|
635
|
+
* the (bad but working) old handle in place rather than a null one that
|
|
636
|
+
* fails every write.
|
|
637
|
+
*
|
|
638
|
+
* NO EXPLICIT SALVAGE CHECKPOINT
|
|
639
|
+
* ------------------------------
|
|
640
|
+
* We never issue `wal_checkpoint` through the orphaned handle. Once a new
|
|
641
|
+
* `-shm` exists on disk, our handle and any other connection are in DISJOINT
|
|
642
|
+
* locking domains and a checkpoint through the stale mapping can corrupt the
|
|
643
|
+
* main DB. Losing rows is strictly better than corrupting the ones that landed.
|
|
644
|
+
* `close()` does still run SQLite's IMPLICIT checkpoint-on-close through the
|
|
645
|
+
* old fds — a knowingly accepted residual, not an oversight: it is exactly what
|
|
646
|
+
* a clean container restart already does today, so this path is no more
|
|
647
|
+
* dangerous than the restart it replaces. (In the isolated case it also
|
|
648
|
+
* SALVAGES the orphaned rows; we do not rely on that, because the concurrent
|
|
649
|
+
* case cannot.)
|
|
650
|
+
*
|
|
651
|
+
* `initHistory` early-returns when `db != null`, so nulling is load-bearing —
|
|
652
|
+
* without it the reopen is a silent no-op and the orphaned handle keeps eating
|
|
653
|
+
* rows. The re-init re-runs the idempotent DDL/migrations, the chmod/ownership
|
|
654
|
+
* fixups, retention, and `verifyHistoryWritable()` — a free post-reopen proof
|
|
655
|
+
* that the new handle can actually persist a row.
|
|
656
|
+
*
|
|
657
|
+
* THREE FAILURE SHAPES, THREE DIFFERENT RESIDUAL STATES
|
|
658
|
+
* -----------------------------------------------------
|
|
659
|
+
* If the hard CLOSE throws, `db` is untouched: history is still lossy but
|
|
660
|
+
* functional, and the orphaned fds are still present so the sweep re-detects
|
|
661
|
+
* and retries on the next tick. If the close SUCCEEDS and the re-init throws,
|
|
662
|
+
* `db` is null and the orphaned fds are gone — the sweep's fd detector will
|
|
663
|
+
* never fire again — so that state is recorded in {@link historyReopenFailure}
|
|
664
|
+
* for the sweep to re-alarm on independently of detection. Without that flag a
|
|
665
|
+
* failed re-init is a permanent, silent history outage: strictly worse than the
|
|
666
|
+
* bug this whole path exists to fix. If the re-init succeeds but the SELF-CHECK
|
|
667
|
+
* fails, `db` is the new (readable, un-writable) handle: reads keep working,
|
|
668
|
+
* the flag is set, and the sweep keeps retrying — the state is dead for writes
|
|
669
|
+
* but there is no reason to also blind the read paths.
|
|
670
|
+
*
|
|
671
|
+
* Throws if the hard close, the re-init, or the post-reopen writer self-check
|
|
672
|
+
* fails. Callers must treat a throw as "history is still broken, restart
|
|
673
|
+
* required".
|
|
674
|
+
*/
|
|
675
|
+
export function reopenHistory(stateDir: string, retentionDays = 30): void {
|
|
676
|
+
const current = db
|
|
677
|
+
if (current != null) {
|
|
678
|
+
// Deliberately NOT caught: a failed close means the fds are still open and
|
|
679
|
+
// writes through the reopened handle would fail. Propagate with `db` intact.
|
|
680
|
+
hardCloseDb(current)
|
|
681
|
+
}
|
|
682
|
+
db = null
|
|
683
|
+
try {
|
|
684
|
+
initHistory(stateDir, retentionDays)
|
|
685
|
+
// `initHistory` only WARNS when the self-check fails and returns normally,
|
|
686
|
+
// so without this the sweep would log "writes are durable again" on a full
|
|
687
|
+
// disk, right next to "WRITER SELF-CHECK FAILED". Prove it, don't claim it.
|
|
688
|
+
const check = verifyHistoryWritable()
|
|
689
|
+
if (!check.ok) {
|
|
690
|
+
throw new Error(`post-reopen writer self-check failed: ${check.error ?? 'unknown'}`)
|
|
691
|
+
}
|
|
692
|
+
} catch (err) {
|
|
693
|
+
historyReopenFailure = err instanceof Error ? err.message : String(err)
|
|
694
|
+
throw err
|
|
695
|
+
}
|
|
696
|
+
historyReopenFailure = null
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Deterministically release a connection: finalize every statement this module
|
|
701
|
+
* owns, then hard-close.
|
|
702
|
+
*
|
|
703
|
+
* Exported for the sweep's own use and for tests that need to prove the close
|
|
704
|
+
* is real. `Bun.gc(true)` runs first as belt-and-braces for any statement
|
|
705
|
+
* created outside the cache (a future caller, or bun's own internal
|
|
706
|
+
* transaction statements); the finalize pass is what makes the close
|
|
707
|
+
* deterministic. `close(true)` is NOT caught — a deferred close is exactly the
|
|
708
|
+
* failure this function exists to surface.
|
|
709
|
+
*/
|
|
710
|
+
export function hardCloseDb(handle: SqliteDatabase): void {
|
|
711
|
+
finalizeCachedStatements()
|
|
712
|
+
const gc = (globalThis as { Bun?: { gc?: (force: boolean) => void } }).Bun?.gc
|
|
713
|
+
if (typeof gc === 'function') {
|
|
714
|
+
try { gc(true) } catch { /* best-effort; close(true) below reports the truth */ }
|
|
715
|
+
}
|
|
716
|
+
handle.close(true)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Sticky record of a reopen that closed the old handle successfully and then
|
|
721
|
+
* failed to re-init. Non-null means `db` is null and every read/write path is
|
|
722
|
+
* dead — a state nothing else in the process can detect, because the orphaned
|
|
723
|
+
* fds that triggered the reopen are gone.
|
|
724
|
+
*
|
|
725
|
+
* Read by the orphaned-DB sweep on EVERY tick, independently of fd detection,
|
|
726
|
+
* so the outage keeps alarming and keeps being retried.
|
|
727
|
+
*/
|
|
728
|
+
let historyReopenFailure: string | null = null
|
|
729
|
+
|
|
730
|
+
/** The sticky reopen-failure message, or null when history is healthy. */
|
|
731
|
+
export function getHistoryReopenFailure(): string | null {
|
|
732
|
+
return historyReopenFailure
|
|
733
|
+
}
|
|
734
|
+
|
|
478
735
|
/**
|
|
479
736
|
* Prune `messages` rows older than `retentionDays`. Used by the periodic
|
|
480
737
|
* reaper (#1073) to catch the case where the gateway runs for weeks or
|
|
@@ -497,7 +754,7 @@ export function pruneMessagesOlderThanDays(
|
|
|
497
754
|
if (db == null) return 0
|
|
498
755
|
if (retentionDays <= 0) return 0
|
|
499
756
|
const cutoffSec = (nowSec ?? Math.floor(Date.now() / 1000)) - retentionDays * 86400
|
|
500
|
-
const stmt =
|
|
757
|
+
const stmt = prep(`
|
|
501
758
|
DELETE FROM messages
|
|
502
759
|
WHERE rowid IN (
|
|
503
760
|
SELECT rowid FROM messages WHERE ts < ? LIMIT ?
|
|
@@ -572,7 +829,7 @@ export function recordInbound(args: RecordInboundArgs): void {
|
|
|
572
829
|
)
|
|
573
830
|
return
|
|
574
831
|
}
|
|
575
|
-
const stmt =
|
|
832
|
+
const stmt = prep(`
|
|
576
833
|
INSERT OR REPLACE INTO messages
|
|
577
834
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text, forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date, forwarded_message_id)
|
|
578
835
|
VALUES (?, ?, ?, 'user', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -650,7 +907,7 @@ export function recordOutbound(args: RecordOutboundArgs): void {
|
|
|
650
907
|
}
|
|
651
908
|
if (validRows.length === 0) return
|
|
652
909
|
const groupId = validRows[0]!.id
|
|
653
|
-
const stmt =
|
|
910
|
+
const stmt = prep(`
|
|
654
911
|
INSERT OR REPLACE INTO messages
|
|
655
912
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
|
|
656
913
|
VALUES (?, ?, ?, 'assistant', NULL, NULL, ?, ?, ?, ?)
|
|
@@ -668,7 +925,7 @@ export function recordOutbound(args: RecordOutboundArgs): void {
|
|
|
668
925
|
// (chat_id, message_id) — unique within a chat regardless of thread, the
|
|
669
926
|
// same assumption `recordEdit` / `recordReaction` already make — makes the
|
|
670
927
|
// promotion exact and is a no-op when no observer row exists.
|
|
671
|
-
const dropSystem =
|
|
928
|
+
const dropSystem = prep(
|
|
672
929
|
`DELETE FROM messages WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
|
|
673
930
|
)
|
|
674
931
|
// bun:sqlite has a transaction() helper. Cheap insurance against partial
|
|
@@ -731,9 +988,8 @@ export function recordSystemOutbound(args: RecordSystemOutboundArgs): boolean {
|
|
|
731
988
|
// blanket send observer, so a warn here would be one stderr line per API call.
|
|
732
989
|
if (db == null) return false
|
|
733
990
|
try {
|
|
734
|
-
const res =
|
|
735
|
-
|
|
736
|
-
INSERT INTO messages
|
|
991
|
+
const res = prep(`
|
|
992
|
+
INSERT INTO messages
|
|
737
993
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, kind)
|
|
738
994
|
SELECT ?, ?, ?, 'system', NULL, NULL, ?, ?, NULL, NULL, ?
|
|
739
995
|
WHERE NOT EXISTS (
|
|
@@ -777,11 +1033,9 @@ export function updateSystemOutboundText(args: {
|
|
|
777
1033
|
text: string
|
|
778
1034
|
}): boolean {
|
|
779
1035
|
try {
|
|
780
|
-
const res =
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
)
|
|
784
|
-
.run(redact(args.text), args.chat_id, args.message_id) as { changes?: number }
|
|
1036
|
+
const res = prep(
|
|
1037
|
+
`UPDATE messages SET text = ? WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
|
|
1038
|
+
).run(redact(args.text), args.chat_id, args.message_id) as { changes?: number }
|
|
785
1039
|
return (res?.changes ?? 0) > 0
|
|
786
1040
|
} catch {
|
|
787
1041
|
return false
|
|
@@ -805,12 +1059,11 @@ interface RecordEditArgs {
|
|
|
805
1059
|
* original send-time thread isn't known at edit time.
|
|
806
1060
|
*/
|
|
807
1061
|
export function recordEdit(args: RecordEditArgs): void {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
`)
|
|
1062
|
+
prep(`
|
|
1063
|
+
UPDATE messages
|
|
1064
|
+
SET text = ?
|
|
1065
|
+
WHERE chat_id = ? AND message_id = ?
|
|
1066
|
+
`)
|
|
814
1067
|
// Same outbound chokepoint as recordOutbound — an edit must not
|
|
815
1068
|
// reintroduce a raw secret into the stored row.
|
|
816
1069
|
.run(redact(args.text), args.chat_id, args.message_id)
|
|
@@ -833,12 +1086,11 @@ export interface RecordReactionArgs {
|
|
|
833
1086
|
* we match on (chat_id, message_id) and ignore thread_id — same as recordEdit.
|
|
834
1087
|
*/
|
|
835
1088
|
export function recordReaction(args: RecordReactionArgs): void {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
`)
|
|
1089
|
+
prep(`
|
|
1090
|
+
UPDATE messages
|
|
1091
|
+
SET user_reaction = ?
|
|
1092
|
+
WHERE chat_id = ? AND message_id = ?
|
|
1093
|
+
`)
|
|
842
1094
|
.run(args.emoji, args.chat_id, args.message_id)
|
|
843
1095
|
}
|
|
844
1096
|
|
|
@@ -857,11 +1109,10 @@ export interface DeleteFromHistoryArgs {
|
|
|
857
1109
|
* we match on (chat_id, message_id) and ignore thread.
|
|
858
1110
|
*/
|
|
859
1111
|
export function deleteFromHistory(args: DeleteFromHistoryArgs): void {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
`)
|
|
1112
|
+
prep(`
|
|
1113
|
+
DELETE FROM messages
|
|
1114
|
+
WHERE chat_id = ? AND message_id = ?
|
|
1115
|
+
`)
|
|
865
1116
|
.run(args.chat_id, args.message_id)
|
|
866
1117
|
}
|
|
867
1118
|
|
|
@@ -913,7 +1164,7 @@ export function getLatestInboundMessageId(
|
|
|
913
1164
|
}
|
|
914
1165
|
}
|
|
915
1166
|
sql += ' ORDER BY ts DESC, message_id DESC LIMIT 1'
|
|
916
|
-
const row =
|
|
1167
|
+
const row = prep(sql).get(...params as any[]) as
|
|
917
1168
|
| { message_id: number }
|
|
918
1169
|
| undefined
|
|
919
1170
|
return row?.message_id ?? null
|
|
@@ -947,11 +1198,17 @@ export function lookupMessageRoleAndText(
|
|
|
947
1198
|
includeSystem?: boolean
|
|
948
1199
|
},
|
|
949
1200
|
): { role: MessageRole; text: string; kind: string | null } | null {
|
|
1201
|
+
// History dead (a reopen that closed the old handle and failed to re-init) or
|
|
1202
|
+
// never initialised → "no such row", which is the answer this caller already
|
|
1203
|
+
// handles for a reaped message. A `requireDb()` throw would instead propagate
|
|
1204
|
+
// into the reaction-trigger handler; the same degrade
|
|
1205
|
+
// `hasOutboundDeliveredSince` / `hasOutboundWithText` already apply.
|
|
1206
|
+
if (db == null) return null
|
|
950
1207
|
const sql =
|
|
951
1208
|
`SELECT role, text, kind FROM messages WHERE chat_id = ? AND message_id = ?` +
|
|
952
1209
|
(opts?.includeSystem === true ? '' : ` AND role <> 'system'`) +
|
|
953
1210
|
` LIMIT 1`
|
|
954
|
-
const row =
|
|
1211
|
+
const row = prep(sql).get(chatId, messageId) as
|
|
955
1212
|
| { role: MessageRole; text: string | null; kind: string | null }
|
|
956
1213
|
| undefined
|
|
957
1214
|
if (!row) return null
|
|
@@ -963,11 +1220,9 @@ export function getRecentOutboundCount(
|
|
|
963
1220
|
withinSeconds: number,
|
|
964
1221
|
): number {
|
|
965
1222
|
const cutoff = Math.floor(Date.now() / 1000) - withinSeconds
|
|
966
|
-
const row =
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
)
|
|
970
|
-
.get(chatId, 'assistant', cutoff) as { cnt: number } | undefined
|
|
1223
|
+
const row = prep(
|
|
1224
|
+
'SELECT COUNT(*) as cnt FROM messages WHERE chat_id = ? AND role = ? AND ts >= ?',
|
|
1225
|
+
).get(chatId, 'assistant', cutoff) as { cnt: number } | undefined
|
|
971
1226
|
return row?.cnt ?? 0
|
|
972
1227
|
}
|
|
973
1228
|
|
|
@@ -1041,9 +1296,9 @@ export function hasOutboundDeliveredSince(
|
|
|
1041
1296
|
}
|
|
1042
1297
|
}
|
|
1043
1298
|
sql += ' LIMIT 1'
|
|
1044
|
-
const row =
|
|
1045
|
-
|
|
1046
|
-
|
|
1299
|
+
const row = prep(sql).get(...(params as [unknown, ...unknown[]])) as
|
|
1300
|
+
| Record<string, unknown>
|
|
1301
|
+
| undefined
|
|
1047
1302
|
return row != null
|
|
1048
1303
|
} catch {
|
|
1049
1304
|
return false
|
|
@@ -1121,9 +1376,9 @@ export function hasOutboundWithText(
|
|
|
1121
1376
|
}
|
|
1122
1377
|
// Newest first: a redelivery candidate's match is almost always recent.
|
|
1123
1378
|
sql += ' ORDER BY ts DESC LIMIT 500'
|
|
1124
|
-
const rows =
|
|
1125
|
-
|
|
1126
|
-
|
|
1379
|
+
const rows = prep(sql).all(...(params as [unknown, ...unknown[]])) as {
|
|
1380
|
+
text: string | null
|
|
1381
|
+
}[]
|
|
1127
1382
|
for (const r of rows) {
|
|
1128
1383
|
const hay = normalizeDeliveryText(r.text ?? '')
|
|
1129
1384
|
if (hay.length === 0) continue
|
|
@@ -1169,6 +1424,17 @@ export function deliveryTextMatch(hay: string, needle: string): boolean {
|
|
|
1169
1424
|
}
|
|
1170
1425
|
|
|
1171
1426
|
export function query(opts: QueryOptions): RecordedMessage[] {
|
|
1427
|
+
// History dead or never initialised → no rows, the same result the caller
|
|
1428
|
+
// gets from a genuinely empty chat. `get_recent_messages` degrades to "no
|
|
1429
|
+
// history" instead of erroring the whole tool call; the operator-visible
|
|
1430
|
+
// signal for the dead case is the sweep's sticky alarm, not this throw.
|
|
1431
|
+
if (db == null) {
|
|
1432
|
+
warnHistory(
|
|
1433
|
+
'query: history DB is not open — returning no rows. If a reopen failed, the ' +
|
|
1434
|
+
'orphaned-db-sweep is alarming about it every tick; RESTART the gateway.',
|
|
1435
|
+
)
|
|
1436
|
+
return []
|
|
1437
|
+
}
|
|
1172
1438
|
const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
|
|
1173
1439
|
const params: unknown[] = [opts.chat_id]
|
|
1174
1440
|
let sql = 'SELECT * FROM messages WHERE chat_id = ?'
|
|
@@ -1188,7 +1454,7 @@ export function query(opts: QueryOptions): RecordedMessage[] {
|
|
|
1188
1454
|
}
|
|
1189
1455
|
sql += ' ORDER BY ts DESC, message_id DESC LIMIT ?'
|
|
1190
1456
|
params.push(limit)
|
|
1191
|
-
const rows =
|
|
1457
|
+
const rows = prep(sql).all(...params as any[]) as RecordedMessage[]
|
|
1192
1458
|
// SELECT was DESC; flip to oldest-first for the caller.
|
|
1193
1459
|
rows.reverse()
|
|
1194
1460
|
return rows
|
|
@@ -72,8 +72,12 @@ function resolveSyncSqlite() {
|
|
|
72
72
|
if (typeof globalThis.Bun !== 'undefined') {
|
|
73
73
|
try {
|
|
74
74
|
const { Database } = require('bun:sqlite')
|
|
75
|
-
return function BunDatabaseSyncAdapter(p) {
|
|
76
|
-
|
|
75
|
+
return function BunDatabaseSyncAdapter(p, opts) {
|
|
76
|
+
// Translate node:sqlite's `readOnly` to bun:sqlite's `readonly` so a
|
|
77
|
+
// read-only call site is read-only on BOTH bindings. See the
|
|
78
|
+
// `readBackgroundFlagSync` comment for why that matters.
|
|
79
|
+
// allow-rw-db-open: shared adapter — the writer call sites open RW through it
|
|
80
|
+
const d = new Database(p, opts?.readOnly ? { readonly: true } : undefined)
|
|
77
81
|
return {
|
|
78
82
|
exec: (sql) => d.exec(sql),
|
|
79
83
|
prepare: (sql) => d.prepare(sql),
|
|
@@ -90,6 +94,8 @@ function resolveSyncSqlite() {
|
|
|
90
94
|
* Calls cb(error | null) when the process exits.
|
|
91
95
|
*/
|
|
92
96
|
function spawnSql(dbPath, sql, cb) {
|
|
97
|
+
// allow-rw-db-open: this is the tracker's WRITE path (INSERT/UPDATE) — it
|
|
98
|
+
// must be read-write. The SELECT path is `spawnSqlRead`, which is -readonly.
|
|
93
99
|
const child = spawn('sqlite3', [dbPath, sql], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
94
100
|
let stderr = ''
|
|
95
101
|
child.stderr.on('data', (d) => { stderr += d })
|
|
@@ -108,7 +114,11 @@ function spawnSql(dbPath, sql, cb) {
|
|
|
108
114
|
* Calls cb(error | null, stdout | null).
|
|
109
115
|
*/
|
|
110
116
|
function spawnSqlRead(dbPath, sql, cb) {
|
|
111
|
-
|
|
117
|
+
// `-readonly`: this hook is a FOREIGN process against a DB the gateway holds
|
|
118
|
+
// open. The sqlite3 CLI defaults to read-write, and a read-write handle that
|
|
119
|
+
// closes as the LAST connection checkpoints and UNLINKS the `-wal`/`-shm`
|
|
120
|
+
// sidecars, orphaning the gateway's mapped fds (#4595). This path only SELECTs.
|
|
121
|
+
const child = spawn('sqlite3', ['-readonly', dbPath, sql], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
112
122
|
let stdout = ''
|
|
113
123
|
let stderr = ''
|
|
114
124
|
child.stdout.on('data', (d) => { stdout += d })
|
|
@@ -629,7 +639,12 @@ function readBackgroundFlagSync(dbPath, id) {
|
|
|
629
639
|
const DatabaseSync = resolveSyncSqlite()
|
|
630
640
|
if (DatabaseSync == null) return null
|
|
631
641
|
try {
|
|
632
|
-
|
|
642
|
+
// READ-ONLY: this hook is a FOREIGN process against a DB the gateway holds
|
|
643
|
+
// open. A read-write handle that closes as the LAST connection checkpoints
|
|
644
|
+
// and UNLINKS the `-wal`/`-shm` sidecars, orphaning the gateway's mapped
|
|
645
|
+
// fds — after which its writes silently land in deleted inodes (#4595).
|
|
646
|
+
// This function only ever SELECTs.
|
|
647
|
+
const db = new DatabaseSync(dbPath, { readOnly: true })
|
|
633
648
|
const row = db.prepare('SELECT background FROM subagents WHERE id = ?').get(id)
|
|
634
649
|
db.close()
|
|
635
650
|
if (row == null) return null
|
|
@@ -123,8 +123,12 @@ function resolveSyncSqlite() {
|
|
|
123
123
|
// Adapt bun:sqlite to the node:sqlite DatabaseSync surface used
|
|
124
124
|
// below. bun's Database.prepare/run/get/all and exec are
|
|
125
125
|
// sufficient — we only need the call-site shape.
|
|
126
|
-
return function BunDatabaseSyncAdapter(p) {
|
|
127
|
-
|
|
126
|
+
return function BunDatabaseSyncAdapter(p, opts) {
|
|
127
|
+
// Translate node:sqlite's `readOnly` to bun:sqlite's `readonly` so a
|
|
128
|
+
// read-only call site is read-only on BOTH bindings. See the
|
|
129
|
+
// `readBackgroundFlagSync` comment for why that matters.
|
|
130
|
+
// allow-rw-db-open: shared adapter — the writer call sites open RW through it
|
|
131
|
+
const d = new Database(p, opts?.readOnly ? { readonly: true } : undefined)
|
|
128
132
|
return {
|
|
129
133
|
exec: (sql) => d.exec(sql),
|
|
130
134
|
prepare: (sql) => d.prepare(sql),
|
|
@@ -141,6 +145,8 @@ function resolveSyncSqlite() {
|
|
|
141
145
|
* Calls cb(error | null) when the process exits.
|
|
142
146
|
*/
|
|
143
147
|
function spawnSql(dbPath, sql, cb) {
|
|
148
|
+
// allow-rw-db-open: this is the tracker's WRITE path (INSERT/UPDATE) — it
|
|
149
|
+
// must be read-write. The SELECT path is `spawnSqlRead`, which is -readonly.
|
|
144
150
|
const child = spawn('sqlite3', [dbPath, sql], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
145
151
|
let stderr = ''
|
|
146
152
|
child.stderr.on('data', (d) => { stderr += d })
|