Skip to content

fix(utils): recover corrupt SQLite stores whose corruption surfaces as another error - #13530

Merged
can1357 merged 3 commits into
can1357:mainfrom
Hunter-124:fix/sqlite-recover-swallowed-corruption
Sep 28, 2026
Merged

can1357 merged 3 commits into
can1357:mainfrom
Hunter-124:fix/sqlite-recover-swallowed-corruption

Conversation

@Hunter-124

@Hunter-124 Hunter-124 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

What

I hit this on my own machine: omp crashed on every launch with no such table: hint_usage because a corrupt agent.db never reached the recovery path, and this change makes that corruption get detected so the store is preserved and recreated the way recoverCorruption intends.

When a store opened with recoverCorruption fails to initialize with a non-corruption, non-busy error, openSqliteDatabase/openSqliteDatabaseSync now run PRAGMA quick_check(1) on the still-open handle. If the file is damaged, the failure is routed through the existing quarantine-and-recreate path instead of surfacing as a hard crash.

Why

My ~/.omp/agent/agent.db had a damaged freelist (Freelist: invalid page number 218103808, out-of-order rowids in usage_history). On 18.3.5 every launch crashed with:

SQLiteError: Database "~/.omp/agent/agent.db": no such table: hint_usage
    at prepare (bun:sqlite:345:37)
    at #m (.../dist/cli.js:16076:250)

AgentStorage.#initializeSchema creates its tables in one multi-statement db.run(). Allocating a page for the new hint_usage table hit the bad freelist and failed with SQLITE_CORRUPT, but Bun only reports the step error of the final statement in a multi-statement run() (oven-sh/bun#37415, fix pending in oven-sh/bun#37418; still present in Bun 1.4.2). The trailing CREATE TABLE IF NOT EXISTS schema_version succeeded as a no-op, so the error was dropped, and the next prepare failed with a plain SQLITE_ERROR. isSqliteCorruptionError is false for that, so recoverCorruption never fired.

Fixing it centrally in pi-utils covers every recoverCorruption store (agent.db, auth credentials, model cache, history), all of which use multi-statement db.run() DDL, and keeps working on Bun versions without the upstream fix. The probe only runs after an initializer has already failed, so the happy path is unchanged. On an unrelated init failure against a healthy store it costs one quick_check(1) pass before the original error is rethrown.

Testing

  • New regression test in packages/utils/test/sqlite.test.ts reproduces the real corruption (freelist trunk pointer past EOF, which only a page allocation trips) behind a multi-statement script. It fails on main with no such table: added and passes with the fix. The recreated store is usable.
  • packages/utils/test/sqlite.test.ts 8/8, agent-storage + history-storage suites 38/38, auth-storage-sqlite-busy 7/7. bun run check in packages/utils is clean (oxlint, oxfmt, tsgo).
  • End to end, against a copy of my actual corrupt agent.db with PI_CODING_AGENT_DIR pointed at a temp dir and omp -p --no-session --no-tools "hi":
    • released 18.3.5: crashes with no such table: hint_usage
    • this branch: preserves agent.db.corrupt-<ts>-<uuid> (+ sidecars), recreates agent.db (integrity ok, hint_usage present), and answers the prompt

  • bun check passes (packages/utils)
  • Tested locally
  • CHANGELOG updated with the required attribution

@roboomp roboomp added agent Agent runtime planning and orchestration fix review:p1 triaged labels Sep 27, 2026

@roboomp roboomp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: correct, narrowly scoped root-cause fix. After a failed init, revealHiddenCorruption sends quick_check-detected damage to the existing quarantine path. The probe runs only when recoverCorruption is set and init has already failed, and busy and corruption errors skip it. I checked the regression test: it fails against origin/main's sqlite.ts (no such table: added) and passes on the branch (8/8).
Before merge: the changelog entry has no #13530/@Hunter-124 attribution, and packages/utils/CHANGELOG.md, where the change actually lives, has no entry.
Should-fix: the regression test exercises the probe only while oven-sh/bun#37415 is unfixed. A case that doesn't depend on Bun's behavior would keep the probe covered after the Bun fix ships. One small nit on the shape of the synthesized error.
Thanks @Hunter-124 for the detailed root-cause write-up and the end-to-end check against a real corrupt agent.db.

Comment thread packages/coding-agent/CHANGELOG.md Outdated

### Fixed

