Skip to content

fix(sessions): eager state.db schema migration at backend startup; stop swallowing locked ALTERs - #86671

Merged
teknium1 merged 1 commit into
mainfrom
fix-statedb-eager-migration
Aug 15, 2026
Merged

fix(sessions): eager state.db schema migration at backend startup; stop swallowing locked ALTERs#86671
teknium1 merged 1 commit into
mainfrom
fix-statedb-eager-migration

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

After hermes update, an existing state.db on an old schema made every GET /api/sessions poll fail with sqlite3.OperationalError: no such column: s.last_read_at (or s.last_activity_at) until something unrelated forced a writable open. The desktop sidebar showed "No sessions yet" while every session row sat intact on disk.

Fixes #79531
Fixes #80037

Root causes and fixes

The SCHEMA_SQL-derived read probe (root cause found by @wangyi0177-eng, prototyped in draft PR #80030 by @Tilly-YL) is already on main — any missing declared column now trips the read-only self-heal. This PR lands the two remaining pieces:

1. Migrations ran lazily, not at startup. _init_schema_reconcile_columns only ran on a writable open — typically the user's first NEW session. The backend lifespan now schedules one writable open of its own state.db at startup (_eager_reconcile_own_session_db), so the store is brought current before the first session-list poll on every dashboard/hermes serve/Desktop-headless entrypoint. Runs in a daemon thread (never delays the ready-probe socket, GH-73083) and never raises — a store it can't fix still gets the per-poll read-probe heal.

2. _reconcile_columns swallowed lock contention at DEBUG (root cause found by @www654cc-pixel). With orphaned hermes serve backends holding the DB, the ALTER TABLE sessions ADD COLUMN last_read_at hit database is locked, was logged at DEBUG, and startup "succeeded" with a half-reconciled schema. The open-time lock patience (_connect_and_init_with_lock_patience, #74478) never saw the error because it was caught inside first. Now:

  • duplicate column name (harmless race) → stays DEBUG
  • database is locked / busyre-raises, so the open-time lock patience retries the whole idempotent init with jittered backoff
  • anything else (e.g. un-ADDable NOT NULL) → logged at WARNING

Tests

  • TestReconcileColumnsErrorHandling (tests/test_hermes_state.py): locked ALTER propagates; end-to-end — a transiently locked ALTER is retried by the open lock patience and the store heals (last_read_at added); duplicate-column races stay quiet; other ALTER failures warn.
  • test_startup_eager_reconcile_heals_stale_store (tests/hermes_cli/test_web_server.py): a store missing sessions.last_read_at is healed by the startup reconcile and serves list_sessions_rich.
  • test_startup_eager_reconcile_never_raises: a locked store cannot break startup.

All targeted suites pass locally (tests/test_hermes_state.py, tests/hermes_cli/test_web_server.py heal/stale/eager selection, tests/test_hermes_state_compression_busy_retry.py, tests/test_hermes_state_readonly_preflight.py, tests/test_web_server_sessiondb_eventloop.py). The one pre-existing failure on main (test_search_projection_skips_context_enrichment_queries) is unrelated and fails identically without this diff.

Credit

Infographic omitted: FAL image hosting unavailable (account balance exhausted); generation itself succeeded but no fal.media URL could be produced.

…up and stop swallowing locked ALTERs

After `hermes update`, an existing state.db on an old schema made every
GET /api/sessions poll fail with sqlite3.OperationalError "no such
column: s.last_read_at" (or s.last_activity_at) until something
unrelated forced a writable open — the desktop sidebar showed "No
sessions yet" while every row sat intact on disk (#79531, #80037).

Two remaining root causes (the stale hand-written read probe was
already replaced by the SCHEMA_SQL-derived probe on main, prototyped in
draft PR #80030 by @Tilly-YL):

1. Migrations ran lazily: _init_schema/_reconcile_columns only ran on a
   writable open, typically the user's first NEW session. The dashboard
   backend now schedules one writable open of its own state.db from the
   lifespan (daemon thread, never blocks the ready-probe socket, never
   raises), so the store is brought current before the first session-
   list poll on every `hermes serve` / `hermes dashboard` / Desktop
   headless entrypoint.

2. _reconcile_columns caught sqlite3.OperationalError around every
   ALTER TABLE ADD COLUMN and logged at DEBUG. Lock contention from
   orphaned sibling backends made the ALTER fail silently — startup
   "succeeded" with a half-reconciled schema, and the open-time lock
   patience (#74478) never saw the error because it was swallowed
   inside first. Now: "duplicate column" races stay at DEBUG,
   locked/busy re-raises so _connect_and_init_with_lock_patience
   retries the whole idempotent init with jittered backoff, and any
   other failure (e.g. un-ADDable NOT NULL) logs at WARNING.

Regression tests: a store missing sessions.last_read_at is healed by
the eager startup reconcile and serves list_sessions_rich; a locked
ALTER propagates and is retried to success by the open lock patience;
duplicate-column races stay quiet; other ALTER failures warn.

Fixes #79531
Fixes #80037

Reported-by: @yenhunghuang (#79531) and @FLOW3R0111 (#80037)
Root-cause analysis: @wangyi0177-eng (stale read probe) and
@www654cc-pixel (_reconcile_columns DEBUG-swallow under lock
contention); draft PR #80030 by @Tilly-YL prototyped the probe fix.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades needs-decision Awaiting maintainer decision before any implementation labels Aug 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

running on db5e442 — fix(sessions): run state.db schema migration eagerly at back


waiting for more jobs to start…

⚠️ Warnings

OSV vulnerability scan · View job

5 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.

@teknium1
teknium1 merged commit f45813e into main Aug 15, 2026
48 checks passed
@teknium1
teknium1 deleted the fix-statedb-eager-migration branch August 15, 2026 04:36

@trevorgordon981 trevorgordon981 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Direction is sound — making migration eager and lock errors loud is the right fix for #79531/#80037, and the tests are the strongest part of the PR. One blocking question before merge.

1. The read-probe heal path is not updated for the new re-raise (blocking)

This PR changes _reconcile_columns to re-raise database is locked out the shared helper. The PR text says the per-poll read-probe heal in _open_session_db_at_path "retries on every poll," but the diff doesn't modify that caller to catch the newly re-raised OperationalError("database is locked"). If that heal wraps _reconcile_columns in a try/except that swallows only certain errors (or doesn't catch at all), a locked DB at startup escalates from a quiet DEBUG to an unhandled error / 500 on every session-list poll.

The diff's only guarantee is on the eager thread itself (its own except Exception). The per-poll heal's tolerance of the re-raise is unverified and untested. Confirm the coupling, ideally with a regression test that the poll-time heal doesn't 500 when the store is locked.

2. Message-substring matching is brittle

"locked" in str(exc).lower() or "busy" in message re-classifies any future rewording as a schema error instead of triggering the retry. SQLite's OperationalError carries sqlite_errorcode/sqlite_errorname — a structured check (exc.sqlite_errorcode in (SQLITE_BUSY, SQLITE_LOCKED)) is more robust than substring matching on the message.

3. Persistent orphaned-writer case isn't solved, only surfaced louder

On a lock that persists through all 15 retries (orphaned sibling backend), _eager_reconcile_own_session_db logs WARNING and gives up, leaving the store behind SCHEMA_SQL. The PR acknowledges the per-poll heal can lose repeatedly to the orphan. That's acceptable scope, but it should be stated as "surfaced more loudly," not implied fixed.

4. Test-constant naming mismatch risk

test_locked_alter_is_retried_by_open_lock_patience monkeypatches _WRITE_RETRY_SLOW_MIN_S / _WRITE_RETRY_SLOW_MAX_S. The surrounding retry wrapper uses _WRITE_RETRY_MIN_S/_WRITE_RETRY_MAX_S (no SLOW variants) plus _WRITE_MAX_RETRIES. If the target main doesn't define the SLOW constants, monkeypatch.setattr raises AttributeError and the test errors rather than passes. The author says it passes locally, so they presumably exist on the base — but it's an undocumented coupling to possibly-renamed constants worth double-checking (and ideally deriving from the real ones).

Tests

Five new tests across two files, covering the exact regression modes including an end-to-end retry-heal and a never-raises guard. Main gap: no test that the per-poll read-probe heal tolerates the newly re-raised database is locked (finding #1), and the retry-test constant coupling (finding #4).

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/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

3 participants