refactor(state): stdlib-only home for read_only_db_uri; reuse the doctor/repair helpers

hermes_state_common pulls in agent.* at import, so the URI builder moves to
hermes_state_holders (errno/os/sqlite3/pathlib only) where the gateway
readiness probe and backup can adopt it in a follow-up sweep. The doctor
structural-damage branch is one helper instead of two copies, the holder
scan goes through hermes_state_repair._live_writer_holds_db, the migration
hint uses _schema_not_built (the startswith("no such ") check also matched
"no such module: fts5"), and the hermes_state import is hoisted so an import
failure cannot mask itself as UnboundLocalError.
This commit is contained in:
kshitijk4poor
2026-09-15 12:41:00 +05:30
committed by kshitij
parent ddbd7a9437
commit 30a299180c
7 changed files with 33 additions and 29 deletions
+19 -14
View File
@@ -10,7 +10,8 @@ from hermes_cli.doctor_report import (
warn_on_error,
)
from hermes_cli.sizefmt import format_bytes as _human_bytes
from hermes_state_common import FTS_STORAGE_VERSION, read_only_db_uri
from hermes_state_common import FTS_STORAGE_VERSION
from hermes_state_holders import read_only_db_uri
def _honcho_is_configured_for_doctor() -> bool:
@@ -166,9 +167,8 @@ def _write_health_reason(state_db_path: Path, *, should_fix: bool):
"""FTS/write-health probe (a rolled-back BEGIN IMMEDIATE). Against a store a live writer holds,
that probe is the second-writer class (#103339), so probe a read-only snapshot instead; a quiet
store is probed in place. Returns the failure reason, or None when healthy or skipped."""
from hermes_state_repair import _connect_repair_durable, _db_opens_cleanly
from hermes_state_holders import live_writer_holds_db
if not live_writer_holds_db(state_db_path, connect_repair_durable=_connect_repair_durable):
from hermes_state_repair import _db_opens_cleanly, _live_writer_holds_db
if not _live_writer_holds_db(state_db_path):
return _db_opens_cleanly(state_db_path)
if not should_fix and state_db_path.stat().st_size > _WRITE_PROBE_SNAPSHOT_MAX_BYTES:
check_info("state.db write-health probe skipped: store is held by a live writer and larger than 1 GB "
@@ -236,14 +236,22 @@ def _repair_state_db(f: Finding, should_fix: bool, state_db_path: Path, kind: st
f.fixed += 1
def _report_structural_damage(f: Finding, should_fix: bool, state_db_path: Path, _DHH: str, reason) -> bool:
"""True (and reported/repaired) when the canonical b-tree, not just the FTS index, is damaged."""
from hermes_state_repair import state_db_has_structural_damage
if not state_db_has_structural_damage(state_db_path):
return False
check_warn(f"{_DHH}/state.db has structural corruption (canonical tables/indexes damaged, "
"not the FTS index)", f"({reason})")
_repair_state_db(f, should_fix, state_db_path, "structural")
return True
def _classify_unreadable_state_db(f: Finding, should_fix: bool, state_db_path: Path, _DHH: str, exc: Exception) -> None:
"""Structural damage first; only then schema repair. Avoids SessionDB auto-repair side effects."""
from hermes_state import is_malformed_db_error
from hermes_state_repair import state_db_has_structural_damage
if state_db_has_structural_damage(state_db_path):
check_warn(f"{_DHH}/state.db has structural corruption (canonical tables/indexes damaged, "
"not the FTS index)", f"({exc})")
return _repair_state_db(f, should_fix, state_db_path, "structural")
if _report_structural_damage(f, should_fix, state_db_path, _DHH, exc):
return
if not is_malformed_db_error(exc):
return check_warn(f"{_DHH}/state.db exists but has issues: {exc}")
# sqlite_master itself is malformed (e.g. duplicate messages_fts): every statement fails before it runs,
@@ -254,7 +262,6 @@ def _classify_unreadable_state_db(f: Finding, should_fix: bool, state_db_path: P
def _state_db_health(f: Finding, should_fix: bool, state_db_path: Path, _DHH: str) -> None:
"""Session count + FTS write-health probe; malformed-schema path when even COUNT(*) fails."""
from hermes_state_repair import state_db_has_structural_damage
try:
check_ok(f"{_DHH}/state.db exists ({_session_count(state_db_path)} sessions)")
# COUNT(*) succeeds even when the FTS index is corrupt and every write fails through the triggers.
@@ -262,10 +269,8 @@ def _state_db_health(f: Finding, should_fix: bool, state_db_path: Path, _DHH: st
except Exception as e:
return _classify_unreadable_state_db(f, should_fix, state_db_path, _DHH, e)
if _write_reason is not None:
if state_db_has_structural_damage(state_db_path):
check_warn(f"{_DHH}/state.db has structural corruption (canonical tables/indexes damaged, "
"not the FTS index)", f"({_write_reason})")
return _repair_state_db(f, should_fix, state_db_path, "structural")
if _report_structural_damage(f, should_fix, state_db_path, _DHH, _write_reason):
return
check_warn(f"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)", f"({_write_reason})")
_repair_state_db(f, should_fix, state_db_path, "fts")
+4 -2
View File
@@ -981,8 +981,8 @@ def cmd_sessions(args, sessions_parser=None):
if pre is not None:
return pre(args)
observational = action in _OBSERVATIONAL_DB_ACTIONS
from hermes_state import SessionDB, _default_db_path
try:
from hermes_state import SessionDB, _default_db_path
db = SessionDB(read_only=observational)
except Exception as e:
# mode=ro cannot create the store; a reader on a fresh profile reports empty rather than failing.
@@ -998,7 +998,9 @@ def cmd_sessions(args, sessions_parser=None):
try:
return handler(db, args)
except sqlite3.OperationalError as e:
if not observational or not str(e).lower().startswith("no such "):
from hermes_state_repair import _schema_not_built
if not observational or not _schema_not_built(e):
raise
# A read-only opener skips schema migration, so a store from an older release can lack a column.
print(f"Error: session database needs migration — run any writing hermes command first ({e})")
+2 -3
View File
@@ -27,9 +27,8 @@ from pathlib import Path
from hermes_constants import get_hermes_home, mkdir_under_hermes_home
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
from hermes_state_common import (
escape_like as _escape_like, read_only_db_uri, stat_db_file_identity as _stat_db_file_identity,
)
from hermes_state_common import escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity
from hermes_state_holders import read_only_db_uri
from hermes_state_errors import (
_DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY,
_STATE_DB_REPLACED_MSG, DeletedWalGenerationError, SessionCompressionInProgressError, StateDbCorruptError,
-7
View File
@@ -8,19 +8,12 @@ import logging
import os
import sys
import time
from pathlib import Path
from typing import Any
from agent.skill_commands import SKILL_EXCERPT_JOINT, SKILL_SCAFFOLD_SQL_LIKE, describe_skill_invocation
from agent.context_compressor import (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX, _MERGED_PRIOR_CONTEXT_HEADER,
_MERGED_SUMMARY_DELIMITER, _SUMMARY_END_MARKER)
def read_only_db_uri(db_path) -> str:
"""``file:`` URI for a ``mode=ro`` open. ``as_uri()`` percent-encodes ``?``/``#`` in the home
path; a raw ``f"file:{path}?mode=ro"`` truncates there and opens the wrong (empty) database."""
return Path(db_path).resolve().as_uri() + "?mode=ro"
# Session preview = head of the first user message (shown when a session has no title). A /skill invocation
# embeds the whole skill body, so scaffolded rows take a wider excerpt (whole message under budget, else head +
+1 -2
View File
@@ -24,9 +24,8 @@ import time
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from hermes_state_holders import canonical_sqlite_path
from hermes_state_holders import canonical_sqlite_path, read_only_db_uri
from hermes_state_common import (
read_only_db_uri,
FTS_REBUILD_DEFERRAL_KEY, stat_db_file_identity as _stat_db_file_identity
)
+6
View File
@@ -21,6 +21,12 @@ except ImportError: # pragma: no cover - stripped/scaffold installs only
psutil = None # type: ignore[assignment]
def read_only_db_uri(db_path) -> str:
"""``file:`` URI for a ``mode=ro`` open. ``as_uri()`` percent-encodes ``?``/``#`` in the home
path; a raw ``f"file:{path}?mode=ro"`` truncates there and opens the wrong (empty) database."""
return Path(db_path).resolve().as_uri() + "?mode=ro"
logger = logging.getLogger(__name__)
_IS_WINDOWS = sys.platform == "win32"
+1 -1
View File
@@ -22,8 +22,8 @@ from typing import Any, Dict, List, Optional, Tuple
from hermes_constants import get_hermes_home
from hermes_startup_watchdog import report_startup_progress
from hermes_state_holders import read_only_db_uri
from hermes_state_common import (
read_only_db_uri,
_acquire_db_flock, _clear_lock_holder_record, _describe_lock_holder, _read_lock_holder_record,
is_advisory_lock_contention,
)