diff --git a/hermes_cli/doctor_state.py b/hermes_cli/doctor_state.py index 038315f6e6..0a66427f0e 100644 --- a/hermes_cli/doctor_state.py +++ b/hermes_cli/doctor_state.py @@ -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") diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index f9c1497d88..1a53aea286 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -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})") diff --git a/hermes_state.py b/hermes_state.py index ef11001d33..86d865cc65 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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, diff --git a/hermes_state_common.py b/hermes_state_common.py index bd02fb3ee6..453b2ecef4 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -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 + diff --git a/hermes_state_dbfile.py b/hermes_state_dbfile.py index b1333ef44d..4a96cff79e 100644 --- a/hermes_state_dbfile.py +++ b/hermes_state_dbfile.py @@ -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 ) diff --git a/hermes_state_holders.py b/hermes_state_holders.py index ab7b84bc20..794dffa301 100644 --- a/hermes_state_holders.py +++ b/hermes_state_holders.py @@ -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" diff --git a/hermes_state_repair.py b/hermes_state_repair.py index b1c22d827f..7e8cc95879 100644 --- a/hermes_state_repair.py +++ b/hermes_state_repair.py @@ -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, )