fix(dashboard): heal named-profile session stores behind the schema contract (OOF-76) - #80237
Closed
shannonsands wants to merge 1 commit into
Closed
fix(dashboard): heal named-profile session stores behind the schema contract (OOF-76)#80237shannonsands wants to merge 1 commit into
shannonsands wants to merge 1 commit into
Conversation
…(OOF-76) Named-profile session stores (<home>/profiles/<name>/state.db) drift behind the dashboard's read surface silently: idle profiles' gateways never open them writable, so _reconcile_columns() never runs and every cross-profile session route 500s on the missing tables/columns until a redeploy happens to force a writable open. Two production instances were found serving permanent 500s this way while readiness stayed green. Fixes: - hermes_state_common: new SCHEMA_SQL-derived contract (state_schema_mismatches) — diffs a live store's regular tables against the contract instead of the application schema_version integer, which legitimately lags SCHEMA_VERSION on FTS5-unavailable runtimes (the original conflation behind the false-green). - web_server: _open_session_db_at_path() — shared heal-capable read-only opener. Detects staleness via a hot-surface read probe plus the contract diff (cached on SQLite's schema cookie, one PRAGMA read per open when converged), then runs ONE serialized idempotent writable reconcile per (store identity, stale cookie) state. Deterministic heal failures are never retried as DDL per poll; transient lock/busy contention stays retryable. Store identity is (path, dev, inode) so restore-replaced files get fresh state. - web_routers/profiles: both cross-profile aggregation routes (/api/profiles/sessions, /api/profiles/sessions/sidebar) now go through the shared opener instead of raw read-only SessionDB opens. - gateway/readiness: state_db probe is now schema-aware (a readable but drifted store degrades instead of staying green), and a new bounded profile_state_dbs probe surfaces named-profile drift as an ADVISORY check — visible in checks but excluded from the restart-driving overall verdict, since restarts cannot heal an idle profile store and an unhealable degraded rollup would invite a restart loop (OOF-39 class). The capped scan rotates its window across polls so fleets larger than the cap still get eventual coverage, and truncation is reported explicitly. Public /api/status deliberately keeps its default-store-only storage probe for the same restart-loop reason; profile drift reports on the authenticated detailed readiness endpoint. Tests: contract-diff unit tests (lagging schema_version is NOT drift; missing tables/columns are; extra tables/columns tolerated), readiness probe tests (drift, foreign-only stores, bounded/rotating scan, advisory rollup), and route regressions (idle-profile heal for missing column and table, sidebar heal, repeated drift in one process, concurrent stale reads heal exactly once, deterministic-vs-transient heal failure semantics).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem (OOF-76)
Named-profile session stores (
<home>/profiles/<name>/state.db) drift behind the dashboard's read surface silently: idle profiles' gateways never open them writable, so_reconcile_columns()never runs. Once a store misses newer read-surface tables/columns (system_prompts,sessions.last_activity_at/system_prompt_hash, ...), every cross-profile dashboard session route 500s forever — until a redeploy happens to force a writable open somewhere. Two production instances were forensically confirmed serving permanent 500s this way (one store stuck at schema v23) while readiness stayed green.The false-green had a second cause worth calling out: the application
schema_versioninteger legitimately lagsSCHEMA_VERSIONon FTS5-unavailable runtimes even when every regular table has converged, so any drift check keyed on that integer is wrong by design. The fix diffs the actual table contract instead.Fix
hermes_state_common.py— new SCHEMA_SQL-derived contract:_state_schema_contract()builds a{table: {columns}}map by executingSCHEMA_SQLagainst an in-memory database (same technique asSessionSchemaMixin._parse_schema_columns), cached per process.state_schema_mismatches(conn)diffs a live store's regular tables against it. FTS virtual tables and theschema_versioninteger are deliberately out of scope. A store with zero tables is uninitialised (bootstrap's job), not stale; once any contract table exists, the full contract is required. Extra tables/columns are tolerated (reconcile only ever ADDs).hermes_cli/web_server.py— shared heal-capable read-only opener_open_session_db_at_path():PRAGMA schema_version— the engine's DDL counter) so converged stores pay one PRAGMA read per open.(store identity, stale cookie)state. Concurrent stale reads produce exactly one DDL heal. Deterministic heal failures (disk full, unfixable DDL) are never re-run as DDL on every poll; transient lock/busy contention un-marks itself and stays retryable.(path, st_dev, st_ino)so a restore/recovery replacing the file in place gets fresh heal + validation state; a second drift later in the process lifetime gets its own heal attempt (keyed on the observed cookie) instead of 500ing until restart.hermes_cli/web_routers/profiles.py— both cross-profile aggregation routes (/api/profiles/sessions,/api/profiles/sessions/sidebar) now go through the shared opener instead of raw read-onlySessionDBopens (the primary 500 path in the incident).gateway/readiness.py— schema-aware probes:state_dbprobe now degrades on a readable-but-drifted default store (was readability-only — the exact false-green).profile_state_dbsprobe: bounded scan of named-profile stores (count cap and 1s wall-clock budget — each sqlite connect carries a 1s busy timeout), aggregate counts only (never profile names/paths), capped window rotates across polls so fleets larger than the cap get eventual coverage, truncation reported explicitly.checksbut excluded from the restart-driving overall verdict. Restarting cannot heal an idle profile store (only the dashboard's heal-capable read path can), so folding it into the rollup would invite an unhealable-signal restart loop (OOF-39 class). Public/api/statuskeeps its default-store-only storage probe for the same reason.Tests
tests/state/test_state_schema_contract.py(new): contract contents, FTS exclusion, caching; converged/empty/foreign-only stores; laggingschema_versioninteger is NOT drift (the OOF-76 false-positive); missing table/column reporting; extra tables/columns tolerated.tests/gateway/test_readiness.py: drift degradesstate_db; foreign-only store is drift; uninitialised stores stay ok; profile probe aggregate counts + no name leakage; bounded scan + truncation flag; window rotation reaches a stale store past the cap; advisory rollup (drifted profile store ⇒ overall still ok, check degraded); disk probe pinned (host sits above the 90% threshold — pre-existing environmental failure now mooted).tests/hermes_cli/test_web_server.py: idle named-profile heal for missing column AND missing table; sidebar route heal; repeated drift in one process heals again; 6 concurrent stale reads → exactly one writable reconcile, all 200; deterministic heal failure attempted once, transient lock failure retried and healed.Validation
tests/gateway/: branch 10 failed / 4857 passed vs baseline (origin/main@ ff3793f) 12 failed / 4849 passed — zero branch-only failures; the branch additionally fixes the environmental readiness failure.test_dashboard_param_clamps+test_write_lock_patienceflakes on both sides).Fixes OOF-76.