Skip to content

fix(state): serialize state.db schema surgery across processes + backup-refusal hard stop - #84882

Merged
teknium1 merged 5 commits into
mainfrom
fix/state-repair-cross-process-lock
Aug 13, 2026
Merged

fix(state): serialize state.db schema surgery across processes + backup-refusal hard stop#84882
teknium1 merged 5 commits into
mainfrom
fix/state-repair-cross-process-lock

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

state.db schema repair is now serialized across processes, so gateway + CLI + cron + Desktop can no longer race writable_schema surgery and re-corrupt the file — the exact repair/re-corrupt cascade in #69603 (P1), reproduced live today on a real corrupted install (5 failed repair attempts in 2.5h with concurrent openers).

Salvage of #69609 by @ernst-bablick (authorship preserved), widened with a backup-refusal hard stop from the incident report in the issue thread.

Changes

  • hermes_state.py (from fix(state): serialize state.db schema surgery across processes #69609): cross-process file lock around repair_state_db_schema() (fcntl on POSIX, msvcrt on Windows — no module-top fcntl import) + _bump_schema_cookie() after both sqlite_master edit sites (PRAGMA schema_version=N+1 under writable_schema — SQLite does NOT auto-bump the cookie on direct sqlite_master UPDATEs, so other processes' prepared statements never invalidated)
  • Our follow-up: _backup_db_file returns (path, reason); repair hard-stops before ANY mutating strategy (FTS rebuild, REINDEX, surgery, VACUUM) when the pre-repair backup is refused or fails, surfacing the reason in report['error']. Explicit backup=False (CLI --no-backup) unchanged.
  • 3 new tests: refusal hard-stops with source bytes byte-identical; ENOSPC copy failure surfaces reason; backup=False still repairs

Validation

Result
test_state_db_malformed_repair.py 16/16 (incl. the PR's real-subprocess flock race test asserting exactly one malformed-backup file)
test_hermes_state.py 219 passed, 1 pre-existing failure (identical on clean origin/main)
E2E duplicated messages_fts sqlite_master row (the #69603 class), two simultaneous subprocesses calling repair: strategies ['dedup_schema','already_healthy'], exactly 1 backup file, integrity ok, all 20 messages intact, zero tracebacks

Fixes #69603.

Infographic

one-repair-at-a-time

https://files.catbox.moe/dnkur0.png

ernst-bablick and others added 3 commits August 12, 2026 16:18
`repair_state_db_schema()` performs `PRAGMA writable_schema=ON` +
`sqlite_master` surgery + `VACUUM` on a private connection. The only guard
around it is `_repair_attempt_lock`, a `threading.Lock`, whose docstring
claims it "serialises concurrent web_server / gateway opens" — but a
threading lock covers threads inside one interpreter, not processes.

A normal host runs four independent processes against the same state.db:
the gateway service, the Desktop app's own `hermes serve` backend (it
spawns one per launch, not a thin client), interactive CLI sessions, and
the TUI slash worker. When two of them hit a malformed DB, both entered
the critical section and each ran the full surgery while the other was
mid-rewrite. Observed as a repair/re-corrupt cascade: the DB is repaired,
then re-corrupts minutes later, repeatedly.

Two fixes:

1. Wrap the surgery in a bounded `flock` on `<db>.repair.lock`. `flock` is
   the right primitive — the kernel drops it when the holder dies, so a
   crashed repairer cannot wedge future repairs the way a pidfile would.
   The acquire is bounded (#36644's failure shape) and, unlike the kanban
   init lock, a caller that times out must NOT proceed: here "proceed
   anyway" is exactly the unsafe interleaving. It re-probes instead, and
   reports success if the holder already healed the file.

   Under the lock, the existing `_db_opens_cleanly()` check becomes a
   double-check: a queued process finds the DB healthy and returns
   `already_healthy` rather than re-running surgery on a repaired DB.

2. Bump the schema cookie after direct `sqlite_master` edits. Ordinary DDL
   bumps it for free and every other connection compares it before running
   a prepared statement — that is how they learn to drop a cached schema.
   Editing `sqlite_master` under `writable_schema=ON` does not, so live
   connections in other processes kept writing `messages` rows through
   triggers into `messages_fts*` shadow tables the surgery had just
   deleted. SQLite's writable_schema docs call out incrementing
   `schema_version` as the required companion to such an edit.

Tests: four new cases in tests/test_state_db_malformed_repair.py, all
using real child processes and a real flock. All four fail on main and
pass with this change; the concurrency case asserts exactly one
`malformed-backup-*` file is produced by two simultaneous repairers
(two on main). Full state suite: 558 passed.

Complements #43742, which makes the *in-process* claim loser retry rather
than raise; it explicitly leaves `repair_state_db_schema()` unchanged and
does nothing cross-process. The two are independent and compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Aug 2026 incident in #69603 documented a fail-open: when the
pre-repair backup was refused (another same-process handle open),
repair_state_db_schema() recorded backup_path=None and proceeded —
leaving the writable_schema surgery, FTS-schema deletion, REINDEX and
VACUUM strategies reachable against the only remaining copy of the
damaged DB.

_backup_db_file() now returns (path, reason) and the repair path treats
any refused/failed backup as an unconditional hard stop: abort before
the first mutating strategy and surface the reason in report['error'].
Explicit backup=False (CLI --no-backup) is unchanged — that is the
operator opting out, not a silent failure.

Three new tests: refusal hard-stops with source bytes untouched,
OS-level copy failure hard-stops with the reason surfaced, and
backup=False still repairs.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 87876ca — chore(contributors): map ernst-bablick email for PR #69609 s

❌ Job failures

OS-specific tests / Windows-only tests · View job

Job OS-specific tests / Windows-only tests failed.


⚠️ Warnings

OSV vulnerability scan · View job

3 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 4m10s vs 7m15s (-42.5%). 12 job(s) slower, 11 faster, 1 unchanged.

  • OS-specific tests / Windows-only tests: -32.0s
  • Python tests / Run tests slice 11/12: +31.0s
  • Python tests / Run tests slice 10/12: -29.0s
  • Python tests / Run tests slice 9/12: -19.0s
  • Python tests / Run tests slice 7/12: +14.0s

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

state.db repair/re-corrupt cascade: schema surgery is only serialized in-process, and sqlite_master edits never bump the schema cookie

3 participants