Files
orca/src/main/sqlite/sqlite-read-failure.ts
T
Neil 9062494f9b fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript (#16587)
* fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript

#15036 reported "1 transcript skipped / database is locked" with both Agent
Session History scopes empty. Two separate defects.

The panel counts every unkinded scan issue as a skipped transcript, so a
failure that lost an entire *source* was reported as one lost *file*. The
whole-database failure is now kinded `scope`, and an unknown `kind` from a
newer host degrades to `scope` instead of failing validation and coming back
unkinded — a mixed-version remote host previously turned a source-level
failure into a phantom skipped transcript.

The read also inherited sqlite3's 0 ms busy timeout, so a genuinely contended
open failed in ~1 ms. It now opens once with a bounded timeout. No retry loop:
sqlite's own busy handler already blocks and retries internally for the whole
timeout, and WAL readers do not block on a writer at all (measured: 547/547
cross-process reads at timeout=0 while a writer held open transactions).

Measured against a real Ubuntu-24.04 distro, Windows cannot take SQLite's file
locks over \\wsl.localhost at all: an idle, never-WAL, nothing-attached
database still answers SQLITE_BUSY, a 5 s busy timeout does not change it, and
the identical bytes open fine once copied to local disk. So a lock-family error
on that share never means "a writer holds it" and no timeout can help. The copy
says so rather than sending the user after a write-ahead log that is not the
problem. Restoring those sessions needs an in-distro read; that is a follow-up,
and this PR no longer pretends a timeout will do it.

immutable=1 is deliberately not used as a workaround: over the same share it
opens and returns 100 of 150 rows, silently dropping everything still in the
uncheckpointed -wal — in a history panel, exactly the newest sessions.

* skip the provably futile busy wait on \\wsl.localhost paths
2026-08-26 23:37:16 -07:00

78 lines
3.1 KiB
TypeScript

// Why (#15036): a read-only SQLite open can fail for reasons that demand
// opposite responses, and both arrive as an opaque driver string. Contention is
// transient — retry later, never persist it as a permanent failure. A missing
// wal-index is structural — retrying forever cannot fix it.
/** Primary result codes; extended codes pack the primary code in the low byte. */
const SQLITE_BUSY = 5
const SQLITE_LOCKED = 6
const SQLITE_CANTOPEN = 14
// Shared with the Codex index-heal pass, which only ever sees a relayed message
// string (app-server RPC drops `errcode`), so message matching is not optional.
const CONTENTION_MESSAGE = /SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i
function primaryErrcode(error: unknown): number | null {
if (!error || typeof error !== 'object' || !('errcode' in error)) {
return null
}
const errcode = (error as { errcode?: unknown }).errcode
return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* True when a SQLite failure means "someone else holds the database right now".
* @param error - The thrown value from a `node:sqlite` call or a relayed message.
* @returns Whether the caller should retry later rather than record a failure.
*/
export function isTransientSqliteContention(error: unknown): boolean {
const errcode = primaryErrcode(error)
if (errcode === SQLITE_BUSY || errcode === SQLITE_LOCKED) {
return true
}
return CONTENTION_MESSAGE.test(errorText(error))
}
export type SqliteReadFailureKind =
/** Another connection holds the lock; the same read can succeed later. */
| 'contended'
/**
* The database file itself is present, but SQLite still could not open the
* set of files it needs — in practice the `-wal`/`-shm` pair a read-only WAL
* open requires, which shares that cannot host SQLite shared memory (9p
* `\\wsl.localhost`, SMB, SSHFS) refuse. Retrying cannot fix it.
*/
| 'wal-index-unavailable'
/** Anything else: corrupt file, wrong schema, permissions. */
| 'unreadable'
/**
* Classify why a read-only SQLite open or query failed.
*
* Pure: the caller supplies the "does the database file exist" evidence, so
* neither branch needs a filesystem probe of its own. That matters on a WSL 9p
* share, where an extra sync stat is exactly the hang the transcript gate exists
* to prevent.
* @param args.error - The thrown value.
* @param args.databaseFileExists - Whether the main database file was present.
* @returns Which of the three failure modes the error represents.
*/
export function classifySqliteReadFailure(args: {
error: unknown
databaseFileExists: boolean
}): SqliteReadFailureKind {
if (isTransientSqliteContention(args.error)) {
return 'contended'
}
// SQLITE_CANTOPEN against a database that is right there names a companion
// file — the wal-index a read-only WAL open needs — not the database itself.
if (primaryErrcode(args.error) === SQLITE_CANTOPEN && args.databaseFileExists) {
return 'wal-index-unavailable'
}
return 'unreadable'
}