- Fixed a corrupt `agent.db` crashing every launch with `no such table` (for example `hint_usage`) instead of being preserved and recreated ([#13530](https://github.com/can1357/oh-my-pi/pull/13530) by [@Hunter-124](https://github.com/Hunter-124))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: two changelog gaps.

  1. External contributions need attribution per AGENTS.md: append ([#13530](https://github.com/can1357/oh-my-pi/pull/13530) by [@Hunter-124](https://github.com/Hunter-124)). The PR checklist says attribution was added, but this line has none.
  2. The code change is in packages/utils/src/sqlite.ts, and packages/utils/CHANGELOG.md ## [Unreleased] has no entry. The fix also covers the recoverCorruption stores in pi-ai (sqlite-credential-store.ts) and pi-catalog (model-cache.ts), not only agent.db, so a pi-utils entry such as "openSqliteDatabase/openSqliteDatabaseSync with recoverCorruption now recover stores whose corruption surfaces as a non-corruption init error" would describe it accurately.

@Hunter-124 Hunter-124 Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d58b5e7 and 32cc839: the pi-coding-agent entry now carries the #13530 / @Hunter-124 attribution, and packages/utils/CHANGELOG.md has a [Unreleased] → Fixed entry describing the recoverCorruption behavior for all stores.

Comment on lines +220 to +224
const initialize = (db: Database) => {
// Bun reports only the final statement's step error, so the failed CREATE is dropped here...
db.run("CREATE TABLE IF NOT EXISTS added (value TEXT); CREATE TABLE IF NOT EXISTS existing (value TEXT);");
// ...and resurfaces as SQLITE_ERROR, which on its own would not trigger recovery.
db.prepare("SELECT value FROM added").finalize();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: this regression test only reaches revealHiddenCorruption while oven-sh/bun#37415 is unfixed. After oven-sh/bun#37418 ships, the multi-statement db.run() at line 222 will throw SQLITE_CORRUPT directly. The test will keep passing through the old isSqliteCorruptionError path, and nothing will cover the probe.

A companion case that does not depend on Bun's behavior would pin the new contract: use the same freelist-damaged fixture, and have the initializer allocate nothing and just throw new Error("init failed"). That checks that a healthy-looking read path plus a failing quick_check still rotates the store, and the ok-branch is already covered by opt-in recovery never rotates a non-corruption SQLite failure. (Before-fix check: this test fails against origin/main's sqlite.ts with no such table: added and passes on the branch, 8/8.)

@Hunter-124 Hunter-124 Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 32cc839: a non-corruption init failure on a store that fails quick_check is preserved as corruption uses the same freelist-damaged fixture with an initializer that allocates nothing and throws init failed on the first attempt. It asserts the store is rotated (single backup, byte-identical), the second attempt runs on the fresh file, and the preserved error is SQLITE_CORRUPT with the init error as cause. It fails against origin/main's sqlite.ts and passes on the branch.

Comment thread packages/utils/src/sqlite.ts Outdated
Comment on lines +78 to +86
const original = error instanceof Error ? error.message : String(error);
return Object.assign(
new Error(`database disk image is malformed (${problem}); initialization failed: ${original}`),
{
code: "SQLITE_CORRUPT",
errno: 11,
cause: error,
},
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Object.assign(new Error(msg), { cause }) sets cause as an ordinary enumerable property instead of using ErrorOptions. new Error(msg, { cause: error }) followed by assigning code/errno is the standard form. Separately, when the probe itself throws corruption (line 76), the original init error is dropped entirely, but the synthesized branch keeps it as cause. Wrapping both the same way would keep the original failure visible when triaging preserved stores.

@Hunter-124 Hunter-124 Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 32cc839: the synthesized error is now new Error(msg, { cause }) with code/errno assigned afterwards. A probe that itself throws corruption is wrapped the same way, keeping the probe error's code/errno and message and attaching the original init error as cause.

@Hunter-124
Hunter-124 force-pushed the fix/sqlite-recover-swallowed-corruption branch from 3152598 to fa0a90a Compare September 28, 2026 03:38
…s another error

Bun's multi-statement db.run() only reports the final statement's step
error (oven-sh/bun#37415). A corrupt agent.db made the hint_usage CREATE
fail with SQLITE_CORRUPT mid-script; Bun dropped it, the later prepare
failed with "no such table: hint_usage" (SQLITE_ERROR), and
recoverCorruption never fired, so omp crashed on every launch.

When an initializer with recoverCorruption fails for any non-corruption,
non-busy reason, run PRAGMA quick_check on the still-open handle; if the
file is damaged, route it through the existing quarantine-and-recreate
path. Healthy stores pay nothing: the probe only runs after a failure.
…ruption

Build the synthesized corruption error with ErrorOptions instead of an
enumerable cause property, and wrap a probe that itself throws corruption
the same way so the original initialization failure stays attached.

Add a regression case that does not depend on Bun dropping mid-script step
errors: a plain initializer failure on a store whose quick_check fails must
still be preserved and recreated.

Add the pi-utils changelog entry with PR attribution.
@Hunter-124
Hunter-124 force-pushed the fix/sqlite-recover-swallowed-corruption branch from fa0a90a to 32cc839 Compare September 28, 2026 03:44
@can1357
can1357 merged commit 7a1e775 into can1357:main Sep 28, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Agent runtime planning and orchestration fix review:p1 triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants