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:
+19
-14
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user