simplify(compat): hermes_state — drop 81 re-exports + 3 registry aliases + 3 shims, repoint 45 callers + 60 test files

hermes_state.py: delete every '# noqa: F401 (re-exported...)' import block (hermes_state_common/errors/guard/
readpool/sessions/fts/dbfile/wal/repair/registry + agent.context_compressor _DB_PERSISTED_MARKER_KEY); keep
only the names hermes_state.py itself uses, without noqa.
hermes_state_registry.py: drop get_shared_session_db/release_shared_session_db/close_shared_session_dbs
aliases; every caller (gateway/, tools/, tui_gateway/, cron/, mcp_serve, run_agent, tests) now imports
acquire/release/close_all/release_or_close from hermes_state_registry.
hermes_state_titles.py: drop set_auto_title_if_empty shim (title_generator keeps its getattr fallback).
Re-remove shim-only names restored by 34abf954bd: latest_user_message_row_id (tests call
latest_message_row_id(key, role='user'); role-targeting assertions kept) and get_session_activity (tests
build the snapshot via agent.session_activity.build_activity_snapshot over db.get_session(sid)).
hermes_state_wal._log_once resolves its dedupe sets as module globals instead of via hermes_state;
hermes_state_repair helpers call module globals directly (tests patch hermes_state_repair.<name>).
Frozen updater surface untouched (update_cmd_maint imports only SessionDB from hermes_state).
This commit is contained in:
Teknium
2026-09-03 13:46:50 -07:00
parent a9b0dd6742
commit 53db597201
111 changed files with 558 additions and 629 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ def _surface(layer: str, code: str, retryable: bool, provider: str = "", model:
def _disk_full(candidate: Any) -> bool: def _disk_full(candidate: Any) -> bool:
try: try:
from hermes_state import is_disk_full_error from hermes_state_errors import is_disk_full_error
return bool(is_disk_full_error(candidate)) return bool(is_disk_full_error(candidate))
except Exception: # pragma: no cover - defensive import guard except Exception: # pragma: no cover - defensive import guard
+2 -4
View File
@@ -242,10 +242,8 @@ def _db_flush_failed(agent, e: Exception, batch_rows: List[Dict[str, Any]], adop
agent._db_flush_scan_prefix = None # full re-scan next flush: an exception mid-loop leaves mixed dispositions agent._db_flush_scan_prefix = None # full re-scan next flush: an exception mid-loop leaves mixed dispositions
# The only place the SQLite error is visible before it becomes a bare False — classify it so the turn-end # The only place the SQLite error is visible before it becomes a bare False — classify it so the turn-end
# explanation can distinguish lock contention from disk-full/read-only. # explanation can distinguish lock contention from disk-full/read-only.
from hermes_state import ( from hermes_state import StateDbCorruptError, StateDbReplacedError, classify_persistence_error, divert_session_transcript_jsonl
CompressionSessionClosedError, StateDbCorruptError, StateDbReplacedError, classify_persistence_error, from hermes_state_errors import CompressionSessionClosedError
divert_session_transcript_jsonl,
)
agent._last_persistence_error_cause = classify_persistence_error(e) agent._last_persistence_error_cause = classify_persistence_error(e)
if isinstance(e, (StateDbReplacedError, StateDbCorruptError)): if isinstance(e, (StateDbReplacedError, StateDbCorruptError)):
# A replaced/quarantined handle will not take this batch again — keep it on disk. # A replaced/quarantined handle will not take this batch again — keep it on disk.
+1 -1
View File
@@ -112,7 +112,7 @@ def _db_path() -> Path:
def _connect() -> sqlite3.Connection: def _connect() -> sqlite3.Connection:
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
path = _db_path() path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
+1 -1
View File
@@ -89,7 +89,7 @@ def _transaction() -> Iterator[sqlite3.Connection]:
pass pass
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA busy_timeout=5000")
apply_wal_with_fallback(conn, db_label="cron/deliveries.db") apply_wal_with_fallback(conn, db_label="cron/deliveries.db")
+1 -1
View File
@@ -44,7 +44,7 @@ def prepare_ledger(
conn: sqlite3.Connection, *, db_label: str, synchronous_full: bool = True conn: sqlite3.Connection, *, db_label: str, synchronous_full: bool = True
) -> None: ) -> None:
"""Row factory + busy timeout + WAL (with fallback) + optional ``synchronous=FULL``.""" """Row factory + busy timeout + WAL (with fallback) + optional ``synchronous=FULL``."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA busy_timeout=5000")
+6 -6
View File
@@ -60,7 +60,7 @@ def _close_late_session_db_result(future: "concurrent.futures.Future") -> None:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
db = future.result() db = future.result()
if db is not None: if db is not None:
from hermes_state import release_or_close from hermes_state_registry import release_or_close
release_or_close(db) release_or_close(db)
@@ -1684,15 +1684,15 @@ def _open_cron_session_db(job: dict):
# timeout proceeds without a session store instead of blocking the run forever. # timeout proceeds without a session store instead of blocking the run forever.
_session_db_timeout = _get_session_db_timeout() _session_db_timeout = _get_session_db_timeout()
try: try:
from hermes_state import get_shared_session_db from hermes_state_registry import acquire
if _session_db_timeout <= 0: if _session_db_timeout <= 0:
return get_shared_session_db() return acquire()
_session_db_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) _session_db_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
# Copy the context so a profile run resolves ITS OWN home/state.db on the worker thread # Copy the context so a profile run resolves ITS OWN home/state.db on the worker thread
# instead of the process-global default. # instead of the process-global default.
_session_db_context = contextvars.copy_context() _session_db_context = contextvars.copy_context()
_session_db_future = _session_db_pool.submit(_session_db_context.run, get_shared_session_db) _session_db_future = _session_db_pool.submit(_session_db_context.run, acquire)
try: try:
return _session_db_future.result(timeout=_session_db_timeout) return _session_db_future.result(timeout=_session_db_timeout)
except concurrent.futures.TimeoutError: except concurrent.futures.TimeoutError:
@@ -1884,7 +1884,7 @@ def _final_response_from_result(result: dict, job_id: str, job_name: str, AIAgen
# Render every persistence-cause variant or cause-refined text slips through. # Render every persistence-cause variant or cause-refined text slips through.
_explainer_variants = [] _explainer_variants = []
try: try:
from hermes_state import PERSISTENCE_ERROR_CAUSES as _causes from hermes_state_errors import PERSISTENCE_ERROR_CAUSES as _causes
except Exception: except Exception:
_causes = ("locked", "disk", "unknown") _causes = ("locked", "disk", "unknown")
for _cause in (None, *_causes): for _cause in (None, *_causes):
@@ -1982,7 +1982,7 @@ def _finalize_cron_session(session_db, agent, job_id: str, job_name: str, cron_s
except (Exception, KeyboardInterrupt) as e: except (Exception, KeyboardInterrupt) as e:
logger.debug("Job '%s': failed to end session: %s", job_id, e) logger.debug("Job '%s': failed to end session: %s", job_id, e)
try: try:
from hermes_state import release_or_close from hermes_state_registry import release_or_close
release_or_close(_session_db) release_or_close(_session_db)
except (Exception, KeyboardInterrupt) as e: except (Exception, KeyboardInterrupt) as e:
logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e) logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e)
+2 -2
View File
@@ -342,8 +342,8 @@ def _entries_from_origins(platform_name: str, source: str, origins_fn) -> List[D
def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]: def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]:
"""Pull channels/contacts from state.db gateway session rows.""" """Pull channels/contacts from state.db gateway session rows."""
def _origins() -> Iterable[Tuple[Dict[str, Any], Any]]: def _origins() -> Iterable[Tuple[Dict[str, Any], Any]]:
from hermes_state import get_shared_session_db, release_or_close from hermes_state_registry import acquire, release_or_close
db = get_shared_session_db() db = acquire()
try: try:
lister = getattr(db, "list_gateway_sessions", None) lister = getattr(db, "list_gateway_sessions", None)
if not callable(lister): if not callable(lister):
+1 -1
View File
@@ -62,7 +62,7 @@ def _connect() -> sqlite3.Connection:
def _initialize_schema(conn: sqlite3.Connection) -> None: def _initialize_schema(conn: sqlite3.Connection) -> None:
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
apply_wal_with_fallback(conn, db_label="state.db (delivery_ledger)") apply_wal_with_fallback(conn, db_label="state.db (delivery_ledger)")
conn.execute( conn.execute(
"""CREATE TABLE IF NOT EXISTS delivery_obligations ( """CREATE TABLE IF NOT EXISTS delivery_obligations (
+1 -1
View File
@@ -106,7 +106,7 @@ class HostedRoomPolicyCheckpoint:
conn.execute(ddl) conn.execute(ddl)
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path, timeout=10) conn = sqlite3.connect(self.db_path, timeout=10)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
+1 -1
View File
@@ -113,7 +113,7 @@ def connect(
from the journal-mode pragma is retried (it may ignore the busy timeout while another from the journal-mode pragma is retried (it may ignore the busy timeout while another
first opener initializes the DB, especially on Windows). first opener initializes the DB, especially on Windows).
""" """
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
path = Path(db_path) path = Path(db_path)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=10) conn = sqlite3.connect(path, timeout=10)
+4 -4
View File
@@ -74,8 +74,8 @@ def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = Non
for pre-migration databases. for pre-migration databases.
""" """
try: try:
from hermes_state import get_shared_session_db, release_or_close from hermes_state_registry import acquire, release_or_close
db = get_shared_session_db() db = acquire()
try: try:
finder = getattr(db, "find_session_by_origin", None) finder = getattr(db, "find_session_by_origin", None)
session_id = finder(platform=platform, chat_id=chat_id, thread_id=thread_id, user_id=user_id) if callable(finder) else None session_id = finder(platform=platform, chat_id=chat_id, thread_id=thread_id, user_id=user_id) if callable(finder) else None
@@ -117,9 +117,9 @@ def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = Non
def _append_to_sqlite(session_id: str, message: dict) -> None: def _append_to_sqlite(session_id: str, message: dict) -> None:
"""Append a message to the SQLite session database.""" """Append a message to the SQLite session database."""
try: try:
from hermes_state import get_shared_session_db, release_or_close from hermes_state_registry import acquire, release_or_close
db = get_shared_session_db() db = acquire()
try: try:
db.append_message(session_id=session_id, role=message.get("role", "assistant"), content=message.get("content")) db.append_message(session_id=session_id, role=message.get("role", "assistant"), content=message.get("content"))
finally: finally:
+1 -1
View File
@@ -685,7 +685,7 @@ class ResponseStore:
self._conn = sqlite3.connect(":memory:", check_same_thread=False) self._conn = sqlite3.connect(":memory:", check_same_thread=False)
self._db_path = None self._db_path = None
# Shared WAL-fallback so response_store.db degrades gracefully on NFS/SMB/FUSE homes. # Shared WAL-fallback so response_store.db degrades gracefully on NFS/SMB/FUSE homes.
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
apply_wal_with_fallback(self._conn, db_label="response_store.db") apply_wal_with_fallback(self._conn, db_label="response_store.db")
self._conn.execute( self._conn.execute(
"CREATE TABLE IF NOT EXISTS responses (" "CREATE TABLE IF NOT EXISTS responses ("
@@ -80,7 +80,7 @@ class RunIdempotencyStore:
"process memory, so replay will not survive a restart: %s", exc) "process memory, so replay will not survive a restart: %s", exc)
self._conn = sqlite3.connect(":memory:", check_same_thread=False) self._conn = sqlite3.connect(":memory:", check_same_thread=False)
self._db_path = None self._db_path = None
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
apply_wal_with_fallback(self._conn, db_label="runs_idempotency.db") apply_wal_with_fallback(self._conn, db_label="runs_idempotency.db")
self._conn.execute( self._conn.execute(
"""CREATE TABLE IF NOT EXISTS run_idempotency ( """CREATE TABLE IF NOT EXISTS run_idempotency (
+6 -5
View File
@@ -3629,7 +3629,8 @@ class GatewayRunner(
after recording that recoverable state so ``__init__`` can record ``_session_db_init_error`` for the after recording that recoverable state so ``__init__`` can record ``_session_db_init_error`` for the
#88235 broadcast. #88235 broadcast.
""" """
from hermes_state import AsyncSessionDB, _default_db_path, get_shared_session_db from hermes_state import AsyncSessionDB, _default_db_path
from hermes_state_registry import acquire
from gateway.session_db_recovery import RecoverableHandleCache from gateway.session_db_recovery import RecoverableHandleCache
path = Path(_default_db_path()) path = Path(_default_db_path())
cache = getattr(self, "_session_db_handle_cache", None) cache = getattr(self, "_session_db_handle_cache", None)
@@ -3659,7 +3660,7 @@ class GatewayRunner(
# Store handle unavailable: opening our own would resurrect the duplicate borrowed away. # Store handle unavailable: opening our own would resurrect the duplicate borrowed away.
raise RuntimeError("SessionStore SQLite handle unavailable") raise RuntimeError("SessionStore SQLite handle unavailable")
try: try:
return AsyncSessionDB(get_shared_session_db()) return AsyncSessionDB(acquire())
except Exception as exc: except Exception as exc:
logger.warning("SQLite session store not available: %s", exc) logger.warning("SQLite session store not available: %s", exc)
raise raise
@@ -3698,7 +3699,7 @@ class GatewayRunner(
return return
# Shared instances no-op on close() (the registry owns the lifecycle). Release the refcount # Shared instances no-op on close() (the registry owns the lifecycle). Release the refcount
# instead (#90837). # instead (#90837).
from hermes_state import release_or_close from hermes_state_registry import release_or_close
try: try:
release_or_close(inner) release_or_close(inner)
except Exception as exc: except Exception as exc:
@@ -4447,10 +4448,10 @@ def _housekeeping_auto_archive() -> None:
"""Stale-session auto-archive on a live timer (the startup hook fires once); maybe_auto_archive() """Stale-session auto-archive on a live timer (the startup hook fires once); maybe_auto_archive()
is gated by sessions.min_interval_hours. Opens its own SessionDB — SQLite connections are thread-bound.""" is gated by sessions.min_interval_hours. Opens its own SessionDB — SQLite connections are thread-bound."""
from hermes_cli.config import load_config as _load_full_config from hermes_cli.config import load_config as _load_full_config
from hermes_state import get_shared_session_db, release_or_close from hermes_state_registry import acquire, release_or_close
_sess_cfg = (_load_full_config().get("sessions") or {}) _sess_cfg = (_load_full_config().get("sessions") or {})
if _sess_cfg.get("auto_archive", False): if _sess_cfg.get("auto_archive", False):
_adb = get_shared_session_db() _adb = acquire()
try: try:
_adb.maybe_auto_archive( _adb.maybe_auto_archive(
idle_days=float(_sess_cfg.get("auto_archive_days", 3)), idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
+2 -2
View File
@@ -1656,8 +1656,8 @@ class GatewayShutdownMixin:
# Shared SessionDB instances still held by the process-wide registry (tools, cron, mirror). # Shared SessionDB instances still held by the process-wide registry (tools, cron, mirror).
# This is the safety net that guarantees no WAL write lock survives past gateway shutdown # This is the safety net that guarantees no WAL write lock survives past gateway shutdown
# (#90837). # (#90837).
from hermes_state import close_shared_session_dbs from hermes_state_registry import close_all
closed = close_shared_session_dbs() closed = close_all()
if closed: if closed:
logger.debug("Closed %d shared SessionDB instance(s) at shutdown", closed) logger.debug("Closed %d shared SessionDB instance(s) at shutdown", closed)
+4 -3
View File
@@ -51,13 +51,14 @@ class SessionPersistenceMixin:
Resolving here rather than once in ``__init__`` is the whole fix for #88532: it lets the scoping Resolving here rather than once in ``__init__`` is the whole fix for #88532: it lets the scoping
that the multiplexed inbound path already performs actually reach session storage. that the multiplexed inbound path already performs actually reach session storage.
""" """
from hermes_state import _default_db_path, get_shared_session_db from hermes_state import _default_db_path
from hermes_state_registry import acquire
path = Path(db_path) if db_path is not None else Path(_default_db_path()) path = Path(db_path) if db_path is not None else Path(_default_db_path())
def _open(): def _open():
try: try:
return get_shared_session_db(path) # process-wide registry: one writer per path return acquire(path) # process-wide registry: one writer per path
except Exception as e: except Exception as e:
if not _is_live_system_guard(e): if not _is_live_system_guard(e):
print(f"[gateway] Warning: SQLite session store unavailable, falling back to JSONL: {e}") print(f"[gateway] Warning: SQLite session store unavailable, falling back to JSONL: {e}")
@@ -195,7 +196,7 @@ class SessionPersistenceMixin:
would strand secondary profiles' handles with their WAL lock held ('database is locked' on would strand secondary profiles' handles with their WAL lock held ('database is locked' on
restart). Drained under the lock, closed outside it; a pinned handle is the pinner's.""" restart). Drained under the lock, closed outside it; a pinned handle is the pinner's."""
def _close(db) -> None: def _close(db) -> None:
from hermes_state import release_or_close # shared instances no-op on close() from hermes_state_registry import release_or_close # shared instances no-op on close()
try: try:
release_or_close(db) release_or_close(db)
except Exception as exc: except Exception as exc:
+2 -3
View File
@@ -243,9 +243,8 @@ class SessionTranscriptMixin:
try: try:
self._append_transcript_message(session_id, msg) self._append_transcript_message(session_id, msg)
except Exception as exc: except Exception as exc:
from hermes_state import ( from hermes_state import StateDbCorruptError, StateDbReplacedError
CompressionSessionClosedError, StateDbCorruptError, StateDbReplacedError, from hermes_state_errors import CompressionSessionClosedError
)
if isinstance(exc, (StateDbReplacedError, StateDbCorruptError)): if isinstance(exc, (StateDbReplacedError, StateDbCorruptError)):
self._divert_transcript_after_db_replaced(session_id, queue_session_id, exc) self._divert_transcript_after_db_replaced(session_id, queue_session_id, exc)
return return
+3 -3
View File
@@ -209,8 +209,8 @@ def recover_pending_to_db(session_db=None) -> int:
return 0 return 0
own_db = session_db is None own_db = session_db is None
if own_db: if own_db:
from hermes_state import get_shared_session_db from hermes_state_registry import acquire
session_db = get_shared_session_db() session_db = acquire()
recovered = 0 recovered = 0
try: try:
for path in flush_files: for path in flush_files:
@@ -224,7 +224,7 @@ def recover_pending_to_db(session_db=None) -> int:
finally: finally:
if own_db: # shutdown cancellation/interrupt must not strand an owned DB if own_db: # shutdown cancellation/interrupt must not strand an owned DB
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
from hermes_state import release_or_close from hermes_state_registry import release_or_close
release_or_close(session_db) release_or_close(session_db)
if recovered: if recovered:
logger.info("Recovered %d pending message(s) from shutdown flush", recovered) logger.info("Recovered %d pending message(s) from shutdown flush", recovered)
+3 -3
View File
@@ -612,16 +612,16 @@ class GatewayStatusCommandsMixin:
days = int(flag) if flag.isdigit() else days days = int(flag) if flag.isdigit() else days
i += 1 i += 1
try: try:
from hermes_state import get_shared_session_db from hermes_state_registry import acquire
from agent.insights import InsightsEngine from agent.insights import InsightsEngine
def _run_insights(): def _run_insights():
db = get_shared_session_db() db = acquire()
try: try:
engine = InsightsEngine(db) engine = InsightsEngine(db)
return engine.format_gateway(engine.generate(days=days, source=source)) return engine.format_gateway(engine.generate(days=days, source=source))
finally: finally:
from hermes_state import release_or_close from hermes_state_registry import release_or_close
release_or_close(db) release_or_close(db)
# Not a bare hop: ``SessionDB()`` resolves ``get_hermes_home()`` at call time, a # Not a bare hop: ``SessionDB()`` resolves ``get_hermes_home()`` at call time, a
+2 -1
View File
@@ -709,7 +709,8 @@ def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> None:
ns = _parse( ns = _parse(
"sessions repair", args, (("--check-only",), dict(action="store_true")), "sessions repair", args, (("--check-only",), dict(action="store_true")),
(("--no-backup",), dict(action="store_true"))) (("--no-backup",), dict(action="store_true")))
from hermes_state import DEFAULT_DB_PATH, _db_opens_cleanly, repair_state_db_schema from hermes_state import DEFAULT_DB_PATH
from hermes_state_repair import _db_opens_cleanly, repair_state_db_schema
db_path = DEFAULT_DB_PATH db_path = DEFAULT_DB_PATH
if not db_path.exists(): if not db_path.exists():
print(f"No session database at {db_path} (nothing to repair).") print(f"No session database at {db_path} (nothing to repair).")
+3 -3
View File
@@ -171,7 +171,7 @@ def _repair_state_db(f: Finding, should_fix: bool, state_db_path: Path, kind: st
ok_label, not_fixed_label, failed_issue, fix_hint = _STATE_DB_REPAIRS[kind] ok_label, not_fixed_label, failed_issue, fix_hint = _STATE_DB_REPAIRS[kind]
if not should_fix: if not should_fix:
return f.issues.append(fix_hint) return f.issues.append(fix_hint)
from hermes_state import repair_state_db_schema from hermes_state_repair import repair_state_db_schema
report = repair_state_db_schema(state_db_path) report = repair_state_db_schema(state_db_path)
if not report.get("repaired"): if not report.get("repaired"):
check_warn(not_fixed_label, f"({report.get('error')}; backup: {report.get('backup_path')})") check_warn(not_fixed_label, f"({report.get('error')}; backup: {report.get('backup_path')})")
@@ -192,7 +192,7 @@ def _state_db_health(f: Finding, should_fix: bool, state_db_path: Path, _DHH: st
check_ok(f"{_DHH}/state.db exists ({_session_count(state_db_path)} sessions)") 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; # COUNT(*) succeeds even when the FTS index is corrupt and every write fails through the triggers;
# _db_opens_cleanly drives a rolled-back write to surface that. # _db_opens_cleanly drives a rolled-back write to surface that.
from hermes_state import _db_opens_cleanly from hermes_state_repair import _db_opens_cleanly
# `_db_opens_cleanly` now drives a rolled-back write so this otherwise-silent corruption class is # `_db_opens_cleanly` now drives a rolled-back write so this otherwise-silent corruption class is
# surfaced (and repaired in place with --fix). See #50502. # surfaced (and repaired in place with --fix). See #50502.
_write_reason = _db_opens_cleanly(state_db_path) _write_reason = _db_opens_cleanly(state_db_path)
@@ -213,7 +213,7 @@ def _state_db_stats(issues: list, state_db_path: Path) -> None:
"""Health/stats snapshot: strictly read-only (mode=ro) so it is safe against a live DB held by """Health/stats snapshot: strictly read-only (mode=ro) so it is safe against a live DB held by
the gateway; any failure degrades to one info line rather than failing doctor.""" the gateway; any failure degrades to one info line rather than failing doctor."""
with warn_on_error("state.db stats unavailable ({e})", "", report=lambda t, _d: check_info(t)): with warn_on_error("state.db stats unavailable ({e})", "", report=lambda t, _d: check_info(t)):
from hermes_state import collect_state_db_stats, count_db_holders from hermes_state_dbfile import collect_state_db_stats, count_db_holders
rows = _render_state_db_stats(collect_state_db_stats(state_db_path), holders=count_db_holders(state_db_path)) rows = _render_state_db_stats(collect_state_db_stats(state_db_path), holders=count_db_holders(state_db_path))
for _kind, _text, _detail in rows: for _kind, _text, _detail in rows:
if _kind != "warn": if _kind != "warn":
+7 -7
View File
@@ -231,12 +231,12 @@ def _maybe_checkpoint_wal(conn: sqlite3.Connection, db_path: Path) -> None:
key = str(db_path) key = str(db_path)
now = time.monotonic() now = time.monotonic()
with _WAL_CHECKPOINT_LOCK: with _WAL_CHECKPOINT_LOCK:
last = _kb._LAST_WAL_CHECKPOINT.get(key) last = _LAST_WAL_CHECKPOINT.get(key)
if last is not None and (now - last) < _WAL_CHECKPOINT_INTERVAL_SECONDS: if last is not None and (now - last) < _WAL_CHECKPOINT_INTERVAL_SECONDS:
return return
# Claim the slot first so concurrent same-process ticks don't # Claim the slot first so concurrent same-process ticks don't
# double-checkpoint on the boundary. # double-checkpoint on the boundary.
_kb._LAST_WAL_CHECKPOINT[key] = now _LAST_WAL_CHECKPOINT[key] = now
try: try:
row = conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone() row = conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone()
_kb._log.debug( _kb._log.debug(
@@ -582,7 +582,7 @@ def repair_db(db_path: Optional[Path] = None, *, board: Optional[str] = None) ->
if _missing_or_empty(resolved): if _missing_or_empty(resolved):
return RepairResult(status="missing", db_path=resolved) return RepairResult(status="missing", db_path=resolved)
with _kb._cross_process_init_lock(resolved): with _cross_process_init_lock(resolved):
messages, reason = _probe_for_corruption(resolved) messages, reason = _probe_for_corruption(resolved)
if messages is None: if messages is None:
# Same quarantine the connect-time guard takes when sqlite # Same quarantine the connect-time guard takes when sqlite
@@ -641,8 +641,8 @@ def _open_configured(path: Path, under_lock) -> tuple[sqlite3.Connection, Any]:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
with _INIT_LOCK: with _INIT_LOCK:
# WAL doesn't work on network filesystems; the helper falls back to # WAL doesn't work on network filesystems; the helper falls back to
# DELETE with one ERROR log (see hermes_state._WAL_INCOMPAT_MARKERS). # DELETE with one ERROR log (see hermes_state_wal._WAL_INCOMPAT_MARKERS).
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})") apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})")
# FULL (not NORMAL): fsync before each checkpoint to narrow the # FULL (not NORMAL): fsync before each checkpoint to narrow the
# crash window that can leave a b-tree page header torn. # crash window that can leave a b-tree page header torn.
@@ -699,7 +699,7 @@ def connect(db_path: Optional[Path] = None, *, board: Optional[str] = None) -> s
path, path,
) )
with _kb._cross_process_init_lock(path): with _cross_process_init_lock(path):
# Read-only file/sidecar preflight first, so a stray read-only kanban.db # Read-only file/sidecar preflight first, so a stray read-only kanban.db
# fails actionably instead of "attempt to write a readonly database". # fails actionably instead of "attempt to write a readonly database".
# See #12508. # See #12508.
@@ -1187,5 +1187,5 @@ def write_txn(conn: sqlite3.Connection, *, allow_nested: bool = False):
# Late-bound origin namespace (see module docstring); imported LAST so this # Late-bound origin namespace (see module docstring); imported LAST so this
# module is fully populated before ``kanban_db`` re-exports from it. # module is fully populated before ``kanban_db`` imports from it.
from hermes_cli import kanban_db as _kb # noqa: E402 from hermes_cli import kanban_db as _kb # noqa: E402
+1 -1
View File
@@ -123,7 +123,7 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
conn = sqlite3.connect(str(path)) conn = sqlite3.connect(str(path))
try: try:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
apply_wal_with_fallback(conn, db_label="projects.db") apply_wal_with_fallback(conn, db_label="projects.db")
conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA foreign_keys=ON")
+4 -2
View File
@@ -16,7 +16,9 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Iterator, Optional from typing import Any, Callable, Iterator, Optional
from hermes_state import (FTS_STORAGE_VERSION, SCHEMA_VERSION, SessionDB, _db_opens_cleanly) from hermes_state import SessionDB
from hermes_state_common import FTS_STORAGE_VERSION, SCHEMA_VERSION
from hermes_state_repair import _db_opens_cleanly
ProgressCallback = Callable[[dict[str, Any]], None] ProgressCallback = Callable[[dict[str, Any]], None]
@@ -180,7 +182,7 @@ def _copy_source_bundle(source: Path, snapshot_dir: Path) -> tuple[Path, list[st
The whole copy runs inside ``offline_file_access`` (holds the connection-lifecycle lock). Recovery The whole copy runs inside ``offline_file_access`` (holds the connection-lifecycle lock). Recovery
normally runs as its own CLI process against an offline file, so the refusal should never fire; the normally runs as its own CLI process against an offline file, so the refusal should never fire; the
guard keeps this path consistent with ``hermes_state._backup_db_file``. guard keeps this path consistent with ``hermes_state_repair._backup_db_file``.
Checking for a live connection and *then* copying would be a check/use race: a connection could open in Checking for a live connection and *then* copying would be a check/use race: a connection could open in
that window, and the copy's ``close()`` would cancel its POSIX advisory locks -- the failure class that window, and the copy's ``close()`` would cancel its POSIX advisory locks -- the failure class
+2 -1
View File
@@ -21,7 +21,8 @@ from hermes_cli.web_deps import late
from hermes_cli.web_models import ( from hermes_cli.web_models import (
BulkDeleteSessions, SessionImport, SessionOwnerBackfill, SessionPrune, SessionRename) BulkDeleteSessions, SessionImport, SessionOwnerBackfill, SessionPrune, SessionRename)
from hermes_cli.web_routers._common import log as _log, http_failure from hermes_cli.web_routers._common import log as _log, http_failure
from hermes_state import is_malformed_db_error, is_transient_sqlite_error from hermes_state import is_malformed_db_error
from hermes_state_errors import is_transient_sqlite_error
list_router = APIRouter() list_router = APIRouter()
search_router = APIRouter() search_router = APIRouter()
+26 -71
View File
@@ -24,73 +24,35 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from agent.message_sanitization import _sanitize_surrogates from agent.message_sanitization import _sanitize_surrogates
# Known-durable message marker (run_agent keeps a copy: circular import; a test pins them in sync).
from agent.context_compressor import ( # noqa: F401 (re-exported; tests import it from here)
# Intrinsic persistence marker stamped on message dicts that are known-durable (#92231). One shared
# constant with agent.context_compressor (this module already imports agent.* at module level, and
# context_compressor is a transitive dependency via hermes_state_common). run_agent keeps its own
# predating copy — hermes_state cannot import run_agent (circular) — guarded by
# test_marker_constant_in_sync.
_DB_PERSISTED_MARKER as _DB_PERSISTED_MARKER_KEY,
)
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
from hermes_state_common import ( # noqa: F401 (re-exported; tests import from hermes_state) from hermes_state_common import escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity
AUTO_VACUUM_MIN_FREELIST_RATIO, _FTS_TRIGGERS, escape_like as _escape_like, FTS_CJK_STALE_KEY, from hermes_state_errors import (
FTS_REBUILD_DEFERRAL_KEY, FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, FTS_TRIGRAM_SQL, LEGACY_FTS_SQL, _DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY,
LEGACY_FTS_TRIGRAM_SQL, SCHEMA_SQL, SCHEMA_VERSION, stat_db_file_identity as _stat_db_file_identity, _STATE_DB_REPLACED_MSG, DeletedWalGenerationError, SessionCompressionInProgressError, StateDbCorruptError,
StateDbReplacedError, _is_no_more_rows, classify_persistence_error, is_malformed_db_error,
is_malformed_schema_error,
) )
from hermes_state_errors import ( # noqa: F401 (re-exported; the historical import path) from hermes_state_guard import (
_DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_APPLICATION_ID_OFFSET, _STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _real_platform_state_root,
_STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY, _STATE_DB_REPLACED_MSG, PERSISTENCE_ERROR_CAUSES, _set_last_init_error, get_last_init_error,
CompressionSessionBusyError, CompressionSessionClosedError, DeletedWalGenerationError,
SessionCompressionInProgressError, SessionTurnLeaseLostError, StateDbCorruptError,
StateDbReplacedError, _is_no_more_rows, classify_persistence_error, is_disk_full_error,
is_malformed_db_error, is_malformed_schema_error, is_transient_sqlite_error,
) )
from hermes_state_guard import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>) from hermes_state_readpool import _READ_POOL_MAX, _proc_fd_targets, _read_budget_for
_STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _process_looks_like_pytest, from hermes_state_sessions import SessionSessionsMixin
_real_platform_state_root, _running_under_pytest, _set_last_init_error, get_last_init_error, from hermes_state_fts import SessionFtsSetupMixin, load_fts5_cjk_extension
)
from hermes_state_readpool import ( # noqa: F401 (re-exported; tests import from hermes_state)
_READ_POOL_MAX, _proc_fd_targets, _process_read_permits, _read_budget_for,
)
from hermes_state_sessions import ( # noqa: F401 (re-exported; the historical import path)
SessionSessionsMixin, _cwd_prefix_clause, workspace_key,
)
from hermes_state_fts import SessionFtsSetupMixin, load_fts5_cjk_extension # noqa: F401 (re-exported)
from hermes_state_portability import SessionPortabilityMixin from hermes_state_portability import SessionPortabilityMixin
from hermes_state_telegram import SessionTelegramTopicsMixin from hermes_state_telegram import SessionTelegramTopicsMixin
from hermes_state_schema import SessionSchemaMixin from hermes_state_schema import SessionSchemaMixin
import hermes_state_holders as _state_holders import hermes_state_holders as _state_holders
from hermes_state_dbfile import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>) from hermes_state_dbfile import (
_canonical_sqlite_path, _concrete_state_db_holder_pids, _connect_tracked_db, _canonical_sqlite_path, _connect_tracked_db, _read_sqlite_application_id, _stat_sqlite_sidecar_identity,
_is_inactive_orphan_desktop_holder, _read_sqlite_application_id, _stat_sqlite_sidecar_identity, _watched_sqlite_sidecar_paths, _watched_sqlite_sidecar_paths, is_zeroed_state_db, quarantine_cross_process_lock, quarantine_zeroed_state_db,
collect_state_db_stats, count_db_holders, is_zeroed_state_db, iter_deleted_sqlite_sidecar_holders, refuse_deleted_wal_generation,
quarantine_cross_process_lock, quarantine_zeroed_state_db, refuse_deleted_wal_generation,
) )
from hermes_state_messages import SessionMessagesMixin from hermes_state_messages import SessionMessagesMixin
from hermes_state_wal import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>) from hermes_state_wal import _WAL_INCOMPAT_MARKERS, apply_database_pragmas, apply_wal_with_fallback
WalUnsupportedError, _WAL_INCOMPAT_MARKERS, _apply_macos_checkpoint_barrier, _apply_synchronous_pragma, from hermes_state_repair import _claim_repair_attempt, preflight_db_writability, repair_state_db_schema
_database_has_content, _delete_overridden_warned_paths, _enforce_macos_synchronous_full,
_journal_upgrade_warned_paths, _on_disk_journal_mode, _wal_fallback_warned_paths,
_wal_reset_bug_warned_paths, _wal_reset_repair_hint, apply_database_pragmas, apply_wal_with_fallback,
is_sqlite_wal_reset_vulnerable, resolve_journal_mode, resolve_synchronous_level, sqlite_source_id,
)
from hermes_state_repair import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>)
_MAX_MALFORMED_BACKUPS, _MAX_PERSISTENT_REPAIR_ATTEMPTS, _REPAIR_BACKUP_MIN_FREE_BYTES,
_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND, _backup_content_identity, _backup_db_file,
_claim_repair_attempt, _connect_repair_durable, _copy_database_snapshot, _cross_process_repair_lock,
_db_fingerprint, _db_opens_cleanly, _existing_malformed_backups, _live_writer_holds_db,
_persistent_repair_attempts_exhausted, _probe_journal_mode_for_repair, _prune_malformed_backups,
_read_repair_ledger, _record_repair_outcome, _release_auto_maintenance_lock,
_repair_backup_headroom_bytes, _repair_ledger_path, _repair_scratch_space_error,
_repair_snapshot_timeout_seconds, _repair_state_db_schema_locked, _restore_journal_mode_after_repair,
_exclusive_repair_db_guard, _run_repair_strategies,
_try_acquire_auto_maintenance_lock, _unlink_db_triple, apply_durability_barriers,
preflight_db_writability, repair_state_db_schema,
)
from hermes_state_titles import SessionTitlesMixin from hermes_state_titles import SessionTitlesMixin
from hermes_state_usage import SessionUsageMixin from hermes_state_usage import SessionUsageMixin
from hermes_state_maintenance import SessionMaintenanceMixin from hermes_state_maintenance import SessionMaintenanceMixin
@@ -350,23 +312,16 @@ def divert_session_transcript_jsonl(session_id: str, messages) -> "Optional[Path
# Process-wide shared SessionDB registry: long-lived in-process callers share ONE writer # Process-wide shared SessionDB registry: long-lived in-process callers share ONE writer
# connection per resolved path via get_shared_session_db(); one-shots use SessionDB() + close(). # connection per resolved path via hermes_state_registry.acquire(); one-shots use SessionDB() + close().
def _foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]: def _foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]:
"""Compatibility delegate to the state-holder authority.""" """Compatibility delegate to the state-holder authority."""
return _state_holders.foreign_state_db_holders(db_path) return _state_holders.foreign_state_db_holders(db_path)
# ── Process-wide shared SessionDB registry (#90837) ── The registry itself lives in # ── Process-wide shared SessionDB registry (#90837) ── lives in hermes_state_registry.py (acquire /
# hermes_state_registry.py — a bounded module owning acquisition, generation identity, refcounting, # release / close_all / release_or_close). Long-lived in-process callers (gateway, tui_gateway, cron,
# retirement, and teardown. These re-exports keep the historical import path (``from hermes_state import # in-process tools) share ONE writer connection per resolved path via hermes_state_registry.acquire(); CLI
# get_shared_session_db``) working for every call site and test that imports from here. Routing rules (see # one-shots, recovery flows, and read-only cross-profile opens use SessionDB() directly with their own close().
# hermes_state_registry for the full lifecycle): - Long-lived in-process callers (gateway, tui_gateway,
# cron, in-process tools) share ONE writer connection per resolved path via get_shared_session_db(). - CLI
# one-shots, recovery flows, and read-only cross-profile opens keep using SessionDB() directly with their
# own close().
from hermes_state_registry import ( # noqa: F401 (re-export)
close_shared_session_dbs, get_shared_session_db, release_or_close,
)
class SessionDB( class SessionDB(
@@ -509,8 +464,8 @@ class SessionDB(
self._token_writer_thread: Optional[threading.Thread] = None self._token_writer_thread: Optional[threading.Thread] = None
self._token_writer_stop = self._token_writer_busy = False self._token_writer_stop = self._token_writer_busy = False
self._token_atexit_hook: Optional[Callable[[], None]] = None self._token_atexit_hook: Optional[Callable[[], None]] = None
# Opened via get_shared_session_db(): close() releases a refcount instead. # Opened via hermes_state_registry.acquire(): close() releases a refcount instead.
# Set True when this instance is opened via get_shared_session_db(). Makes close() a no-op so the # Set True when this instance is opened via hermes_state_registry.acquire(). Makes close() a no-op so the
# registry (not individual callers) controls the connection lifecycle (#90837). # registry (not individual callers) controls the connection lifecycle (#90837).
self._shared_registry_owned = False self._shared_registry_owned = False
initialization_complete = False initialization_complete = False
@@ -1174,7 +1129,7 @@ class SessionDB(
Drains queued token deltas first (the background writer needs the connection). Read-only connections Drains queued token deltas first (the background writer needs the connection). Read-only connections
never request a checkpoint. See #45383. never request a checkpoint. See #45383.
When this instance is shared (opened via ``get_shared_session_db``), ``close()`` RELEASES one When this instance is shared (opened via ``hermes_state_registry.acquire``), ``close()`` RELEASES one
refcount instead of tearing down the connection: the registry owns the lifecycle and only closes on refcount instead of tearing down the connection: the registry owns the lifecycle and only closes on
the final release (#90837). This prevents one caller's close from tearing down the writer connection the final release (#90837). This prevents one caller's close from tearing down the writer connection
that other callers in the same process are still using — while still letting legacy ``close()`` call that other callers in the same process are still using — while still letting legacy ``close()`` call
+3 -3
View File
@@ -1,5 +1,5 @@
"""Shared constants and helpers for the SessionDB family of modules. Lives outside hermes_state so """Shared constants and helpers for the SessionDB family of modules. Lives outside hermes_state so
the mixin modules can import it without a cycle; hermes_state re-exports every name.""" the mixin modules can import it without a cycle."""
import contextlib import contextlib
import errno import errno
@@ -817,7 +817,7 @@ END;
# structural rebuild (FTS5 'rebuild' or `_recover_stale_fts`'s drop/recreate) must run in ONE at a time — # structural rebuild (FTS5 'rebuild' or `_recover_stale_fts`'s drop/recreate) must run in ONE at a time —
# concurrent rebuilds corrupted state.db in production. Gates `rebuild_fts()`, `_rebuild_fts_indexes()`, # concurrent rebuilds corrupted state.db in production. Gates `rebuild_fts()`, `_rebuild_fts_indexes()`,
# `_recover_stale_fts()`; the chunked backfill (`fts_rebuild_step`) is deliberately NOT routed through it (it # `_recover_stale_fts()`; the chunked backfill (`fts_rebuild_step`) is deliberately NOT routed through it (it
# claims progress under SQLite transaction authority). Mirrors `hermes_state._cross_process_repair_lock`: # claims progress under SQLite transaction authority). Mirrors `hermes_state_repair._cross_process_repair_lock`:
# portable (msvcrt/flock), bounded wait, FAIL CLOSED; orphaned-fd holders (see `_acquire_db_flock`) are broken # portable (msvcrt/flock), bounded wait, FAIL CLOSED; orphaned-fd holders (see `_acquire_db_flock`) are broken
# only when provably dead, indeterminate liveness defers. `<db>.fts_rebuild.lock` is distinct from # only when provably dead, indeterminate liveness defers. `<db>.fts_rebuild.lock` is distinct from
# `<db>.repair.lock` (offline schema surgery, minutes in VACUUM). Lives here: mixins cannot import hermes_state. # `<db>.repair.lock` (offline schema surgery, minutes in VACUUM). Lives here: mixins cannot import hermes_state.
@@ -832,7 +832,7 @@ END;
# `SessionSchemaMixin._rebuild_fts_indexes()` (via `_init_schema`), and # `SessionSchemaMixin._rebuild_fts_indexes()` (via `_init_schema`), and
# `SessionSchemaMixin._recover_stale_fts()`. The chunked deferred backfill (`fts_rebuild_step`) is # `SessionSchemaMixin._recover_stale_fts()`. The chunked deferred backfill (`fts_rebuild_step`) is
# deliberately NOT routed through it — it claims progress under `_execute_write`'s SQLite transaction # deliberately NOT routed through it — it claims progress under `_execute_write`'s SQLite transaction
# authority and is intentionally multi-process. Semantics mirror `hermes_state._cross_process_repair_lock` # authority and is intentionally multi-process. Semantics mirror `hermes_state_repair._cross_process_repair_lock`
# (the schema- surgery authority): portable (msvcrt on Windows, flock elsewhere), bounded wait, and FAIL # (the schema- surgery authority): portable (msvcrt on Windows, flock elsewhere), bounded wait, and FAIL
# CLOSED — a caller that cannot acquire the lock must NOT rebuild. The kernel drops both lock types when the # CLOSED — a caller that cannot acquire the lock must NOT rebuild. The kernel drops both lock types when the
# holder dies — UNLESS a forked child inherited the lock fd (flock rides the open file description, which # holder dies — UNLESS a forked child inherited the lock fd (flock rides the open file description, which
+1 -1
View File
@@ -198,7 +198,7 @@ class SessionCompressionMixin:
See #75316. See #75316.
``None`` = unbounded (no internal flush happened). See #47202. ``None`` = unbounded (no internal flush happened). See #47202.
""" """
from hermes_state import CompressionSessionBusyError from hermes_state_errors import CompressionSessionBusyError
def _do(conn): def _do(conn):
if require_lease_refresh and compression_lock_holder: if require_lease_refresh and compression_lock_holder:
conn.execute( conn.execute(
+6 -6
View File
@@ -2,10 +2,9 @@
Header probes (application_id / zeroed-file detection), deleted-WAL-sidecar Header probes (application_id / zeroed-file detection), deleted-WAL-sidecar
holder scans, quarantine of zeroed databases, ``collect_state_db_stats`` and holder scans, quarantine of zeroed databases, ``collect_state_db_stats`` and
holder-process classification. Every name is re-imported into ``hermes_state`` holder-process classification. Helpers that hermes_state itself imports and
so ``hermes_state.<name>`` keeps resolving — and tests that monkeypatch it keep calls (``_connect_tracked_db`` & co) are looked up lazily from ``hermes_state`` at
intercepting, because intra-module calls to patched helpers go through a lazy call time, so tests that monkeypatch ``hermes_state.<name>`` keep intercepting.
``from hermes_state import ...`` at call time.
""" """
from __future__ import annotations from __future__ import annotations
@@ -81,7 +80,7 @@ def _pread_db_header(db_path: Path, length: int) -> "Optional[bytes]":
def _read_sqlite_application_id(db_path: Path) -> "Optional[int]": def _read_sqlite_application_id(db_path: Path) -> "Optional[int]":
"""application_id from the SQLite header, via the lock-safe :func:`_pread_db_header`.""" """application_id from the SQLite header, via the lock-safe :func:`_pread_db_header`."""
from hermes_state import _STATE_DB_APPLICATION_ID_OFFSET from hermes_state_errors import _STATE_DB_APPLICATION_ID_OFFSET
end = _STATE_DB_APPLICATION_ID_OFFSET + 4 end = _STATE_DB_APPLICATION_ID_OFFSET + 4
header = _pread_db_header(db_path, end) header = _pread_db_header(db_path, end)
if header is None or len(header) < end or header[:16] != b"SQLite format 3\x00": if header is None or len(header) < end or header[:16] != b"SQLite format 3\x00":
@@ -142,7 +141,8 @@ def iter_deleted_sqlite_sidecar_holders(db_path) -> List[Tuple[int, str]]:
def refuse_deleted_wal_generation(db_path) -> None: def refuse_deleted_wal_generation(db_path) -> None:
"""Raise if any process holds a deleted WAL/SHM generation for *db_path*; called """Raise if any process holds a deleted WAL/SHM generation for *db_path*; called
*before* ``sqlite3.connect`` so a second opener cannot mint a replacement WAL inode.""" *before* ``sqlite3.connect`` so a second opener cannot mint a replacement WAL inode."""
from hermes_state import DeletedWalGenerationError, _DELETED_WAL_GENERATION_MSG from hermes_state import DeletedWalGenerationError
from hermes_state_errors import _DELETED_WAL_GENERATION_MSG
if not iter_deleted_sqlite_sidecar_holders(db_path): if not iter_deleted_sqlite_sidecar_holders(db_path):
return return
logger.error(_DELETED_WAL_GENERATION_MSG) logger.error(_DELETED_WAL_GENERATION_MSG)
+2 -1
View File
@@ -147,7 +147,8 @@ class SessionGatewayMixin:
Fails closed: anything whose parent, age, argv, or network connections Fails closed: anything whose parent, age, argv, or network connections
cannot be proved safe remains a repair-blocking holder.""" cannot be proved safe remains a repair-blocking holder."""
from hermes_state import _concrete_state_db_holder_pids, _is_inactive_orphan_desktop_holder, psutil from hermes_state import psutil
from hermes_state_dbfile import _concrete_state_db_holder_pids, _is_inactive_orphan_desktop_holder
if not sys.platform.startswith("linux") or psutil is None: if not sys.platform.startswith("linux") or psutil is None:
return [] return []
try: try:
+4 -3
View File
@@ -28,7 +28,7 @@ def _like(value: str) -> str:
def _cwd_prefix_filter(value: str) -> Tuple[List[str], list]: def _cwd_prefix_filter(value: str) -> Tuple[List[str], list]:
from hermes_state import _cwd_prefix_clause from hermes_state_sessions import _cwd_prefix_clause
clause, params = _cwd_prefix_clause(value) clause, params = _cwd_prefix_clause(value)
return [clause], list(params) return [clause], list(params)
@@ -107,7 +107,8 @@ class SessionMaintenanceMixin:
def _write_guards_reject(self, conn, sid: str, **kwargs) -> bool: def _write_guards_reject(self, conn, sid: str, **kwargs) -> bool:
"""True when a live turn lease / compression lock protects ``sid``; expired or """True when a live turn lease / compression lock protects ``sid``; expired or
dead-holder guards are reclaimed and fenced as a side effect.""" dead-holder guards are reclaimed and fenced as a side effect."""
from hermes_state import SessionCompressionInProgressError, SessionTurnLeaseLostError from hermes_state import SessionCompressionInProgressError
from hermes_state_errors import SessionTurnLeaseLostError
try: try:
self._check_transcript_write_guards( self._check_transcript_write_guards(
conn, sid, compression_lock_holder=None, turn_lease_holder=None, conn, sid, compression_lock_holder=None, turn_lease_holder=None,
@@ -376,7 +377,7 @@ class SessionMaintenanceMixin:
``request_dump_*``) for pruned sessions are removed as part of the same sweep (issue #3015). ``request_dump_*``) for pruned sessions are removed as part of the same sweep (issue #3015).
Messaging and UI sources are never touched here. See #54189. Messaging and UI sources are never touched here. See #54189.
""" """
from hermes_state import _release_auto_maintenance_lock, _try_acquire_auto_maintenance_lock from hermes_state_repair import _release_auto_maintenance_lock, _try_acquire_auto_maintenance_lock
result: Dict[str, Any] = {"skipped": False, "pruned": 0, "closed": 0, "vacuumed": False} result: Dict[str, Any] = {"skipped": False, "pruned": 0, "closed": 0, "vacuumed": False}
maintenance_lock = _try_acquire_auto_maintenance_lock(self.db_path) maintenance_lock = _try_acquire_auto_maintenance_lock(self.db_path)
if maintenance_lock is None: if maintenance_lock is None:
+3 -11
View File
@@ -189,7 +189,8 @@ class SessionMessagesMixin:
#74478 patience note below). User-initiated transcript mutations may opt in to rejecting an active #74478 patience note below). User-initiated transcript mutations may opt in to rejecting an active
unowned turn lease in that same transaction. unowned turn lease in that same transaction.
""" """
from hermes_state import CompressionSessionClosedError, SessionCompressionInProgressError, SessionTurnLeaseLostError from hermes_state import SessionCompressionInProgressError
from hermes_state_errors import CompressionSessionClosedError, SessionTurnLeaseLostError
# NOTE (#75316 redesign): appends do NOT check compression_locks. The lock's job is to stop two # NOTE (#75316 redesign): appends do NOT check compression_locks. The lock's job is to stop two
# COMPRESSIONS colliding, not to fence ordinary transcript writes. Concurrent appends during a # COMPRESSIONS colliding, not to fence ordinary transcript writes. Concurrent appends during a
# compression are safe by construction: archive_and_compact() commits against a watermark captured # compression are safe by construction: archive_and_compact() commits against a watermark captured
@@ -410,15 +411,6 @@ class SessionMessagesMixin:
(session_id, role, int(offset))) (session_id, role, int(offset)))
return row[0] if row else None return row[0] if row else None
def latest_user_message_row_id(self, session_id: str) -> Optional[int]:
"""Row id of the most recent active user message, or ``None``.
The agent's default reaction target: "the message that triggered me",
so the model never has to thread row ids through a tool call (mirrors
the photon adapter's ``_record_last_inbound``).
"""
return self.latest_message_row_id(session_id, role="user")
def get_message_role(self, session_id: str, row_id: int) -> Optional[str]: def get_message_role(self, session_id: str, row_id: int) -> Optional[str]:
"""Role of the active message at *row_id* in *session_id*, or ``None``.""" """Role of the active message at *row_id* in *session_id*, or ``None``."""
if not session_id: if not session_id:
@@ -461,7 +453,7 @@ class SessionMessagesMixin:
is inserted as fresh active rows exactly as in the destructive path, so the live view is identical is inserted as fresh active rows exactly as in the destructive path, so the live view is identical
either way; only the durability of the dropped turns differs. either way; only the durability of the dropped turns differs.
""" """
from hermes_state import CompressionSessionClosedError from hermes_state_errors import CompressionSessionClosedError
def _do(conn): def _do(conn):
if reject_active_turn_lease: if reject_active_turn_lease:
self._check_transcript_write_guards( self._check_transcript_write_guards(
-6
View File
@@ -214,12 +214,6 @@ def stats() -> Dict[str, int]:
} }
# Backwards-compatible aliases (hermes_state re-exports them).
get_shared_session_db = acquire
release_shared_session_db = release
close_shared_session_dbs = close_all
def release_or_close(db: "SessionDB") -> None: def release_or_close(db: "SessionDB") -> None:
"""Release a shared instance, or close it when it is not registry-managed. Drop-in for a """Release a shared instance, or close it when it is not registry-managed. Drop-in for a
plain ``db.close()``: read-only opens, CLI one-shots and test fakes fall back.""" plain ``db.close()``: read-only opens, CLI one-shots and test fakes fall back."""
+11 -22
View File
@@ -1,7 +1,6 @@
"""state.db repair, backup and writability preflight (split from hermes_state). """state.db repair, backup and writability preflight (split from hermes_state).
Every name is re-imported into ``hermes_state``; intra-module calls to patchable helpers go through a lazy Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_repair.<name>``.
``from hermes_state import ...`` at call time so monkeypatches there still intercept.
""" """
from __future__ import annotations from __future__ import annotations
@@ -291,7 +290,7 @@ def _backup_free_space_error(db_path: Path) -> Optional[str]:
def _repair_snapshot_timeout_seconds(source_path: Path) -> float: def _repair_snapshot_timeout_seconds(source_path: Path) -> float:
"""Bound one SQLite snapshot by source size incl. sidecars (a WAL can hold committed rows not yet in the """Bound one SQLite snapshot by source size incl. sidecars (a WAL can hold committed rows not yet in the
main file), so a healthy large-database copy is not cut off by the repair-lock timeout.""" main file), so a healthy large-database copy is not cut off by the repair-lock timeout."""
from hermes_state import _REPAIR_LOCK_TIMEOUT_SECONDS, _REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND from hermes_state import _REPAIR_LOCK_TIMEOUT_SECONDS
source_bytes = 0 source_bytes = 0
for candidate in (source_path, *_sidecars(source_path)): for candidate in (source_path, *_sidecars(source_path)):
with contextlib.suppress(FileNotFoundError): # a sidecar may vanish mid-walk with contextlib.suppress(FileNotFoundError): # a sidecar may vanish mid-walk
@@ -584,15 +583,14 @@ def _connect_repair_durable(db_path: Path, *, timeout: float = 5.0) -> sqlite3.C
def _repair_conn(db_path: Path, *, timeout: float = 5.0): def _repair_conn(db_path: Path, *, timeout: float = 5.0):
"""A :func:`_connect_repair_durable` connection as a context manager, closed on exit.""" """A :func:`_connect_repair_durable` connection as a context manager, closed on exit."""
from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state.<name> return contextlib.closing(_connect_repair_durable(db_path, timeout=timeout))
return contextlib.closing(_connect(db_path, timeout=timeout))
def _reapply_durability_barriers(conn: sqlite3.Connection) -> bool: def _reapply_durability_barriers(conn: sqlite3.Connection) -> bool:
"""Best-effort (re)application of the macOS write barriers; True if accepted. """Best-effort (re)application of the macOS write barriers; True if accepted.
Call before ``VACUUM``/``REINDEX`` once the schema parses: a connection opened Call before ``VACUUM``/``REINDEX`` once the schema parses: a connection opened
on a malformed schema could not take them at open time. Never raises.""" on a malformed schema could not take them at open time. Never raises."""
from hermes_state import _apply_macos_checkpoint_barrier, _enforce_macos_synchronous_full from hermes_state_wal import _apply_macos_checkpoint_barrier, _enforce_macos_synchronous_full
try: try:
_apply_macos_checkpoint_barrier(conn) _apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn) _enforce_macos_synchronous_full(conn)
@@ -605,7 +603,7 @@ def apply_durability_barriers(conn: sqlite3.Connection) -> bool:
"""Durability barriers for guest users of ``state.db`` that must inherit its owner's journal mode. Also """Durability barriers for guest users of ``state.db`` that must inherit its owner's journal mode. Also
applies the configured ``database.synchronous`` level, a per-connection pragma that otherwise only applies the configured ``database.synchronous`` level, a per-connection pragma that otherwise only
rides on the journal-mode setup path guests must not run.""" rides on the journal-mode setup path guests must not run."""
from hermes_state import _apply_synchronous_pragma from hermes_state_wal import _apply_synchronous_pragma
ok = _reapply_durability_barriers(conn) ok = _reapply_durability_barriers(conn)
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
from hermes_cli.config import cfg_get, load_config_readonly # local: avoids an import cycle from hermes_cli.config import cfg_get, load_config_readonly # local: avoids an import cycle
@@ -624,8 +622,7 @@ def _close_unpinned(conn: sqlite3.Connection) -> None:
def _open_exclusive(db_path: Path, begin: str) -> sqlite3.Connection: def _open_exclusive(db_path: Path, begin: str) -> sqlite3.Connection:
"""Zero-timeout connection holding ``locking_mode=EXCLUSIVE`` after a rolled-back """Zero-timeout connection holding ``locking_mode=EXCLUSIVE`` after a rolled-back
*begin*; closed (unpinned) and re-raised when exclusion cannot be taken.""" *begin*; closed (unpinned) and re-raised when exclusion cannot be taken."""
from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state.<name> conn = _connect_repair_durable(db_path, timeout=0.0)
conn = _connect(db_path, timeout=0.0)
try: try:
for statement in ("PRAGMA locking_mode=EXCLUSIVE", begin, "ROLLBACK"): for statement in ("PRAGMA locking_mode=EXCLUSIVE", begin, "ROLLBACK"):
conn.execute(statement) conn.execute(statement)
@@ -702,8 +699,7 @@ def _db_opens_cleanly(db_path: Path) -> Optional[str]:
# of entries in index" when a B-tree index (e.g. idx_sessions_handoff_state) falls out of sync with its # of entries in index" when a B-tree index (e.g. idx_sessions_handoff_state) falls out of sync with its
# base table. REINDEX rewrites the index b-tree from the canonical table rows using the existing index # base table. REINDEX rewrites the index b-tree from the canonical table rows using the existing index
# definition, fixing the mismatch without touching data or FTS schema. # definition, fixing the mismatch without touching data or FTS schema.
from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state.<name> conn = _connect_repair_durable(db_path)
conn = _connect(db_path)
try: try:
with contextlib.closing(conn): with contextlib.closing(conn):
# Best-effort tokenizer load: messages_fts_cjk needs cjk_unicode61 before any statement can touch it; # Best-effort tokenizer load: messages_fts_cjk needs cjk_unicode61 before any statement can touch it;
@@ -766,8 +762,7 @@ def _live_writer_holds_db(db_path: Path) -> bool:
repair is then serialised only by the cross-process repairer lock. Before probing, the foreign-holder scan repair is then serialised only by the cross-process repairer lock. Before probing, the foreign-holder scan
(``hermes_state_holders``) fails closed on deleted-WAL-generation, uninspectable, or unknown holders.""" (``hermes_state_holders``) fails closed on deleted-WAL-generation, uninspectable, or unknown holders."""
import hermes_state_holders as _state_holders import hermes_state_holders as _state_holders
from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state.<name> return _state_holders.live_writer_holds_db(db_path, connect_repair_durable=_connect_repair_durable)
return _state_holders.live_writer_holds_db(db_path, connect_repair_durable=_connect)
def _repair_skip(report: Dict[str, Any], verb: str, error: str, exc: Optional[BaseException] = None) -> Dict[str, Any]: def _repair_skip(report: Dict[str, Any], verb: str, error: str, exc: Optional[BaseException] = None) -> Dict[str, Any]:
@@ -794,9 +789,6 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A
See #50502. See #50502.
""" """
from hermes_state import (_cross_process_repair_lock, _db_opens_cleanly, _live_writer_holds_db,
_persistent_repair_attempts_exhausted, _probe_journal_mode_for_repair,
_record_repair_outcome, _repair_state_db_schema_locked)
report: Dict[str, Any] = {"repaired": False, "strategy": None, "backup_path": None, "error": None} report: Dict[str, Any] = {"repaired": False, "strategy": None, "backup_path": None, "error": None}
# Startup-watchdog lease: repair is I/O-bound (near-zero CPU), which the watchdog's CPU fallback would # Startup-watchdog lease: repair is I/O-bound (near-zero CPU), which the watchdog's CPU fallback would
# misread as a parked deadlock. One lease (clamped to _MAX_LEASE_S=900) beats per-chunk renewal complexity. # misread as a parked deadlock. One lease (clamped to _MAX_LEASE_S=900) beats per-chunk renewal complexity.
@@ -858,7 +850,7 @@ def _probe_journal_mode_for_repair(db_path: Path) -> Optional[str]:
"""Best-effort journal-mode probe: ``wal``/``delete``, or ``None`` when the file cannot be opened or """Best-effort journal-mode probe: ``wal``/``delete``, or ``None`` when the file cannot be opened or
probed (malformed header, concurrent opener's locks — both expected on the repair path); callers then probed (malformed header, concurrent opener's locks — both expected on the repair path); callers then
fall back to ``database.journal_mode``.""" fall back to ``database.journal_mode``."""
from hermes_state import _on_disk_journal_mode from hermes_state_wal import _on_disk_journal_mode
try: try:
with _repair_conn(db_path) as conn: with _repair_conn(db_path) as conn:
return _on_disk_journal_mode(conn) return _on_disk_journal_mode(conn)
@@ -885,7 +877,7 @@ def _restore_journal_mode_after_repair(db_path: Path, before_mode: Optional[str]
The transactional promotion already leaves the destination in its pre-repair mode, so on that path this The transactional promotion already leaves the destination in its pre-repair mode, so on that path this
is mostly the WAL-companion re-assertion; the reopen is the hazard, not the mode. See #101064. is mostly the WAL-companion re-assertion; the reopen is the hazard, not the mode. See #101064.
""" """
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
try: try:
if conn is None: if conn is None:
with _repair_conn(db_path) as owned: with _repair_conn(db_path) as owned:
@@ -917,9 +909,6 @@ def _repair_state_db_schema_locked(
so recovery still depends on a human noticing a ``.malformed-backup-*`` file and knowing what to do with so recovery still depends on a human noticing a ``.malformed-backup-*`` file and knowing what to do with
it. Not mutating the original in the first place is the property that holds without a human in the loop. it. Not mutating the original in the first place is the property that holds without a human in the loop.
""" """
from hermes_state import (_backup_db_file, _copy_database_snapshot, _db_opens_cleanly, _exclusive_repair_db_guard,
_repair_scratch_space_error, _restore_journal_mode_after_repair, _run_repair_strategies,
_unlink_db_triple)
scratch = db_path.with_name(f"{db_path.name}.repair-scratch") scratch = db_path.with_name(f"{db_path.name}.repair-scratch")
if (cleanup_error := _unlink_db_triple(scratch)) is not None: if (cleanup_error := _unlink_db_triple(scratch)) is not None:
return _repair_skip(report, "aborted", f"could not remove a stale repair snapshot before probing state.db: {cleanup_error}") return _repair_skip(report, "aborted", f"could not remove a stale repair snapshot before probing state.db: {cleanup_error}")
@@ -1074,7 +1063,7 @@ _REPAIR_STRATEGIES = (
def _run_repair_strategies(db_path: Path, report: Dict[str, Any]) -> Dict[str, Any]: def _run_repair_strategies(db_path: Path, report: Dict[str, Any]) -> Dict[str, Any]:
"""Escalating repair attempts, applied to *db_path* IN PLACE — only ever a scratch copy nothing else holds open, """Escalating repair attempts, applied to *db_path* IN PLACE — only ever a scratch copy nothing else holds open,
never the user's database. The "could not recover" log lives in the caller so it names the user's database.""" never the user's database. The "could not recover" log lives in the caller so it names the user's database."""
from hermes_state import _db_opens_cleanly
for name, body, success_msg, failure_msg in _REPAIR_STRATEGIES: for name, body, success_msg, failure_msg in _REPAIR_STRATEGIES:
try: try:
with _repair_conn(db_path) as conn: with _repair_conn(db_path) as conn:
-15
View File
@@ -601,21 +601,6 @@ class SessionSessionsMixin:
("", ActivityProvenance.UNKNOWN.value, session_id), patience_s=self._ACTIVITY_WRITE_PATIENCE_S, ("", ActivityProvenance.UNKNOWN.value, session_id), patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
) )
def get_session_activity(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Return the durable activity snapshot for *session_id*, or None."""
if not session_id:
return None
row = self.get_session(session_id)
if not row:
return None
from agent.session_activity import build_activity_snapshot
return build_activity_snapshot(
last_activity_at=row.get("last_activity_at"),
last_activity_description=row.get("last_activity_description"),
last_activity_provenance=row.get("last_activity_provenance"),
)
def update_session_meta( def update_session_meta(
self, session_id: str, model_config_json: str, model: Optional[str] = None, self, session_id: str, model_config_json: str, model: Optional[str] = None,
) -> None: ) -> None:
-4
View File
@@ -127,10 +127,6 @@ class SessionTitlesMixin:
raise ValueError(f"invalid automatic title source: {source!r}") raise ValueError(f"invalid automatic title source: {source!r}")
return self._set_session_title(session_id, title, source=source) return self._set_session_title(session_id, title, source=source)
def set_auto_title_if_empty(self, session_id: str, title: str) -> bool:
"""Back-compat shim (third-party plugins reference it by name)."""
return self.set_auto_title(session_id, title, source=self.TITLE_SOURCE_LLM)
def get_session_title(self, session_id: str) -> Optional[str]: def get_session_title(self, session_id: str) -> Optional[str]:
"""Get the title for a session, or None.""" """Get the title for a session, or None."""
row = self._read_one("SELECT title FROM sessions WHERE id = ?", (session_id,)) row = self._read_one("SELECT title FROM sessions WHERE id = ?", (session_id,))
+4 -7
View File
@@ -1,7 +1,6 @@
"""SQLite journal-mode and PRAGMA policy for state.db (split from hermes_state). """SQLite journal-mode and PRAGMA policy for state.db (split from hermes_state).
Every name is re-imported into ``hermes_state``; intra-module calls to patchable helpers go through a lazy Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_wal.<name>``.
``from hermes_state import ...`` at call time so monkeypatches there still intercept.
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,7 +29,7 @@ _WAL_INCOMPAT_MARKERS = ("locking protocol", "not authorized", "disk i/o error")
_WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB _WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
# Once-per-process-per-db_label dedup sets (kanban_db.connect() runs on every kanban operation, so an undeduped # Once-per-process-per-db_label dedup sets (kanban_db.connect() runs on every kanban operation, so an undeduped
# line would repeat per connection). Tests clear these via ``hermes_state.<name>``; ``_log_once`` resolves them there. # line would repeat per connection). Tests clear these via ``hermes_state_wal.<name>``.
_wal_fallback_warned_paths: set[str] = set() _wal_fallback_warned_paths: set[str] = set()
_wal_fallback_warned_lock = threading.Lock() _wal_fallback_warned_lock = threading.Lock()
_wal_reset_bug_warned_paths: set[str] = set() _wal_reset_bug_warned_paths: set[str] = set()
@@ -174,7 +173,7 @@ def _verify_configured_delete(actual: str) -> str:
def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.db", require_wal: bool = False) -> str: def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.db", require_wal: bool = False) -> str:
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure. """Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
Returns the mode actually set. Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect``. Returns the mode actually set. Shared by :class:`SessionDB` and ``hermes_cli.kanban_db_connect.connect``.
WAL-incompatible filesystems either raise ``OperationalError`` ("locking protocol" / "disk I/O error") or — WAL-incompatible filesystems either raise ``OperationalError`` ("locking protocol" / "disk I/O error") or —
macOS NFS / SMB / AgentFS — silently refuse and stay in DELETE; either way log ERROR once per process per macOS NFS / SMB / AgentFS — silently refuse and stay in DELETE; either way log ERROR once per process per
``db_label`` and fall back. ``require_wal=True`` raises :class:`WalUnsupportedError` instead. WAL-reset-bug ``db_label`` and fall back. ``require_wal=True`` raises :class:`WalUnsupportedError` instead. WAL-reset-bug
@@ -193,7 +192,6 @@ def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.
still documents the WAL-reset bug as real through 3.51.2 with serious consequences. Until a fixed still documents the WAL-reset bug as real through 3.51.2 with serious consequences. Until a fixed
runtime is delivered, keep new databases out of WAL. runtime is delivered, keep new databases out of WAL.
""" """
from hermes_state import is_sqlite_wal_reset_vulnerable, resolve_journal_mode
configured = resolve_journal_mode() configured = resolve_journal_mode()
# Vulnerable SQLite: never enable WAL on non-WAL files (configured mode resolved first so an explicit DELETE # Vulnerable SQLite: never enable WAL on non-WAL files (configured mode resolved first so an explicit DELETE
@@ -397,9 +395,8 @@ _ONCE_LOGS = {
def _log_once(kind: str, db_label: str, *args: Any) -> None: def _log_once(kind: str, db_label: str, *args: Any) -> None:
"""Emit ``_ONCE_LOGS[kind]`` once per (process, db_label). Callable *args* are """Emit ``_ONCE_LOGS[kind]`` once per (process, db_label). Callable *args* are
resolved only after the dedupe check, so install-method probes run once.""" resolved only after the dedupe check, so install-method probes run once."""
import hermes_state
lock, set_name, level, message = _ONCE_LOGS[kind] lock, set_name, level, message = _ONCE_LOGS[kind]
seen = getattr(hermes_state, set_name) seen = globals()[set_name]
with lock: with lock:
if db_label in seen: if db_label in seen:
return return
+3 -3
View File
@@ -78,8 +78,8 @@ def _close_quietly(db, what: str) -> None:
def _get_session_db(): def _get_session_db():
"""SessionDB instance for reading message transcripts, or None.""" """SessionDB instance for reading message transcripts, or None."""
try: try:
from hermes_state import get_shared_session_db from hermes_state_registry import acquire
return get_shared_session_db() return acquire()
except Exception as e: except Exception as e:
logger.debug("SessionDB unavailable: %s", e) logger.debug("SessionDB unavailable: %s", e)
return None return None
@@ -96,7 +96,7 @@ def _load_session_messages(session_id: str):
return None, f"Failed to read messages: {e}" return None, f"Failed to read messages: {e}"
finally: finally:
try: try:
from hermes_state import release_or_close from hermes_state_registry import release_or_close
release_or_close(db) release_or_close(db)
except Exception: except Exception:
logger.debug("Failed to close MCP SessionDB", exc_info=True) logger.debug("Failed to close MCP SessionDB", exc_info=True)
+1 -1
View File
@@ -125,7 +125,7 @@ class MemoryStore:
def _init_db(self) -> None: def _init_db(self) -> None:
"""Create schema, enable WAL via the shared fallback helper (NFS/SMB/FUSE degrade gracefully), add hrr_vector to pre-HRR DBs.""" """Create schema, enable WAL via the shared fallback helper (NFS/SMB/FUSE degrade gracefully), add hrr_vector to pre-HRR DBs."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
apply_wal_with_fallback(self._conn, db_label="memory_store.db (holographic)") apply_wal_with_fallback(self._conn, db_label="memory_store.db (holographic)")
self._conn.executescript(_SCHEMA) self._conn.executescript(_SCHEMA)
if "hrr_vector" not in {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}: if "hrr_vector" not in {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}:
+1 -1
View File
@@ -53,7 +53,7 @@ class DiscordRecoveryStore:
return default return default
def _initialize(self, conn: sqlite3.Connection) -> None: def _initialize(self, conn: sqlite3.Connection) -> None:
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
apply_wal_with_fallback(conn, db_label="discord_recovery.db") apply_wal_with_fallback(conn, db_label="discord_recovery.db")
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS discord_messages ( CREATE TABLE IF NOT EXISTS discord_messages (
+3 -3
View File
@@ -289,9 +289,9 @@ class AIAgent(
if self._session_db is not None: if self._session_db is not None:
return self._session_db return self._session_db
try: try:
from hermes_state import get_shared_session_db from hermes_state_registry import acquire
self._session_db = get_shared_session_db() self._session_db = acquire()
self._owns_session_db = True # we opened it, so close() must release it self._owns_session_db = True # we opened it, so close() must release it
return self._session_db return self._session_db
except Exception: except Exception:
@@ -998,7 +998,7 @@ class AIAgent(
self._owns_session_db = False self._owns_session_db = False
# Shared instances no-op on close(); release the refcount so the registry closes on the last caller. # Shared instances no-op on close(); release the refcount so the registry closes on the last caller.
# See #90837. # See #90837.
from hermes_state import release_or_close from hermes_state_registry import release_or_close
release_or_close(session_db) release_or_close(session_db)
def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None: def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:
@@ -3,7 +3,8 @@
from types import SimpleNamespace from types import SimpleNamespace
from agent.conversation_compression import recover_rotated_compression_session from agent.conversation_compression import recover_rotated_compression_session
from hermes_state import CompressionSessionClosedError, SessionDB from hermes_state import SessionDB
from hermes_state_errors import CompressionSessionClosedError
def test_recover_rotated_compression_session_reopens_legacy_orphan(tmp_path): def test_recover_rotated_compression_session_reopens_legacy_orphan(tmp_path):
+5 -5
View File
@@ -99,7 +99,7 @@ if _hermes_home_points_at_production(os.environ.get("HERMES_HOME", "")):
# the child at the same moment the child lost the HERMES_HOME redirect. # the child at the same moment the child lost the HERMES_HOME redirect.
# HERMES_TEST_ISOLATION is OUR marker: exported here (before any test module # HERMES_TEST_ISOLATION is OUR marker: exported here (before any test module
# imports), inherited by every child by default, and honored by # imports), inherited by every child by default, and honored by
# hermes_state._running_under_pytest() as a test-context signal. A child # hermes_state_guard._running_under_pytest() as a test-context signal. A child
# that carries it and still resolves the production state.db fails hard. # that carries it and still resolves the production state.db fails hard.
# Tests that legitimately need a child to look like a non-test process AND # Tests that legitimately need a child to look like a non-test process AND
# open a real DB must export HERMES_STATE_DB_GUARD_BYPASS=1 in that child's # open a real DB must export HERMES_STATE_DB_GUARD_BYPASS=1 in that child's
@@ -647,7 +647,7 @@ def _neutralize_macos_keychain_creds(request, monkeypatch):
# ── Kanban write guard (#69283) ───────────────────────────────────────────── # ── Kanban write guard (#69283) ─────────────────────────────────────────────
# When hermetic isolation is bypassed (stale checkout, wrong rootdir, direct # When hermetic isolation is bypassed (stale checkout, wrong rootdir, direct
# invocation), kanban writes silently pollute the real ~/.hermes. This autouse # invocation), kanban writes silently pollute the real ~/.hermes. This autouse
# fixture patches ``kanban_db.connect`` to refuse writes whose resolved DB # fixture patches ``kanban_db_connect.connect`` to refuse writes whose resolved DB
# path lands under the REAL kanban root (captured at import time, before any # path lands under the REAL kanban root (captured at import time, before any
# fixture rewires the environment). A deny-list is used instead of an # fixture rewires the environment). A deny-list is used instead of an
# allow-list because test-level fixtures legitimately move HERMES_HOME to # allow-list because test-level fixtures legitimately move HERMES_HOME to
@@ -711,8 +711,8 @@ def _kanban_write_guard(_hermetic_environment, monkeypatch):
# doesn't exist yet (AttributeError flake, caught in a full-suite run). # doesn't exist yet (AttributeError flake, caught in a full-suite run).
# A half-imported module has no callers yet either — nothing to guard # A half-imported module has no callers yet either — nothing to guard
# this round; the next test's fixture will patch the completed module. # this round; the next test's fixture will patch the completed module.
_orig_connect = getattr(_kdb, "connect", None) _orig_connect = getattr(_kdbc, "connect", None)
if _orig_connect is None: if _orig_connect is None or getattr(_kdb, "kanban_db_path", None) is None:
return return
def _guarded_connect(db_path=None, *args, **kwargs): def _guarded_connect(db_path=None, *args, **kwargs):
@@ -736,7 +736,7 @@ def _kanban_write_guard(_hermetic_environment, monkeypatch):
f"to the real ~/.hermes. See #69283." f"to the real ~/.hermes. See #69283."
) )
monkeypatch.setattr(_kdb, "connect", _guarded_connect) monkeypatch.setattr(_kdbc, "connect", _guarded_connect)
# ── Live state.db write guard ─────────────────────────────────────────────── # ── Live state.db write guard ───────────────────────────────────────────────
+2 -2
View File
@@ -58,7 +58,7 @@ def test_run_job_bounds_sessiondb_finalization(tmp_path):
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls, \ patch("run_agent.AIAgent") as mock_agent_cls, \
patch("cron.scheduler._cron_cleanup_timeout_seconds", return_value=0.02): patch("cron.scheduler._cron_cleanup_timeout_seconds", return_value=0.02):
@@ -118,7 +118,7 @@ def test_dispatch_guard_releases_after_sessiondb_finalization_hang(tmp_path):
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls, \ patch("run_agent.AIAgent") as mock_agent_cls, \
patch("cron.scheduler._cron_cleanup_timeout_seconds", return_value=0.02), \ patch("cron.scheduler._cron_cleanup_timeout_seconds", return_value=0.02), \
+3 -3
View File
@@ -54,7 +54,7 @@ def _run_with_current_provider(job, current_provider, tmp_path):
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -259,7 +259,7 @@ def _run_with_current_provider_and_model(
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -423,7 +423,7 @@ class TestRuntimeResolutionTargetModel:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_capture, side_effect=_capture,
+1 -1
View File
@@ -35,7 +35,7 @@ class TestRunJobRequestOverrides:
with patch("cron.scheduler._hermes_home", tmp_path), \ with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \ patch("dotenv.load_dotenv"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
+14 -14
View File
@@ -553,7 +553,7 @@ class TestRunJobSessionPersistence:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -611,7 +611,7 @@ class TestRunJobSessionPersistence:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), patch("cron.scheduler_delivery._resolve_origin", return_value=None),
patch("hermes_cli.env_loader.load_hermes_dotenv"), patch("hermes_cli.env_loader.load_hermes_dotenv"),
patch("hermes_cli.env_loader.reset_secret_source_cache"), patch("hermes_cli.env_loader.reset_secret_source_cache"),
patch("hermes_state.get_shared_session_db", return_value=fake_db), patch("hermes_state_registry.acquire", return_value=fake_db),
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -753,7 +753,7 @@ class TestRunJobSessionPersistence:
with patch("cron.scheduler._hermes_home", tmp_path), \ with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._preflight_job_config", return_value=None), \ patch("cron.scheduler._preflight_job_config", return_value=None), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -813,7 +813,7 @@ class TestRunJobSessionPersistence:
with patch("cron.scheduler._hermes_home", tmp_path), \ with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._preflight_job_config", return_value=None), \ patch("cron.scheduler._preflight_job_config", return_value=None), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -874,7 +874,7 @@ class TestRunJobSessionPersistence:
with patch("cron.scheduler._hermes_home", tmp_path), \ with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._preflight_job_config", return_value=None), \ patch("cron.scheduler._preflight_job_config", return_value=None), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -924,7 +924,7 @@ class TestRunJobSessionPersistence:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.reset_secret_source_cache", _record_reset), \ patch("hermes_cli.env_loader.reset_secret_source_cache", _record_reset), \
patch("hermes_cli.env_loader.load_hermes_dotenv", _record_load), \ patch("hermes_cli.env_loader.load_hermes_dotenv", _record_load), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -985,7 +985,7 @@ class TestRunJobSessionPersistence:
with patch("cron.scheduler._hermes_home", tmp_path), \ with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._preflight_job_config", return_value=None), \ patch("cron.scheduler._preflight_job_config", return_value=None), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -1083,7 +1083,7 @@ class TestRunJobConfigEnvVarExpansion:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \ return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls: patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1233,7 +1233,7 @@ class TestRunJobConfigEnvVarExpansion:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \ return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls: patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1278,7 +1278,7 @@ class TestRunJobModelResolution:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \ return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls: patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1304,7 +1304,7 @@ class TestRunJobModelResolution:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \ return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls: patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1336,7 +1336,7 @@ class TestRunJobModelResolution:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \ return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls: patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1361,7 +1361,7 @@ class TestRunJobModelResolution:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \ return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls: patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1406,7 +1406,7 @@ class TestRunJobSkillBacked:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -94,7 +94,7 @@ def test_run_job_cron_execute_code_deny_does_not_pollute_later_gateway_execute_c
monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False)
monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual")
monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "deny") monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "deny")
monkeypatch.setattr("hermes_state.get_shared_session_db", _DummySessionDB) monkeypatch.setattr("hermes_state_registry.acquire", _DummySessionDB)
monkeypatch.setattr("run_agent.AIAgent", _FakeCronAgent) monkeypatch.setattr("run_agent.AIAgent", _FakeCronAgent)
monkeypatch.setattr( monkeypatch.setattr(
"hermes_constants.resolve_reasoning_config", lambda *_args, **_kwargs: None "hermes_constants.resolve_reasoning_config", lambda *_args, **_kwargs: None
+1 -1
View File
@@ -250,7 +250,7 @@ def test_long_running_script_refreshes_owned_claim_in_profile_store(
with ( with (
jobs.use_cron_store(profile_home), jobs.use_cron_store(profile_home),
patch("hermes_state.get_shared_session_db", return_value=MagicMock()), patch("hermes_state_registry.acquire", return_value=MagicMock()),
): ):
success, _doc, _response, error = scheduler.run_job(claimed_job) success, _doc, _response, error = scheduler.run_job(claimed_job)
profile_claim = jobs.get_job("long-script")["run_claim"] profile_claim = jobs.get_job("long-script")["run_claim"]
+7 -7
View File
@@ -102,7 +102,7 @@ class TestSessionDbInitTimeout:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", side_effect=make_session_db), \ patch("hermes_state_registry.acquire", side_effect=make_session_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME, return_value=_RUNTIME,
@@ -131,7 +131,7 @@ class TestSessionDbInitTimeout:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db"), \ patch("hermes_state_registry.acquire"), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME, return_value=_RUNTIME,
@@ -166,7 +166,7 @@ class TestSessionDbInitTimeout:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \ patch("hermes_state_registry.acquire", return_value=fake_db), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME, return_value=_RUNTIME,
@@ -209,7 +209,7 @@ class TestSessionDbInitTimeout:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db"), \ patch("hermes_state_registry.acquire"), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME, return_value=_RUNTIME,
@@ -259,7 +259,7 @@ class TestDispatchGuardReleasedAfterHang:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db"), \ patch("hermes_state_registry.acquire"), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME, return_value=_RUNTIME,
@@ -357,7 +357,7 @@ class TestLateSessionDbClosedAfterTimeout:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db", side_effect=_hanging_then_capture), \ patch("hermes_state_registry.acquire", side_effect=_hanging_then_capture), \
patch( patch(
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={ return_value={
@@ -415,7 +415,7 @@ class TestSessionDbInitAfterEarlyReturns:
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.get_shared_session_db") as mock_db_cls, \ patch("hermes_state_registry.acquire") as mock_db_cls, \
patch( patch(
"cron.scheduler._run_job_script_with_claim_heartbeat", "cron.scheduler._run_job_script_with_claim_heartbeat",
return_value=(True, '{"wakeAgent": false}'), return_value=(True, '{"wakeAgent": false}'),
+4 -4
View File
@@ -11,6 +11,7 @@ import pytest
from gateway import hosted_room_driver as driver from gateway import hosted_room_driver as driver
from gateway import hosted_rooms as rooms from gateway import hosted_rooms as rooms
import hermes_state import hermes_state
import hermes_state_wal
from gateway.hosted_room_policy_checkpoint import HostedRoomPolicyCheckpoint from gateway.hosted_room_policy_checkpoint import HostedRoomPolicyCheckpoint
from hermes_state import SessionDB from hermes_state import SessionDB
@@ -167,7 +168,7 @@ def test_first_database_open_retries_only_transient_journal_lock(
tmp_path, tmp_path,
monkeypatch, monkeypatch,
): ):
original = hermes_state.apply_wal_with_fallback original = hermes_state_wal.apply_wal_with_fallback
attempts = 0 attempts = 0
def transient_lock(conn, **kwargs): def transient_lock(conn, **kwargs):
@@ -177,7 +178,7 @@ def test_first_database_open_retries_only_transient_journal_lock(
raise sqlite3.OperationalError("database is locked") raise sqlite3.OperationalError("database is locked")
return original(conn, **kwargs) return original(conn, **kwargs)
monkeypatch.setattr(hermes_state, "apply_wal_with_fallback", transient_lock) monkeypatch.setattr(hermes_state_wal, "apply_wal_with_fallback", transient_lock)
assert _create(tmp_path / "state.db")["room_id"] == "room-1" assert _create(tmp_path / "state.db")["room_id"] == "room-1"
assert attempts == 3 assert attempts == 3
@@ -197,8 +198,7 @@ def test_first_database_open_does_not_retry_other_journal_errors(
) )
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_wal, "apply_wal_with_fallback",
"apply_wal_with_fallback",
configured_delete_refusal, configured_delete_refusal,
) )
+2 -2
View File
@@ -125,9 +125,9 @@ class TestAppendToSqlite:
mock_db = MagicMock() mock_db = MagicMock()
released = [] released = []
with patch("hermes_state.get_shared_session_db", return_value=mock_db), \ with patch("hermes_state_registry.acquire", return_value=mock_db), \
patch( patch(
"hermes_state.release_or_close", "hermes_state_registry.release_or_close",
side_effect=lambda db: released.append(db), side_effect=lambda db: released.append(db),
): ):
_append_to_sqlite("sess_1", {"role": "assistant", "content": "hello"}) _append_to_sqlite("sess_1", {"role": "assistant", "content": "hello"})
+3 -1
View File
@@ -7,7 +7,7 @@ survivor. A gateway closing a 500MB WAL store runs a PASSIVE checkpoint in
autocheckpoint threshold) that does not reliably finish in 5s. A SIGKILL autocheckpoint threshold) that does not reliably finish in 5s. A SIGKILL
landing mid-checkpoint leaves half-written b-tree pages — macOS ``fsync`` landing mid-checkpoint leaves half-written b-tree pages — macOS ``fsync``
guarantees neither data-on-platter nor write ordering, which is exactly why guarantees neither data-on-platter nor write ordering, which is exactly why
``hermes_state._enforce_macos_synchronous_full`` exists. ``hermes_state_wal._enforce_macos_synchronous_full`` exists.
The port-rebinding reason the 5s deadline was introduced still holds, so the The port-rebinding reason the 5s deadline was introduced still holds, so the
force-kill stays — it just must not fire on a process that is still shutting force-kill stays — it just must not fire on a process that is still shutting
@@ -17,6 +17,8 @@ from __future__ import annotations
import pytest import pytest
import hermes_state_wal
from hermes_cli.gateway import ( from hermes_cli.gateway import (
_ORPHAN_EXIT_GRACE_SECONDS, _ORPHAN_EXIT_GRACE_SECONDS,
_await_gateway_exit, _await_gateway_exit,
+1 -1
View File
@@ -1475,7 +1475,7 @@ class TestGatewaySessionDbRecovery:
def test_transcript_reroute_migrates_remaining_backlog_to_child(self): def test_transcript_reroute_migrates_remaining_backlog_to_child(self):
import threading import threading
from types import SimpleNamespace from types import SimpleNamespace
from hermes_state import CompressionSessionClosedError from hermes_state_errors import CompressionSessionClosedError
class FakeDb: class FakeDb:
def get_compression_tip(self, session_id): def get_compression_tip(self, session_id):
+3 -2
View File
@@ -108,6 +108,7 @@ def test_runtime_health_is_sanitized_and_recovers() -> None:
def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch, tmp_path) -> None: def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch, tmp_path) -> None:
import hermes_state import hermes_state
import hermes_state_registry
from gateway.run import GatewayRunner, _SESSION_DB_UNPINNED from gateway.run import GatewayRunner, _SESSION_DB_UNPINNED
from gateway.session import SessionStore from gateway.session import SessionStore
from gateway.session_persistence import _DB_UNPINNED from gateway.session_persistence import _DB_UNPINNED
@@ -124,7 +125,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch,
opened.append(handle) opened.append(handle)
return handle return handle
monkeypatch.setattr(hermes_state, "get_shared_session_db", fail_once_session_db) monkeypatch.setattr(hermes_state_registry, "acquire", fail_once_session_db)
monkeypatch.setattr(hermes_state, "_default_db_path", lambda: db_path) monkeypatch.setattr(hermes_state, "_default_db_path", lambda: db_path)
store = object.__new__(SessionStore) store = object.__new__(SessionStore)
@@ -153,7 +154,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch,
runner_opened.append(handle) runner_opened.append(handle)
return handle return handle
monkeypatch.setattr(hermes_state, "get_shared_session_db", runner_fail_once) monkeypatch.setattr(hermes_state_registry, "acquire", runner_fail_once)
monkeypatch.setattr(hermes_state, "AsyncSessionDB", lambda db: ("async", db)) monkeypatch.setattr(hermes_state, "AsyncSessionDB", lambda db: ("async", db))
runner = object.__new__(GatewayRunner) runner = object.__new__(GatewayRunner)
runner._session_db_pinned = _SESSION_DB_UNPINNED runner._session_db_pinned = _SESSION_DB_UNPINNED
+2 -2
View File
@@ -123,9 +123,9 @@ def test_recover_closes_owned_db_when_unexpected_exception_escapes(
raise KeyboardInterrupt raise KeyboardInterrupt
db = InterruptingDB() db = InterruptingDB()
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda: db) monkeypatch.setattr("hermes_state_registry.acquire", lambda: db)
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.release_or_close", lambda _: setattr(db, "released", True) "hermes_state_registry.release_or_close", lambda _: setattr(db, "released", True)
) )
with pytest.raises(KeyboardInterrupt): with pytest.raises(KeyboardInterrupt):
+3 -1
View File
@@ -19,6 +19,8 @@ from pathlib import Path
import pytest import pytest
import hermes_state_repair
import hermes_startup_watchdog as sw import hermes_startup_watchdog as sw
from hermes_startup_watchdog import ( from hermes_startup_watchdog import (
SERVICE_RESTART_EXIT_CODE, SERVICE_RESTART_EXIT_CODE,
@@ -453,7 +455,7 @@ class TestProgressLease:
import hermes_state import hermes_state
src = inspect.getsource(hermes_state.repair_state_db_schema) src = inspect.getsource(hermes_state_repair.repair_state_db_schema)
assert "report_startup_progress" in src assert "report_startup_progress" in src
+11 -1
View File
@@ -16,6 +16,16 @@ from agent.session_activity import ActivityProvenance, build_activity_snapshot
from hermes_state import SessionDB from hermes_state import SessionDB
def _activity_snapshot(db, session_id):
"""Durable activity snapshot for *session_id* (what gateway/delegate readers build from the row)."""
row = db.get_session(session_id)
return build_activity_snapshot(
last_activity_at=row.get("last_activity_at"),
last_activity_description=row.get("last_activity_description"),
last_activity_provenance=row.get("last_activity_provenance"),
)
# ── S1: observational activity writes must not ride the 20s patience ──────── # ── S1: observational activity writes must not ride the 20s patience ────────
@@ -77,7 +87,7 @@ def test_s1_clear_labels_noop_skips_transaction(tmp_path, monkeypatch):
calls.clear() calls.clear()
db.clear_session_activity_labels(sid) db.clear_session_activity_labels(sid)
assert len(calls) == 1 assert len(calls) == 1
activity = db.get_session_activity(sid) activity = _activity_snapshot(db, sid)
assert activity["last_activity_description"] == "" assert activity["last_activity_description"] == ""
+11 -10
View File
@@ -15,6 +15,7 @@ from pathlib import Path
import pytest import pytest
import hermes_state import hermes_state
import hermes_state_wal
from hermes_cli import kanban_db as kb from hermes_cli import kanban_db as kb
@@ -74,7 +75,7 @@ def test_cross_process_init_lock_uses_windows_byte_range_lock(tmp_path, monkeypa
monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt)
db_path = tmp_path / "kanban.db" db_path = tmp_path / "kanban.db"
with kb._cross_process_init_lock(db_path): with kbc._cross_process_init_lock(db_path):
# Acquired exactly once via the non-blocking byte-range lock. # Acquired exactly once via the non-blocking byte-range lock.
assert [call[1:] for call in calls] == [(fake_msvcrt.LK_NBLCK, 1)] assert [call[1:] for call in calls] == [(fake_msvcrt.LK_NBLCK, 1)]
@@ -873,7 +874,7 @@ class TestSharedBoardPaths:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# NFS / network-filesystem fallback (see hermes_state.apply_wal_with_fallback) # NFS / network-filesystem fallback (see hermes_state_wal.apply_wal_with_fallback)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_connect_falls_back_to_delete_on_locking_protocol(tmp_path, monkeypatch, caplog): def test_connect_falls_back_to_delete_on_locking_protocol(tmp_path, monkeypatch, caplog):
@@ -903,16 +904,16 @@ def test_connect_falls_back_to_delete_on_locking_protocol(tmp_path, monkeypatch,
# These tests exercise the WAL-attempt path; assume a fixed SQLite so the # These tests exercise the WAL-attempt path; assume a fixed SQLite so the
# WAL-reset vulnerability gate doesn't short-circuit before the pragma. # WAL-reset vulnerability gate doesn't short-circuit before the pragma.
import hermes_state as _hermes_state import hermes_state_wal as _hermes_state_wal
monkeypatch.setattr( monkeypatch.setattr(
_hermes_state, "is_sqlite_wal_reset_vulnerable", _hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
lambda version_info=None: False, lambda version_info=None: False,
) )
_hermes_state._wal_fallback_warned_paths.clear() _hermes_state_wal._wal_fallback_warned_paths.clear()
# Clear module cache so a fresh connect() is attempted # Clear module cache so a fresh connect() is attempted
kb._INITIALIZED_PATHS.clear() kb._INITIALIZED_PATHS.clear()
hermes_state._wal_fallback_warned_paths.clear() hermes_state_wal._wal_fallback_warned_paths.clear()
real_connect = _sqlite3.connect real_connect = _sqlite3.connect
@@ -963,10 +964,10 @@ def test_connect_works_when_wal_is_silently_refused(tmp_path, monkeypatch, caplo
monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb._INITIALIZED_PATHS.clear() kb._INITIALIZED_PATHS.clear()
hermes_state._wal_fallback_warned_paths.clear() hermes_state_wal._wal_fallback_warned_paths.clear()
# Assume a fixed SQLite so the WAL-reset gate doesn't short-circuit. # Assume a fixed SQLite so the WAL-reset gate doesn't short-circuit.
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
lambda version_info=None: False, lambda version_info=None: False,
) )
@@ -1033,7 +1034,7 @@ def test_sqlite_connect_closes_tracked_conn_on_setup_failure(tmp_path, monkeypat
monkeypatch.setattr(kb.sqlite3, "connect", failing_connect) monkeypatch.setattr(kb.sqlite3, "connect", failing_connect)
with pytest.raises(sqlite3.OperationalError, match="simulated setup failure"): with pytest.raises(sqlite3.OperationalError, match="simulated setup failure"):
kb._sqlite_connect(db_path) kbc._sqlite_connect(db_path)
with sqlite_safe_read._live_lock: with sqlite_safe_read._live_lock:
after = sqlite_safe_read._live_connections.get(key, 0) after = sqlite_safe_read._live_connections.get(key, 0)
@@ -1627,7 +1628,7 @@ def test_write_txn_check_reads_correct_header_fields(tmp_path):
# connect_closing(): context manager that actually closes the FD # connect_closing(): context manager that actually closes the FD
# Regression coverage for #33159 (kanban.db FD leak — gateway crashes after # Regression coverage for #33159 (kanban.db FD leak — gateway crashes after
# ~4 days). sqlite3.Connection's built-in __exit__ commits/rollbacks but # ~4 days). sqlite3.Connection's built-in __exit__ commits/rollbacks but
# does NOT close, so `with kb.connect() as conn:` leaks the FD in # does NOT close, so `with kbc.connect() as conn:` leaks the FD in
# long-lived processes (gateway run_slash, dashboard decompose handler). # long-lived processes (gateway run_slash, dashboard decompose handler).
# `connect_closing()` is the leak-safe replacement. # `connect_closing()` is the leak-safe replacement.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -1
View File
@@ -12,7 +12,8 @@ from types import SimpleNamespace
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import FTS_STORAGE_VERSION, SCHEMA_VERSION, SessionDB from hermes_state import SessionDB
from hermes_state_common import FTS_STORAGE_VERSION, SCHEMA_VERSION
from hermes_cli import session_recovery from hermes_cli import session_recovery
from hermes_cli.session_recovery import ( from hermes_cli.session_recovery import (
SessionRecoverySafetyError, SessionRecoverySafetyError,
@@ -26,6 +26,8 @@ from unittest.mock import patch
import pytest import pytest
import hermes_state_repair
from hermes_cli.session_lost_and_found import ( from hermes_cli.session_lost_and_found import (
_parse_sqlite3_cli_version, _parse_sqlite3_cli_version,
_wal_reset_vulnerable, _wal_reset_vulnerable,
@@ -245,7 +247,7 @@ class TestGuidanceNeverNamesLiveDb:
must not embed a raw sqlite3 command against the live path.""" must not embed a raw sqlite3 command against the live path."""
import hermes_state import hermes_state
body = inspect.getsource(hermes_state._backup_db_file) body = inspect.getsource(hermes_state_repair._backup_db_file)
assert ".recover\"`" not in body assert ".recover\"`" not in body
assert "sessions recover --source" in body assert "sessions recover --source" in body
assert "--inspect-only" in body assert "--inspect-only" in body
@@ -15,22 +15,18 @@ from pathlib import Path
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import ( import hermes_state_wal
DeletedWalGenerationError, from hermes_state import DeletedWalGenerationError, SessionDB, classify_persistence_error, refuse_deleted_wal_generation
SessionDB, from hermes_state_dbfile import iter_deleted_sqlite_sidecar_holders
classify_persistence_error,
iter_deleted_sqlite_sidecar_holders,
refuse_deleted_wal_generation,
)
@pytest.fixture @pytest.fixture
def force_wal(monkeypatch): def force_wal(monkeypatch):
"""Pin WAL so this host's vulnerable SQLite still matches production topology.""" """Pin WAL so this host's vulnerable SQLite still matches production topology."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
) )
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "wal") monkeypatch.setattr(hermes_state_wal, "resolve_journal_mode", lambda: "wal")
def _make_db(path: Path, session_id: str, content: str) -> SessionDB: def _make_db(path: Path, session_id: str, content: str) -> SessionDB:
@@ -87,9 +83,9 @@ def test_clean_open_and_second_open_still_work(tmp_path, force_wal):
def test_delete_journal_two_writers_still_work(tmp_path, monkeypatch): def test_delete_journal_two_writers_still_work(tmp_path, monkeypatch):
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "delete") monkeypatch.setattr(hermes_state_wal, "resolve_journal_mode", lambda: "delete")
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
) )
path = tmp_path / "state.db" path = tmp_path / "state.db"
a = _make_db(path, "s", "from-a") a = _make_db(path, "s", "from-a")
@@ -20,6 +20,7 @@ import sys
from pathlib import Path from pathlib import Path
import hermes_state import hermes_state
import hermes_state_guard
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -27,13 +28,14 @@ _CHILD_PROBE = r"""
import json, os, sys import json, os, sys
sys.path.insert(0, {repo!r}) sys.path.insert(0, {repo!r})
import hermes_state as hs import hermes_state as hs
import hermes_state_guard
fired = False fired = False
try: try:
hs._ensure_test_isolation(hs._real_platform_state_root() / "state.db") hs._ensure_test_isolation(hs._real_platform_state_root() / "state.db")
except RuntimeError: except RuntimeError:
fired = True fired = True
print(json.dumps({{ print(json.dumps({{
"armed": hs._running_under_pytest(), "armed": hermes_state_guard._running_under_pytest(),
"fired": fired, "fired": fired,
}})) }}))
""" """
@@ -78,7 +80,7 @@ def test_marker_alone_reports_test_context(monkeypatch):
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.delenv("PYTEST_VERSION", raising=False) monkeypatch.delenv("PYTEST_VERSION", raising=False)
monkeypatch.setenv("HERMES_TEST_ISOLATION", "/tmp/some-isolation-root") monkeypatch.setenv("HERMES_TEST_ISOLATION", "/tmp/some-isolation-root")
assert hermes_state._running_under_pytest() is True assert hermes_state_guard._running_under_pytest() is True
def test_no_signals_reports_production(monkeypatch): def test_no_signals_reports_production(monkeypatch):
@@ -87,7 +89,7 @@ def test_no_signals_reports_production(monkeypatch):
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.delenv("PYTEST_VERSION", raising=False) monkeypatch.delenv("PYTEST_VERSION", raising=False)
monkeypatch.delenv("HERMES_TEST_ISOLATION", raising=False) monkeypatch.delenv("HERMES_TEST_ISOLATION", raising=False)
assert hermes_state._running_under_pytest() is False assert hermes_state_guard._running_under_pytest() is False
def test_child_with_rebuilt_env_keeping_marker_refuses_production_db(): def test_child_with_rebuilt_env_keeping_marker_refuses_production_db():
@@ -29,6 +29,7 @@ from pathlib import Path
import pytest import pytest
import hermes_state import hermes_state
import hermes_state_guard
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -137,7 +138,7 @@ class TestPytestProcessRecognition:
], ],
) )
def test_recognises_pytest_invocations(self, cmdline): def test_recognises_pytest_invocations(self, cmdline):
assert hermes_state._process_looks_like_pytest(self._FakeProc(cmdline)) assert hermes_state_guard._process_looks_like_pytest(self._FakeProc(cmdline))
@pytest.mark.parametrize( @pytest.mark.parametrize(
"cmdline", "cmdline",
@@ -150,11 +151,11 @@ class TestPytestProcessRecognition:
], ],
) )
def test_ignores_non_pytest_invocations(self, cmdline): def test_ignores_non_pytest_invocations(self, cmdline):
assert not hermes_state._process_looks_like_pytest(self._FakeProc(cmdline)) assert not hermes_state_guard._process_looks_like_pytest(self._FakeProc(cmdline))
def test_unreadable_process_is_not_pytest(self): def test_unreadable_process_is_not_pytest(self):
class _Denied: class _Denied:
def cmdline(self): def cmdline(self):
raise PermissionError("access denied") raise PermissionError("access denied")
assert not hermes_state._process_looks_like_pytest(_Denied()) assert not hermes_state_guard._process_looks_like_pytest(_Denied())
@@ -165,7 +165,7 @@ def test_flush_adopts_exactly_once_no_retry_loop(tmp_path: Path, monkeypatch) ->
"""Adoption budget: the tip lookup runs at most once per flush, and a """Adoption budget: the tip lookup runs at most once per flush, and a
second closed-parent write after adoption fails closed instead of looping. second closed-parent write after adoption fails closed instead of looping.
""" """
from hermes_state import CompressionSessionClosedError from hermes_state_errors import CompressionSessionClosedError
db = SessionDB(db_path=tmp_path / "state.db") db = SessionDB(db_path=tmp_path / "state.db")
try: try:
@@ -205,11 +205,8 @@ def test_flush_adopts_exactly_once_no_retry_loop(tmp_path: Path, monkeypatch) ->
def test_compression_closed_error_classifies_as_compression_closed() -> None: def test_compression_closed_error_classifies_as_compression_closed() -> None:
from hermes_state import ( from hermes_state import classify_persistence_error
PERSISTENCE_ERROR_CAUSES, from hermes_state_errors import CompressionSessionClosedError, PERSISTENCE_ERROR_CAUSES
CompressionSessionClosedError,
classify_persistence_error,
)
cause = classify_persistence_error(CompressionSessionClosedError("session-abc")) cause = classify_persistence_error(CompressionSessionClosedError("session-abc"))
assert cause == "compression_closed" assert cause == "compression_closed"
@@ -222,7 +219,8 @@ def test_compression_closed_error_classifies_as_compression_closed() -> None:
def test_compression_closed_wording_never_mentions_disk() -> None: def test_compression_closed_wording_never_mentions_disk() -> None:
from hermes_state import CompressionSessionClosedError, classify_persistence_error from hermes_state import classify_persistence_error
from hermes_state_errors import CompressionSessionClosedError
text = AIAgent._format_turn_completion_explanation( text = AIAgent._format_turn_completion_explanation(
"session_persistence_failed", "session_persistence_failed",
@@ -48,7 +48,7 @@ class TestCronJobCleanup:
"model": "test/model", "model": "test/model",
} }
with patch("hermes_state.get_shared_session_db", return_value=mock_db), \ with patch("hermes_state_registry.acquire", return_value=mock_db), \
patch.object(scheduler, "_build_job_prompt", return_value="hello"), \ patch.object(scheduler, "_build_job_prompt", return_value="hello"), \
patch.object(sched_delivery, "_resolve_origin", return_value=None), \ patch.object(sched_delivery, "_resolve_origin", return_value=None), \
patch.object(scheduler, "_resolve_delivery_target", return_value=None), \ patch.object(scheduler, "_resolve_delivery_target", return_value=None), \
@@ -68,8 +68,10 @@ def test_session_search_lazily_opens_db_when_entrypoint_did_not_pass_one(monkeyp
hermes_state = ModuleType("hermes_state") hermes_state = ModuleType("hermes_state")
hermes_state.SessionDB = FakeSessionDB hermes_state.SessionDB = FakeSessionDB
hermes_state.get_shared_session_db = lambda db_path=None: sentinel_db
monkeypatch.setitem(sys.modules, "hermes_state", hermes_state) monkeypatch.setitem(sys.modules, "hermes_state", hermes_state)
hermes_state_registry = ModuleType("hermes_state_registry")
hermes_state_registry.acquire = lambda db_path=None: sentinel_db
monkeypatch.setitem(sys.modules, "hermes_state_registry", hermes_state_registry)
session_search_mod = ModuleType("tools.session_search_tool") session_search_mod = ModuleType("tools.session_search_tool")
@@ -17,6 +17,7 @@ suite (we patch ``agent.process_bootstrap.OpenAI`` and drive ``agent.client``),
pass identically in CI and locally. pass identically in CI and locally.
""" """
import hermes_state_errors
import os import os
import uuid import uuid
from types import SimpleNamespace from types import SimpleNamespace
@@ -249,7 +250,7 @@ def test_classify_persistence_error_corruption_beats_disk_bucket():
def test_classify_persistence_error_reuses_disk_full_markers(): def test_classify_persistence_error_reuses_disk_full_markers():
"""The disk bucket delegates to hermes_state.is_disk_full_error, so """The disk bucket delegates to hermes_state_errors.is_disk_full_error, so
every marker that helper recognizes (ENOSPC, 'not enough space', ...) every marker that helper recognizes (ENOSPC, 'not enough space', ...)
must classify as 'disk' — the two classifiers can never drift apart.""" must classify as 'disk' — the two classifiers can never drift apart."""
import errno import errno
@@ -270,10 +271,8 @@ def test_classify_persistence_error_compression_busy_is_distinct():
storage damage — but its message contains neither 'locked' nor 'busy', storage damage — but its message contains neither 'locked' nor 'busy',
so it must classify by exception type (and by phrase for RPC-wrapped so it must classify by exception type (and by phrase for RPC-wrapped
strings). This is the exact failure mode of issue #81227.""" strings). This is the exact failure mode of issue #81227."""
from hermes_state import ( from hermes_state import SessionCompressionInProgressError
CompressionSessionBusyError, from hermes_state_errors import CompressionSessionBusyError
SessionCompressionInProgressError,
)
from hermes_state import classify_persistence_error from hermes_state import classify_persistence_error
assert classify_persistence_error( assert classify_persistence_error(
@@ -294,7 +293,8 @@ def test_classify_persistence_error_compression_busy_is_distinct():
def test_classify_persistence_error_turn_lease_lost_is_distinct(): def test_classify_persistence_error_turn_lease_lost_is_distinct():
from hermes_state import SessionTurnLeaseLostError, classify_persistence_error from hermes_state import classify_persistence_error
from hermes_state_errors import SessionTurnLeaseLostError
assert classify_persistence_error( assert classify_persistence_error(
SessionTurnLeaseLostError( SessionTurnLeaseLostError(
@@ -309,7 +309,8 @@ def test_classify_persistence_error_turn_lease_lost_is_distinct():
def test_persistence_error_causes_tuple_matches_classifier(): def test_persistence_error_causes_tuple_matches_classifier():
"""PERSISTENCE_ERROR_CAUSES must cover every value the classifier can """PERSISTENCE_ERROR_CAUSES must cover every value the classifier can
return (consumers like cron suppression iterate it).""" return (consumers like cron suppression iterate it)."""
from hermes_state import PERSISTENCE_ERROR_CAUSES, classify_persistence_error from hermes_state import classify_persistence_error
from hermes_state_errors import PERSISTENCE_ERROR_CAUSES
probes = ( probes = (
"database is locked", "database is locked",
@@ -11,7 +11,8 @@ from unittest.mock import patch
import pytest import pytest
from hermes_state import SessionDB, CompressionSessionBusyError from hermes_state import SessionDB
from hermes_state_errors import CompressionSessionBusyError
def _setup_db(tmp_path): def _setup_db(tmp_path):
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import errno import errno
import sqlite3 import sqlite3
from hermes_state import is_disk_full_error from hermes_state_errors import is_disk_full_error
def test_enospc_oserror(): def test_enospc_oserror():
+8 -6
View File
@@ -27,7 +27,8 @@ from pathlib import Path
import pytest import pytest
import hermes_state_common import hermes_state_common
from hermes_state import FTS_STALE_KEY, SessionDB, _FTS_TRIGGERS from hermes_state import SessionDB
from hermes_state_common import FTS_STALE_KEY, _FTS_TRIGGERS
pytestmark = pytest.mark.skipif( pytestmark = pytest.mark.skipif(
sys.platform == "win32", reason="POSIX flock child-process harness" sys.platform == "win32", reason="POSIX flock child-process harness"
@@ -338,9 +339,9 @@ class TestOrphanedHolderStalenessBreak:
import os, sys, time import os, sys, time
sys.path.insert(0, {repo!r}) sys.path.insert(0, {repo!r})
from pathlib import Path from pathlib import Path
import hermes_state import hermes_state_repair
lock_cm = hermes_state._cross_process_repair_lock(Path({db!r})) lock_cm = hermes_state_repair._cross_process_repair_lock(Path({db!r}))
assert lock_cm.__enter__() is True assert lock_cm.__enter__() is True
pid = os.fork() pid = os.fork()
if pid == 0: if pid == 0:
@@ -358,9 +359,9 @@ os._exit(1)
grandchild = int(proc.stdout.readline().strip().split()[1]) grandchild = int(proc.stdout.readline().strip().split()[1])
proc.wait(timeout=10) proc.wait(timeout=10)
try: try:
import hermes_state as hs import hermes_state_repair
with hs._cross_process_repair_lock(db_path) as holding: with hermes_state_repair._cross_process_repair_lock(db_path) as holding:
assert holding is True assert holding is True
finally: finally:
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
@@ -463,6 +464,7 @@ class TestNonContentionErrnoFailsFast:
import fcntl import fcntl
import hermes_state import hermes_state
import hermes_state_repair
monkeypatch.setattr(hermes_state, "_REPAIR_LOCK_TIMEOUT_SECONDS", 30.0) monkeypatch.setattr(hermes_state, "_REPAIR_LOCK_TIMEOUT_SECONDS", 30.0)
@@ -471,7 +473,7 @@ class TestNonContentionErrnoFailsFast:
monkeypatch.setattr(fcntl, "flock", _flock) monkeypatch.setattr(fcntl, "flock", _flock)
t0 = time.monotonic() t0 = time.monotonic()
with hermes_state._cross_process_repair_lock(tmp_path / "state.db") as ok: with hermes_state_repair._cross_process_repair_lock(tmp_path / "state.db") as ok:
assert ok is False assert ok is False
assert time.monotonic() - t0 < 2.0 assert time.monotonic() - t0 < 2.0
@@ -6,7 +6,8 @@ import sqlite3
import pytest import pytest
from hermes_state import FTS_TRIGRAM_SQL, SCHEMA_VERSION, SessionDB from hermes_state import SessionDB
from hermes_state_common import FTS_TRIGRAM_SQL, SCHEMA_VERSION
@pytest.fixture @pytest.fixture
@@ -9,7 +9,8 @@ from __future__ import annotations
import pytest import pytest
from hermes_state import SCHEMA_VERSION, SessionDB from hermes_state import SessionDB
from hermes_state_common import SCHEMA_VERSION
from hermes_state_common import FTS_TRIGRAM_EXCLUDED_SOURCES, fts_trigram_session_sql from hermes_state_common import FTS_TRIGRAM_EXCLUDED_SOURCES, fts_trigram_session_sql
@@ -5,7 +5,8 @@ from __future__ import annotations
import sqlite3 import sqlite3
import threading import threading
from hermes_state import SCHEMA_VERSION, SessionDB from hermes_state import SessionDB
from hermes_state_common import SCHEMA_VERSION
def _open_pair(tmp_path): def _open_pair(tmp_path):
+2 -1
View File
@@ -11,7 +11,8 @@ from types import SimpleNamespace
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import SessionDB, SessionTurnLeaseLostError from hermes_state import SessionDB
from hermes_state_errors import SessionTurnLeaseLostError
def test_turn_lease_serializes_separate_session_db_instances(tmp_path): def test_turn_lease_serializes_separate_session_db_instances(tmp_path):
@@ -5,7 +5,7 @@ work on a file several Hermes processes share (gateway service, the Desktop
app's `hermes serve` backend, CLI sessions, the TUI slash worker): app's `hermes serve` backend, CLI sessions, the TUI slash worker):
* `hermes_state_common.fts_rebuild_admission` — full structural FTS rebuilds * `hermes_state_common.fts_rebuild_admission` — full structural FTS rebuilds
* `hermes_state._cross_process_repair_lock` — writable_schema surgery / VACUUM * `hermes_state_repair._cross_process_repair_lock` — writable_schema surgery / VACUUM
Both document themselves as fail-closed, and both honoured that only for a Both document themselves as fail-closed, and both honoured that only for a
*timed-out* acquire. When the lock file could not be `open()`ed at all they *timed-out* acquire. When the lock file could not be `open()`ed at all they
@@ -34,8 +34,10 @@ from pathlib import Path
import pytest import pytest
import hermes_state import hermes_state
import hermes_state_repair
import hermes_state_common import hermes_state_common
from hermes_state import SessionDB, repair_state_db_schema from hermes_state import SessionDB
from hermes_state_repair import repair_state_db_schema
def _make_unopenable(lock_path: Path) -> None: def _make_unopenable(lock_path: Path) -> None:
@@ -130,7 +132,7 @@ def test_repair_lock_fails_closed_when_lock_file_is_unopenable(tmp_path):
db_path = tmp_path / "state.db" db_path = tmp_path / "state.db"
_make_unopenable(db_path.with_name(db_path.name + ".repair.lock")) _make_unopenable(db_path.with_name(db_path.name + ".repair.lock"))
with hermes_state._cross_process_repair_lock(db_path) as holding: with hermes_state_repair._cross_process_repair_lock(db_path) as holding:
assert holding is False assert holding is False
@@ -145,7 +147,7 @@ def test_repair_skips_surgery_when_lock_file_is_unopenable(tmp_path):
db_path = tmp_path / "state.db" db_path = tmp_path / "state.db"
_build_healthy_db(db_path) _build_healthy_db(db_path)
_corrupt_duplicate_fts(db_path) _corrupt_duplicate_fts(db_path)
assert hermes_state._db_opens_cleanly(db_path) is not None assert hermes_state_repair._db_opens_cleanly(db_path) is not None
before = db_path.read_bytes() before = db_path.read_bytes()
_make_unopenable(db_path.with_name(db_path.name + ".repair.lock")) _make_unopenable(db_path.with_name(db_path.name + ".repair.lock"))
+11 -10
View File
@@ -12,7 +12,8 @@ import sqlite3
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import repair_state_db_schema import hermes_state_repair
from hermes_state_repair import repair_state_db_schema
def _make_db(path): def _make_db(path):
@@ -31,9 +32,9 @@ def test_wal_restoration_reuses_exclusive_repair_connection(tmp_path, monkeypatc
def fail_if_reopened(_path): def fail_if_reopened(_path):
pytest.fail("WAL restoration reopened state.db outside the repair guard") pytest.fail("WAL restoration reopened state.db outside the repair guard")
monkeypatch.setattr(hermes_state, "_connect_repair_durable", fail_if_reopened) monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", fail_if_reopened)
hermes_state._restore_journal_mode_after_repair(db_path, None, conn=conn) hermes_state_repair._restore_journal_mode_after_repair(db_path, None, conn=conn)
# The mode itself is whatever apply_wal_with_fallback resolves on this # The mode itself is whatever apply_wal_with_fallback resolves on this
# runtime (WAL, or DELETE on WAL-reset-vulnerable SQLite builds); the # runtime (WAL, or DELETE on WAL-reset-vulnerable SQLite builds); the
# contract under test is the connection reuse, asserted above. # contract under test is the connection reuse, asserted above.
@@ -46,20 +47,20 @@ def test_repair_never_reopens_after_the_guard_releases(tmp_path, monkeypatch):
opens is opened while the exclusive guard is still held, and none after.""" opens is opened while the exclusive guard is still held, and none after."""
db = tmp_path / "state.db" db = tmp_path / "state.db"
_make_db(db) _make_db(db)
monkeypatch.setattr(hermes_state, "_db_opens_cleanly", lambda path: "forced-unhealthy") monkeypatch.setattr(hermes_state_repair, "_db_opens_cleanly", lambda path: "forced-unhealthy")
# The scratch-space pre-flight wants ~10GB headroom; irrelevant here. # The scratch-space pre-flight wants ~10GB headroom; irrelevant here.
monkeypatch.setattr(hermes_state, "_repair_scratch_space_error", lambda path: None) monkeypatch.setattr(hermes_state_repair, "_repair_scratch_space_error", lambda path: None)
def fake_strategies(scratch_path, report): def fake_strategies(scratch_path, report):
report["repaired"] = True report["repaired"] = True
report["strategy"] = "test_strategy" report["strategy"] = "test_strategy"
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", fake_strategies)
events: list[str] = [] events: list[str] = []
real_guard = hermes_state._exclusive_repair_db_guard real_guard = hermes_state_repair._exclusive_repair_db_guard
real_connect = hermes_state._connect_repair_durable real_connect = hermes_state_repair._connect_repair_durable
from contextlib import contextmanager from contextlib import contextmanager
@@ -74,8 +75,8 @@ def test_repair_never_reopens_after_the_guard_releases(tmp_path, monkeypatch):
events.append("connect") events.append("connect")
return real_connect(path, *a, **kw) return real_connect(path, *a, **kw)
monkeypatch.setattr(hermes_state, "_exclusive_repair_db_guard", tracing_guard) monkeypatch.setattr(hermes_state_repair, "_exclusive_repair_db_guard", tracing_guard)
monkeypatch.setattr(hermes_state, "_connect_repair_durable", tracing_connect) monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", tracing_connect)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is True assert report["repaired"] is True
+1 -1
View File
@@ -14,7 +14,7 @@ import sqlite3
import pytest import pytest
from hermes_state import is_sqlite_wal_reset_vulnerable from hermes_state_wal import is_sqlite_wal_reset_vulnerable
from tests.conftest import _wal_is_usable from tests.conftest import _wal_is_usable
+3 -2
View File
@@ -11,7 +11,8 @@ from pathlib import Path
import pytest import pytest
from hermes_state import FTS_CJK_STALE_KEY, SessionDB from hermes_state import SessionDB
from hermes_state_common import FTS_CJK_STALE_KEY
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
SRC = REPO / "native" / "fts5_cjk" / "fts5_cjk.c" SRC = REPO / "native" / "fts5_cjk" / "fts5_cjk.c"
@@ -149,7 +150,7 @@ def test_legacy_v22_optimize_lands_on_cjk(cjk_so, tmp_path, monkeypatch):
cjk index in the same run.""" cjk index in the same run."""
import time as _time import time as _time
from hermes_state import SCHEMA_SQL from hermes_state_common import SCHEMA_SQL
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so)) monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so))
db_path = tmp_path / "state.db" db_path = tmp_path / "state.db"
+1 -1
View File
@@ -12,7 +12,7 @@ import sqlite3
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import apply_durability_barriers from hermes_state_repair import apply_durability_barriers
def _config(monkeypatch, database_section): def _config(monkeypatch, database_section):
+32 -26
View File
@@ -10,14 +10,21 @@ from unittest import mock
import pytest import pytest
import hermes_state import hermes_state
from agent.session_activity import ActivityProvenance import hermes_state_wal
from hermes_state import ( import hermes_state_common
FTS_SQL, from agent.session_activity import ActivityProvenance, build_activity_snapshot
FTS_STORAGE_VERSION, from hermes_state import SessionDB
SCHEMA_SQL, from hermes_state_common import FTS_SQL, FTS_STORAGE_VERSION, SCHEMA_SQL, SCHEMA_VERSION
SCHEMA_VERSION,
SessionDB,
) def _activity_snapshot(db, session_id):
"""Durable activity snapshot for *session_id* (what gateway/delegate readers build from the row)."""
row = db.get_session(session_id)
return build_activity_snapshot(
last_activity_at=row.get("last_activity_at"),
last_activity_description=row.get("last_activity_description"),
last_activity_provenance=row.get("last_activity_provenance"),
)
class _NoFtsCursor(sqlite3.Cursor): class _NoFtsCursor(sqlite3.Cursor):
@@ -1741,7 +1748,7 @@ class TestSanitizeTitle:
class TestSchemaInit: class TestSchemaInit:
def test_wal_mode(self, db): def test_wal_mode(self, db):
"""Prefer WAL on fixed SQLite; DELETE on WAL-reset-vulnerable builds (#69784).""" """Prefer WAL on fixed SQLite; DELETE on WAL-reset-vulnerable builds (#69784)."""
from hermes_state import is_sqlite_wal_reset_vulnerable from hermes_state_wal import is_sqlite_wal_reset_vulnerable
cursor = db._conn.execute("PRAGMA journal_mode") cursor = db._conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0].lower() mode = cursor.fetchone()[0].lower()
@@ -1796,7 +1803,7 @@ class TestSchemaInit:
This is the architectural invariant: SCHEMA_SQL declares the This is the architectural invariant: SCHEMA_SQL declares the
desired schema, _reconcile_columns ensures it matches reality. desired schema, _reconcile_columns ensures it matches reality.
""" """
from hermes_state import SCHEMA_SQL from hermes_state_common import SCHEMA_SQL
expected = SessionDB._parse_schema_columns(SCHEMA_SQL) expected = SessionDB._parse_schema_columns(SCHEMA_SQL)
for table_name, declared_cols in expected.items(): for table_name, declared_cols in expected.items():
@@ -2375,7 +2382,7 @@ class TestListSessionsRich:
assert row["last_activity_description"] == "starting API call #1" assert row["last_activity_description"] == "starting API call #1"
assert row["last_activity_provenance"] == "unknown" assert row["last_activity_provenance"] == "unknown"
activity = db.get_session_activity("s1") activity = _activity_snapshot(db, "s1")
assert activity["last_activity_at"] == heartbeat assert activity["last_activity_at"] == heartbeat
assert activity["last_activity_description"] == "starting API call #1" assert activity["last_activity_description"] == "starting API call #1"
assert "phase" not in activity assert "phase" not in activity
@@ -2405,7 +2412,7 @@ class TestListSessionsRich:
assert row["last_activity_at"] == heartbeat assert row["last_activity_at"] == heartbeat
assert row["last_activity_description"] == "" assert row["last_activity_description"] == ""
assert row["last_activity_provenance"] == "unknown" assert row["last_activity_provenance"] == "unknown"
activity = db.get_session_activity("s1") activity = _activity_snapshot(db, "s1")
assert activity["last_activity_at"] == heartbeat assert activity["last_activity_at"] == heartbeat
assert activity["last_activity_description"] == "" assert activity["last_activity_description"] == ""
assert activity["last_activity_provenance"] == "unknown" assert activity["last_activity_provenance"] == "unknown"
@@ -2448,7 +2455,7 @@ class TestListSessionsRich:
rows = db.list_gateway_sessions(active_only=True) rows = db.list_gateway_sessions(active_only=True)
assert len(rows) == 1 assert len(rows) == 1
assert rows[0]["last_active"] == heartbeat assert rows[0]["last_active"] == heartbeat
activity = db.get_session_activity("gw-1") activity = _activity_snapshot(db, "gw-1")
assert activity["last_activity_description"] == "compressing context" assert activity["last_activity_description"] == "compressing context"
def test_order_by_last_active_surfaces_recently_touched_older_session_first(self, db): def test_order_by_last_active_surfaces_recently_touched_older_session_first(self, db):
@@ -3108,7 +3115,7 @@ class TestVacuum:
def test_auto_maintenance_freelist_ratio_exactly_at_threshold_skips(self, db, monkeypatch): def test_auto_maintenance_freelist_ratio_exactly_at_threshold_skips(self, db, monkeypatch):
"""Gate is strictly greater-than: 25.0% reclaimable does not VACUUM.""" """Gate is strictly greater-than: 25.0% reclaimable does not VACUUM."""
from hermes_state import AUTO_VACUUM_MIN_FREELIST_RATIO from hermes_state_common import AUTO_VACUUM_MIN_FREELIST_RATIO
monkeypatch.setattr(db, "prune_sessions", lambda **_kwargs: 1) monkeypatch.setattr(db, "prune_sessions", lambda **_kwargs: 1)
monkeypatch.setattr(db, "_freelist_ratio", lambda: AUTO_VACUUM_MIN_FREELIST_RATIO) monkeypatch.setattr(db, "_freelist_ratio", lambda: AUTO_VACUUM_MIN_FREELIST_RATIO)
@@ -3148,7 +3155,7 @@ class TestVacuum:
def test_freelist_ratio_reads_real_pragmas(self, db): def test_freelist_ratio_reads_real_pragmas(self, db):
"""Real-DB check: freeing most of the file pushes the ratio past the gate.""" """Real-DB check: freeing most of the file pushes the ratio past the gate."""
from hermes_state import AUTO_VACUUM_MIN_FREELIST_RATIO from hermes_state_common import AUTO_VACUUM_MIN_FREELIST_RATIO
db.create_session(session_id="keep", source="cli") db.create_session(session_id="keep", source="cli")
db.append_message(session_id="keep", role="user", content="hi") db.append_message(session_id="keep", role="user", content="hi")
@@ -3534,7 +3541,7 @@ class TestFTS5ToolCallMigration:
assert len(session_db.search_messages("LEGACYARG")) == 1, \ assert len(session_db.search_messages("LEGACYARG")) == 1, \
"v23 optimize must index tool_calls JSON into FTS" "v23 optimize must index tool_calls JSON into FTS"
# schema_version bumped once the FTS layer is v23 # schema_version bumped once the FTS layer is v23
from hermes_state import SCHEMA_VERSION from hermes_state_common import SCHEMA_VERSION
row = session_db._conn.execute( row = session_db._conn.execute(
"SELECT version FROM schema_version LIMIT 1" "SELECT version FROM schema_version LIMIT 1"
).fetchone() ).fetchone()
@@ -3806,7 +3813,7 @@ class TestFTSExternalContentMigration:
Mirrors what happened when ``_ensure_fts_schema`` ran inside Mirrors what happened when ``_ensure_fts_schema`` ran inside
``_execute_write`` and the process died before the marker writes. ``_execute_write`` and the process died before the marker writes.
""" """
from hermes_state import FTS_SQL, FTS_TRIGRAM_SQL from hermes_state_common import FTS_SQL, FTS_TRIGRAM_SQL
conn = db._conn conn = db._conn
db._drop_fts_triggers(conn) db._drop_fts_triggers(conn)
@@ -3871,7 +3878,7 @@ class TestFTSExternalContentMigration:
assert db.fts_rebuild_status() is None assert db.fts_rebuild_status() is None
assert db.fts_optimize_available() is False assert db.fts_optimize_available() is False
assert db.get_meta("fts_storage_version") == str( assert db.get_meta("fts_storage_version") == str(
hermes_state.FTS_STORAGE_VERSION hermes_state_common.FTS_STORAGE_VERSION
) )
assert db._conn.execute( assert db._conn.execute(
"SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'" "SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'"
@@ -3913,7 +3920,7 @@ class TestFTSExternalContentMigration:
"INSERT INTO state_meta (key, value) VALUES " "INSERT INTO state_meta (key, value) VALUES "
"('fts_storage_version', ?) " "('fts_storage_version', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(str(hermes_state.FTS_STORAGE_VERSION),), (str(hermes_state_common.FTS_STORAGE_VERSION),),
) )
db._conn.commit() db._conn.commit()
@@ -3928,7 +3935,7 @@ class TestFTSExternalContentMigration:
assert result["ok"] is True assert result["ok"] is True
assert len(db.search_messages("deployment")) == 1 assert len(db.search_messages("deployment")) == 1
assert db.get_meta("fts_storage_version") == str( assert db.get_meta("fts_storage_version") == str(
hermes_state.FTS_STORAGE_VERSION hermes_state_common.FTS_STORAGE_VERSION
) )
assert db.fts_optimize_available() is False assert db.fts_optimize_available() is False
finally: finally:
@@ -4275,14 +4282,14 @@ class TestApplyWalProbe:
import hermes_state import hermes_state
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
) )
def test_sets_wal_on_fresh_connection(self, tmp_path): def test_sets_wal_on_fresh_connection(self, tmp_path):
"""Probe sees 'delete', then set-pragma runs and returns 'wal'.""" """Probe sees 'delete', then set-pragma runs and returns 'wal'."""
import sqlite3 import sqlite3
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
class _TracingConn(sqlite3.Connection): class _TracingConn(sqlite3.Connection):
def __init__(self, *a, **kw): def __init__(self, *a, **kw):
@@ -4315,7 +4322,7 @@ class TestApplyWalProbe:
import sys import sys
import threading import threading
import sqlite3 import sqlite3
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
db_path = tmp_path / "concurrent.db" db_path = tmp_path / "concurrent.db"
errors = [] errors = []
@@ -4361,7 +4368,7 @@ class TestApplyWalProbe:
def test_returns_wal_not_delete_from_probe(self, tmp_path): def test_returns_wal_not_delete_from_probe(self, tmp_path):
"""Early-return only on 'wal'; 'delete' or 'memory' must fall through to set-pragma.""" """Early-return only on 'wal'; 'delete' or 'memory' must fall through to set-pragma."""
import sqlite3 import sqlite3
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
class _TracingConn(sqlite3.Connection): class _TracingConn(sqlite3.Connection):
def __init__(self, *a, **kw): def __init__(self, *a, **kw):
@@ -5699,8 +5706,7 @@ class TestPerformancePragmasEndToEnd:
# path. Force WAL eligibility so _get_read_conn is truly exercised # path. Force WAL eligibility so _get_read_conn is truly exercised
# (established pattern used by the WAL tests above). # (established pattern used by the WAL tests above).
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
"is_sqlite_wal_reset_vulnerable",
lambda version_info=None: False, lambda version_info=None: False,
) )
home = tmp_path / "hermes_home" home = tmp_path / "hermes_home"
+8 -12
View File
@@ -19,13 +19,9 @@ from unittest.mock import patch
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import ( import hermes_state_wal
SessionDB, from hermes_state import SessionDB, format_session_db_unavailable, get_last_init_error
WalUnsupportedError, from hermes_state_wal import WalUnsupportedError, apply_wal_with_fallback
apply_wal_with_fallback,
format_session_db_unavailable,
get_last_init_error,
)
# ``sqlite3.Connection.execute`` is a C-level slot and can't be monkeypatched # ``sqlite3.Connection.execute`` is a C-level slot and can't be monkeypatched
@@ -88,20 +84,20 @@ def _reset_last_init_error():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_wal_fallback_warned_paths(): def _reset_wal_fallback_warned_paths():
"""Reset the WAL-fallback warned-paths set so dedup doesn't leak between tests.""" """Reset the WAL-fallback warned-paths set so dedup doesn't leak between tests."""
hermes_state._wal_fallback_warned_paths.clear() hermes_state_wal._wal_fallback_warned_paths.clear()
yield yield
hermes_state._wal_fallback_warned_paths.clear() hermes_state_wal._wal_fallback_warned_paths.clear()
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _assume_fixed_sqlite(monkeypatch): def _assume_fixed_sqlite(monkeypatch):
"""NFS-fallback tests assume a SQLite build without the WAL-reset bug.""" """NFS-fallback tests assume a SQLite build without the WAL-reset bug."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
) )
hermes_state._wal_reset_bug_warned_paths.clear() hermes_state_wal._wal_reset_bug_warned_paths.clear()
yield yield
hermes_state._wal_reset_bug_warned_paths.clear() hermes_state_wal._wal_reset_bug_warned_paths.clear()
class TestApplyWalWithFallback: class TestApplyWalWithFallback:
+20 -17
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import sqlite3 import sqlite3
import pytest import pytest
import hermes_state_wal
import yaml import yaml
@@ -24,7 +26,7 @@ def _configure_mode(monkeypatch: pytest.MonkeyPatch, tmp_path, mode: object) ->
def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None: def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.is_sqlite_wal_reset_vulnerable", "hermes_state_wal.is_sqlite_wal_reset_vulnerable",
lambda **kwargs: False, lambda **kwargs: False,
) )
@@ -35,9 +37,9 @@ def _reset_configured_delete_override_warned_paths():
once-per-process-per-db_label dedup doesn't leak between tests.""" once-per-process-per-db_label dedup doesn't leak between tests."""
import hermes_state import hermes_state
hermes_state._delete_overridden_warned_paths.clear() hermes_state_wal._delete_overridden_warned_paths.clear()
yield yield
hermes_state._delete_overridden_warned_paths.clear() hermes_state_wal._delete_overridden_warned_paths.clear()
def test_database_journal_mode_has_a_canonical_default(): def test_database_journal_mode_has_a_canonical_default():
@@ -47,14 +49,14 @@ def test_database_journal_mode_has_a_canonical_default():
def test_resolve_journal_mode_uses_real_database_config(monkeypatch, tmp_path): def test_resolve_journal_mode_uses_real_database_config(monkeypatch, tmp_path):
from hermes_state import resolve_journal_mode from hermes_state_wal import resolve_journal_mode
_configure_mode(monkeypatch, tmp_path, "DELETE") _configure_mode(monkeypatch, tmp_path, "DELETE")
assert resolve_journal_mode() == "delete" assert resolve_journal_mode() == "delete"
def test_new_nonsecret_hermes_env_override_is_not_exposed(monkeypatch, tmp_path): def test_new_nonsecret_hermes_env_override_is_not_exposed(monkeypatch, tmp_path):
from hermes_state import resolve_journal_mode from hermes_state_wal import resolve_journal_mode
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
monkeypatch.setenv("HERMES_JOURNAL_MODE", "delete") monkeypatch.setenv("HERMES_JOURNAL_MODE", "delete")
@@ -63,7 +65,7 @@ def test_new_nonsecret_hermes_env_override_is_not_exposed(monkeypatch, tmp_path)
@pytest.mark.parametrize("value", ["bogus", "truncate", None, 42, {"bad": "shape"}]) @pytest.mark.parametrize("value", ["bogus", "truncate", None, 42, {"bad": "shape"}])
def test_invalid_config_value_falls_back_to_wal(monkeypatch, tmp_path, value): def test_invalid_config_value_falls_back_to_wal(monkeypatch, tmp_path, value):
from hermes_state import resolve_journal_mode from hermes_state_wal import resolve_journal_mode
_configure_mode(monkeypatch, tmp_path, value) _configure_mode(monkeypatch, tmp_path, value)
assert resolve_journal_mode() == "wal" assert resolve_journal_mode() == "wal"
@@ -73,14 +75,14 @@ def test_invalid_config_value_falls_back_to_wal(monkeypatch, tmp_path, value):
def test_malformed_database_section_falls_back_to_wal( def test_malformed_database_section_falls_back_to_wal(
monkeypatch, tmp_path, database monkeypatch, tmp_path, database
): ):
from hermes_state import resolve_journal_mode from hermes_state_wal import resolve_journal_mode
_write_config(monkeypatch, tmp_path, {"database": database}) _write_config(monkeypatch, tmp_path, {"database": database})
assert resolve_journal_mode() == "wal" assert resolve_journal_mode() == "wal"
def test_apply_wal_with_fallback_honors_delete_config(monkeypatch, tmp_path): def test_apply_wal_with_fallback_honors_delete_config(monkeypatch, tmp_path):
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "delete") _configure_mode(monkeypatch, tmp_path, "delete")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -93,7 +95,7 @@ def test_apply_wal_with_fallback_honors_delete_config(monkeypatch, tmp_path):
def test_apply_wal_with_fallback_defaults_to_wal(monkeypatch, tmp_path): def test_apply_wal_with_fallback_defaults_to_wal(monkeypatch, tmp_path):
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -107,11 +109,11 @@ def test_apply_wal_with_fallback_defaults_to_wal(monkeypatch, tmp_path):
def test_configured_delete_validates_vulnerable_sqlite_result(monkeypatch, tmp_path): def test_configured_delete_validates_vulnerable_sqlite_result(monkeypatch, tmp_path):
"""The safety gate must not report DELETE when SQLite returns MEMORY.""" """The safety gate must not report DELETE when SQLite returns MEMORY."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "delete") _configure_mode(monkeypatch, tmp_path, "delete")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.is_sqlite_wal_reset_vulnerable", "hermes_state_wal.is_sqlite_wal_reset_vulnerable",
lambda **kwargs: True, lambda **kwargs: True,
) )
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
@@ -127,7 +129,7 @@ def test_configured_delete_never_live_downgrades_existing_wal(monkeypatch, tmp_p
"""Keeping WAL is correct, but the operator must be told their configured """Keeping WAL is correct, but the operator must be told their configured
delete had no effect (otherwise the DB silently stays WAL and the protection delete had no effect (otherwise the DB silently stays WAL and the protection
they configured never applies).""" they configured never applies)."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "delete") _configure_mode(monkeypatch, tmp_path, "delete")
db_path = tmp_path / "existing-wal.db" db_path = tmp_path / "existing-wal.db"
@@ -135,7 +137,7 @@ def test_configured_delete_never_live_downgrades_existing_wal(monkeypatch, tmp_p
try: try:
assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal" assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal"
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.is_sqlite_wal_reset_vulnerable", "hermes_state_wal.is_sqlite_wal_reset_vulnerable",
lambda **kwargs: True, lambda **kwargs: True,
) )
with caplog.at_level("ERROR", logger="hermes_state"): with caplog.at_level("ERROR", logger="hermes_state"):
@@ -155,7 +157,7 @@ def test_configured_delete_overridden_warns_on_non_vulnerable_runtime_too(monkey
3.51.3+ upgrade), the on-disk WAL + configured-delete case reaches the 3.51.3+ upgrade), the on-disk WAL + configured-delete case reaches the
read-only probe path instead of the vulnerability path. That path used to read-only probe path instead of the vulnerability path. That path used to
return WAL with no signal at all; it must emit the same override warning.""" return WAL with no signal at all; it must emit the same override warning."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "delete") _configure_mode(monkeypatch, tmp_path, "delete")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -182,11 +184,11 @@ def test_configured_delete_overridden_warns_on_non_vulnerable_runtime_too(monkey
def test_configured_delete_overridden_warning_fires_once_per_db(monkeypatch, tmp_path, caplog): def test_configured_delete_overridden_warning_fires_once_per_db(monkeypatch, tmp_path, caplog):
"""The override warning is deduped per process per db_label (same discipline """The override warning is deduped per process per db_label (same discipline
as the WAL-fallback warning), so repeated connections don't flood the log.""" as the WAL-fallback warning), so repeated connections don't flood the log."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "delete") _configure_mode(monkeypatch, tmp_path, "delete")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.is_sqlite_wal_reset_vulnerable", "hermes_state_wal.is_sqlite_wal_reset_vulnerable",
lambda **kwargs: True, lambda **kwargs: True,
) )
db_path = tmp_path / "existing-wal.db" db_path = tmp_path / "existing-wal.db"
@@ -211,7 +213,7 @@ def test_configured_delete_with_require_wal_and_existing_wal_returns_wal(monkeyp
existing-WAL probe branch returns "wal" unconditionally (require_wal only existing-WAL probe branch returns "wal" unconditionally (require_wal only
governs the WAL-refusal fallback paths), so the override warning fires and no governs the WAL-refusal fallback paths), so the override warning fires and no
WalUnsupportedError is raised.""" WalUnsupportedError is raised."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "delete") _configure_mode(monkeypatch, tmp_path, "delete")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -242,6 +244,7 @@ def test_real_db_openers_honor_configured_delete(monkeypatch, tmp_path):
from gateway import delivery_ledger from gateway import delivery_ledger
from gateway.platforms.api_server import ResponseStore from gateway.platforms.api_server import ResponseStore
from hermes_cli import kanban_db, projects_db from hermes_cli import kanban_db, projects_db
from hermes_cli import kanban_db_connect as kbc
from hermes_state import SessionDB from hermes_state import SessionDB
from plugins.memory.holographic.store import MemoryStore from plugins.memory.holographic.store import MemoryStore
from plugins.platforms.discord.recovery import DiscordRecoveryStore from plugins.platforms.discord.recovery import DiscordRecoveryStore
+21 -19
View File
@@ -26,6 +26,8 @@ from __future__ import annotations
import sqlite3 import sqlite3
import pytest import pytest
import hermes_state_wal
import yaml import yaml
@@ -42,7 +44,7 @@ def _configure_mode(monkeypatch: pytest.MonkeyPatch, tmp_path, mode: object) ->
def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None: def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.is_sqlite_wal_reset_vulnerable", "hermes_state_wal.is_sqlite_wal_reset_vulnerable",
lambda **kwargs: False, lambda **kwargs: False,
) )
@@ -64,16 +66,16 @@ def _reset_dedup():
"""Order-independence: the warning is deduped per process per db_label.""" """Order-independence: the warning is deduped per process per db_label."""
import hermes_state import hermes_state
hermes_state._journal_upgrade_warned_paths.clear() hermes_state_wal._journal_upgrade_warned_paths.clear()
yield yield
hermes_state._journal_upgrade_warned_paths.clear() hermes_state_wal._journal_upgrade_warned_paths.clear()
class TestTheContentProbe: class TestTheContentProbe:
"""``_database_has_content`` is what keeps fresh installs quiet.""" """``_database_has_content`` is what keeps fresh installs quiet."""
def test_a_brand_new_database_has_no_content(self, tmp_path): def test_a_brand_new_database_has_no_content(self, tmp_path):
from hermes_state import _database_has_content from hermes_state_wal import _database_has_content
conn = sqlite3.connect(str(tmp_path / "new.db")) conn = sqlite3.connect(str(tmp_path / "new.db"))
try: try:
@@ -82,7 +84,7 @@ class TestTheContentProbe:
conn.close() conn.close()
def test_a_database_with_a_table_has_content(self, tmp_path): def test_a_database_with_a_table_has_content(self, tmp_path):
from hermes_state import _database_has_content from hermes_state_wal import _database_has_content
path = tmp_path / "used.db" path = tmp_path / "used.db"
_make_delete_db_with_content(path) _make_delete_db_with_content(path)
@@ -98,7 +100,7 @@ class TestTheContentProbe:
Answering True on an error would emit the warning for a database we Answering True on an error would emit the warning for a database we
could not measure, which includes every fresh one. could not measure, which includes every fresh one.
""" """
from hermes_state import _database_has_content from hermes_state_wal import _database_has_content
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
try: try:
@@ -113,7 +115,7 @@ class TestTheWarningFires:
def test_an_existing_delete_database_warns_when_flipped( def test_an_existing_delete_database_warns_when_flipped(
self, monkeypatch, tmp_path, caplog self, monkeypatch, tmp_path, caplog
): ):
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -139,7 +141,7 @@ class TestTheWarningFires:
Telling an operator their mode changed, without telling them which Telling an operator their mode changed, without telling them which
lever survives an open, leaves them doing the same PRAGMA again. lever survives an open, leaves them doing the same PRAGMA again.
""" """
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -165,7 +167,7 @@ class TestTheWarningFires:
(managed_uv repairs it on update, citing ~2600x slower appends), so (managed_uv repairs it on update, citing ~2600x slower appends), so
this must warn about the change without preventing it. this must warn about the change without preventing it.
""" """
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -183,7 +185,7 @@ class TestTheWarningFires:
self, monkeypatch, tmp_path, caplog self, monkeypatch, tmp_path, caplog
): ):
"""kanban opens a connection per operation; undeduped this is a flood.""" """kanban opens a connection per operation; undeduped this is a flood."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -205,7 +207,7 @@ class TestTheWarningFires:
self, monkeypatch, tmp_path, caplog self, monkeypatch, tmp_path, caplog
): ):
"""#89293 saw four databases flip. Dedup is per label, not global.""" """#89293 saw four databases flip. Dedup is per label, not global."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -236,7 +238,7 @@ class TestTheWarningStaysQuiet:
every opener applies WAL before creating any schema -- so without every opener applies WAL before creating any schema -- so without
this guard the warning fires on every first run of every install. this guard the warning fires on every first run of every install.
""" """
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -252,7 +254,7 @@ class TestTheWarningStaysQuiet:
def test_an_existing_wal_database_is_silent(self, monkeypatch, tmp_path, caplog): def test_an_existing_wal_database_is_silent(self, monkeypatch, tmp_path, caplog):
"""No flip happens: the probe returns early. Nothing to report.""" """No flip happens: the probe returns early. Nothing to report."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -271,7 +273,7 @@ class TestTheWarningStaysQuiet:
def test_configured_delete_is_silent(self, monkeypatch, tmp_path, caplog): def test_configured_delete_is_silent(self, monkeypatch, tmp_path, caplog):
"""The operator used the durable lever. There is nothing to tell them.""" """The operator used the durable lever. There is nothing to tell them."""
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "delete") _configure_mode(monkeypatch, tmp_path, "delete")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
@@ -299,11 +301,11 @@ class TestTheWarningStaysQuiet:
the SQLite upgrade -- warning here would blame the guard that was the SQLite upgrade -- warning here would blame the guard that was
doing its job. doing its job.
""" """
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.is_sqlite_wal_reset_vulnerable", "hermes_state_wal.is_sqlite_wal_reset_vulnerable",
lambda **kwargs: True, lambda **kwargs: True,
) )
path = tmp_path / "vulnerable.db" path = tmp_path / "vulnerable.db"
@@ -327,7 +329,7 @@ class TestTheExistingContractIsUnchanged:
"""Behaviour preservation for the rules this change sits next to.""" """Behaviour preservation for the rules this change sits next to."""
def test_on_disk_wal_is_still_never_live_downgraded(self, monkeypatch, tmp_path): def test_on_disk_wal_is_still_never_live_downgraded(self, monkeypatch, tmp_path):
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "delete") _configure_mode(monkeypatch, tmp_path, "delete")
path = tmp_path / "existing-wal.db" path = tmp_path / "existing-wal.db"
@@ -335,7 +337,7 @@ class TestTheExistingContractIsUnchanged:
try: try:
assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal" assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal"
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.is_sqlite_wal_reset_vulnerable", "hermes_state_wal.is_sqlite_wal_reset_vulnerable",
lambda **kwargs: True, lambda **kwargs: True,
) )
assert apply_wal_with_fallback(conn, db_label="existing-wal.db") == "wal" assert apply_wal_with_fallback(conn, db_label="existing-wal.db") == "wal"
@@ -346,7 +348,7 @@ class TestTheExistingContractIsUnchanged:
def test_default_config_still_yields_wal_on_a_fresh_database( def test_default_config_still_yields_wal_on_a_fresh_database(
self, monkeypatch, tmp_path self, monkeypatch, tmp_path
): ):
from hermes_state import apply_wal_with_fallback from hermes_state_wal import apply_wal_with_fallback
_configure_mode(monkeypatch, tmp_path, "wal") _configure_mode(monkeypatch, tmp_path, "wal")
_disable_vulnerable_gate(monkeypatch) _disable_vulnerable_gate(monkeypatch)
+3 -3
View File
@@ -152,17 +152,17 @@ def test_reacting_never_mutates_message_content(session, db):
def test_latest_user_message_is_the_agents_default_target(session, db): def test_latest_user_message_is_the_agents_default_target(session, db):
"""The agent reacts to "the message that triggered me" without an id.""" """The agent reacts to "the message that triggered me" without an id."""
key, rows = session key, rows = session
assert db.latest_user_message_row_id(key) == rows[0] assert db.latest_message_row_id(key, role="user") == rows[0]
db.append_message(key, "user", "thanks!") db.append_message(key, "user", "thanks!")
newest = db.get_messages_as_conversation(key, include_row_ids=True)[-1]["_row_id"] newest = db.get_messages_as_conversation(key, include_row_ids=True)[-1]["_row_id"]
assert db.latest_user_message_row_id(key) == newest assert db.latest_message_row_id(key, role="user") == newest
# Role-targeting contract: a newer ASSISTANT message must not become the # Role-targeting contract: a newer ASSISTANT message must not become the
# agent's default target — it always means the latest USER message. # agent's default target — it always means the latest USER message.
db.append_message(key, "assistant", "you're welcome") db.append_message(key, "assistant", "you're welcome")
assert db.latest_user_message_row_id(key) == newest assert db.latest_message_row_id(key, role="user") == newest
assert db.latest_message_row_id(key, role="assistant") != newest assert db.latest_message_row_id(key, role="assistant") != newest
+3 -2
View File
@@ -35,6 +35,7 @@ holds POSIX locks on that inode, so raw descriptor counts lag the real
connection count and make such assertions flaky. connection count and make such assertions flaky.
""" """
import hermes_state_readpool
import queue import queue
import threading import threading
@@ -540,10 +541,10 @@ def test_peak_is_bounded_across_many_database_files(tmp_path):
finally: finally:
for d in dbs: for d in dbs:
d.close() d.close()
assert hermes_state._process_read_permits.acquire(blocking=False), ( assert hermes_state_readpool._process_read_permits.acquire(blocking=False), (
"close() stranded a process permit" "close() stranded a process permit"
) )
hermes_state._process_read_permits.release() hermes_state_readpool._process_read_permits.release()
@pytest.mark.requires_wal @pytest.mark.requires_wal
+2 -1
View File
@@ -8,7 +8,8 @@ import time
import pytest import pytest
from hermes_state import SCHEMA_VERSION, SessionDB from hermes_state import SessionDB
from hermes_state_common import SCHEMA_VERSION
@pytest.fixture() @pytest.fixture()
+3 -2
View File
@@ -1,4 +1,4 @@
"""Session <-> workspace grouping key (hermes_state.workspace_key). """Session <-> workspace grouping key (hermes_state_sessions.workspace_key).
The key is what `hermes sessions list --workspace` groups/filters on. It is a The key is what `hermes sessions list --workspace` groups/filters on. It is a
coarse workspace identity derived from fields already recorded on sessions coarse workspace identity derived from fields already recorded on sessions
@@ -6,7 +6,8 @@ coarse workspace identity derived from fields already recorded on sessions
NOT part of the key. NOT part of the key.
""" """
from hermes_state import workspace_key import hermes_state_sessions
from hermes_state_sessions import workspace_key
def test_repo_root_is_the_key_when_known(): def test_repo_root_is_the_key_when_known():
+17 -22
View File
@@ -18,18 +18,15 @@ from types import SimpleNamespace
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import ( import hermes_state_wal
apply_wal_with_fallback, from hermes_state_wal import apply_wal_with_fallback, is_sqlite_wal_reset_vulnerable, sqlite_source_id
is_sqlite_wal_reset_vulnerable,
sqlite_source_id,
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_wal_reset_bug_warnings(): def _reset_wal_reset_bug_warnings():
hermes_state._wal_reset_bug_warned_paths.clear() hermes_state_wal._wal_reset_bug_warned_paths.clear()
yield yield
hermes_state._wal_reset_bug_warned_paths.clear() hermes_state_wal._wal_reset_bug_warned_paths.clear()
class TestIsSqliteWalResetVulnerable: class TestIsSqliteWalResetVulnerable:
@@ -61,7 +58,7 @@ class TestIsSqliteWalResetVulnerable:
class TestApplyWalWalResetGate: class TestApplyWalWalResetGate:
def test_fresh_db_uses_delete_when_vulnerable(self, tmp_path, monkeypatch, caplog): def test_fresh_db_uses_delete_when_vulnerable(self, tmp_path, monkeypatch, caplog):
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
) )
conn = sqlite3.connect(str(tmp_path / "fresh.db")) conn = sqlite3.connect(str(tmp_path / "fresh.db"))
with caplog.at_level("WARNING", logger="hermes_state"): with caplog.at_level("WARNING", logger="hermes_state"):
@@ -77,7 +74,7 @@ class TestApplyWalWalResetGate:
): ):
"""Already-WAL DBs must not be live-downgraded under concurrent openers.""" """Already-WAL DBs must not be live-downgraded under concurrent openers."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
) )
path = tmp_path / "prior_wal.db" path = tmp_path / "prior_wal.db"
seed = sqlite3.connect(str(path)) seed = sqlite3.connect(str(path))
@@ -109,7 +106,7 @@ class TestApplyWalWalResetGate:
def test_warning_deduped_per_label(self, tmp_path, monkeypatch, caplog): def test_warning_deduped_per_label(self, tmp_path, monkeypatch, caplog):
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
) )
with caplog.at_level("WARNING", logger="hermes_state"): with caplog.at_level("WARNING", logger="hermes_state"):
for name in ("a.db", "a.db", "b.db"): for name in ("a.db", "a.db", "b.db"):
@@ -151,7 +148,7 @@ class TestNoDowngradeUnderConcurrentOpeners:
All blocked-state assertions run WHILE the holder owns the DB.""" All blocked-state assertions run WHILE the holder owns the DB."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
) )
db = tmp_path / "live_wal.db" db = tmp_path / "live_wal.db"
seed = sqlite3.connect(str(db)) seed = sqlite3.connect(str(db))
@@ -215,7 +212,7 @@ class TestNoDowngradeUnderConcurrentOpeners:
as 'not WAL' and flipping anyway (the incident's exact confusion). as 'not WAL' and flipping anyway (the incident's exact confusion).
Assertions run WHILE the holder's exclusive lock is live.""" Assertions run WHILE the holder's exclusive lock is live."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
) )
db = tmp_path / "locked_wal.db" db = tmp_path / "locked_wal.db"
seed = sqlite3.connect(str(db)) seed = sqlite3.connect(str(db))
@@ -266,7 +263,7 @@ class TestNoDowngradeUnderConcurrentOpeners:
"""No concurrent openers → the vulnerable-SQLite DELETE gate still """No concurrent openers → the vulnerable-SQLite DELETE gate still
applies exactly as before.""" applies exactly as before."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
) )
conn = sqlite3.connect(str(tmp_path / "exclusive.db")) conn = sqlite3.connect(str(tmp_path / "exclusive.db"))
try: try:
@@ -287,7 +284,7 @@ class TestNoDowngradeUnderConcurrentOpeners:
between probe and flip), the gate returns the observed mode instead of between probe and flip), the gate returns the observed mode instead of
raising or waiting the lock out.""" raising or waiting the lock out."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
) )
class _FlipLockedConnection(sqlite3.Connection): class _FlipLockedConnection(sqlite3.Connection):
@@ -316,11 +313,10 @@ class TestNoDowngradeUnderConcurrentOpeners:
refuse to downgrade when the mode probe is blocked by a concurrent refuse to downgrade when the mode probe is blocked by a concurrent
opener's exclusive lock — raise, never flip blind.""" opener's exclusive lock — raise, never flip blind."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
"is_sqlite_wal_reset_vulnerable",
lambda version_info=None: False, lambda version_info=None: False,
) )
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "delete") monkeypatch.setattr(hermes_state_wal, "resolve_journal_mode", lambda: "delete")
db = tmp_path / "cfg_delete.db" db = tmp_path / "cfg_delete.db"
seed = sqlite3.connect(str(db)) seed = sqlite3.connect(str(db))
try: try:
@@ -358,11 +354,10 @@ class TestNoDowngradeUnderConcurrentOpeners:
"""The filesystem-incompat fallback must not downgrade when the on-disk """The filesystem-incompat fallback must not downgrade when the on-disk
mode cannot be verified (possible concurrent openers).""" mode cannot be verified (possible concurrent openers)."""
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
"is_sqlite_wal_reset_vulnerable",
lambda version_info=None: False, lambda version_info=None: False,
) )
hermes_state._wal_fallback_warned_paths.clear() hermes_state_wal._wal_fallback_warned_paths.clear()
class _LockedProbeConnection(sqlite3.Connection): class _LockedProbeConnection(sqlite3.Connection):
def execute(self, sql, *args, **kwargs): # type: ignore[override] def execute(self, sql, *args, **kwargs): # type: ignore[override]
@@ -394,9 +389,9 @@ def test_doctor_warns_without_adding_issues(monkeypatch, tmp_path, capsys):
monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: home) monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: home)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
) )
monkeypatch.setattr(hermes_state, "sqlite_source_id", lambda: "testid-abc") monkeypatch.setattr(hermes_state_wal, "sqlite_source_id", lambda: "testid-abc")
monkeypatch.setattr(sqlite3, "sqlite_version", "3.50.4", raising=False) monkeypatch.setattr(sqlite3, "sqlite_version", "3.50.4", raising=False)
args = SimpleNamespace(fix=False, ack=None) args = SimpleNamespace(fix=False, ack=None)
+20 -24
View File
@@ -23,11 +23,10 @@ from pathlib import Path
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import ( import hermes_state_repair
SessionDB, import hermes_state_wal
is_malformed_db_error, from hermes_state import SessionDB, is_malformed_db_error
repair_state_db_schema, from hermes_state_repair import repair_state_db_schema
)
def _build_healthy_db(db_path: Path) -> str: def _build_healthy_db(db_path: Path) -> str:
@@ -80,10 +79,9 @@ def test_generic_malformed_open_does_not_attempt_schema_surgery(
def _generic_corruption(*_args, **_kwargs): def _generic_corruption(*_args, **_kwargs):
raise sqlite3.DatabaseError("database disk image is malformed") raise sqlite3.DatabaseError("database disk image is malformed")
monkeypatch.setattr(hermes_state, "apply_wal_with_fallback", _generic_corruption) monkeypatch.setattr(hermes_state, "apply_wal_with_fallback", _generic_corruption) # SessionDB open path
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state, "repair_state_db_schema",
"repair_state_db_schema",
lambda *args, **kwargs: repair_calls.append((args, kwargs)), lambda *args, **kwargs: repair_calls.append((args, kwargs)),
) )
@@ -188,7 +186,7 @@ def _corrupt_fts_shadow_segments(db_path: Path) -> None:
def test_fts_read_corruption_repaired_in_place(tmp_path): def test_fts_read_corruption_repaired_in_place(tmp_path):
"""``repair_state_db_schema`` rebuilds the FTS index so reads resume.""" """``repair_state_db_schema`` rebuilds the FTS index so reads resume."""
from hermes_state import _db_opens_cleanly from hermes_state_repair import _db_opens_cleanly
db_path = tmp_path / "state.db" db_path = tmp_path / "state.db"
_build_healthy_db(db_path) _build_healthy_db(db_path)
@@ -275,7 +273,7 @@ def _corrupt_fts_index_data(db_path: Path) -> None:
def test_fts_write_corruption_detected_by_write_probe(tmp_path): def test_fts_write_corruption_detected_by_write_probe(tmp_path):
"""_db_opens_cleanly's rolled-back write probe flags FTS write corruption.""" """_db_opens_cleanly's rolled-back write probe flags FTS write corruption."""
from hermes_state import _db_opens_cleanly from hermes_state_repair import _db_opens_cleanly
db_path = tmp_path / "state.db" db_path = tmp_path / "state.db"
_build_healthy_db(db_path) _build_healthy_db(db_path)
@@ -296,7 +294,7 @@ def test_fts_write_corruption_detected_by_write_probe(tmp_path):
def test_fts_write_corruption_repaired_in_place(tmp_path): def test_fts_write_corruption_repaired_in_place(tmp_path):
"""repair_state_db_schema rebuilds the FTS index; reads + writes resume.""" """repair_state_db_schema rebuilds the FTS index; reads + writes resume."""
from hermes_state import _db_opens_cleanly from hermes_state_repair import _db_opens_cleanly
db_path = tmp_path / "state.db" db_path = tmp_path / "state.db"
_build_healthy_db(db_path) _build_healthy_db(db_path)
@@ -380,7 +378,7 @@ def test_repair_rebuilds_stale_btree_indexes(tmp_path):
_corrupt_btree_index(db_path, "idx_messages_session") _corrupt_btree_index(db_path, "idx_messages_session")
# The real detector must see the real corruption... # The real detector must see the real corruption...
reason = hermes_state._db_opens_cleanly(db_path) reason = hermes_state_repair._db_opens_cleanly(db_path)
assert reason is not None assert reason is not None
assert "wrong # of entries in index idx_messages_session" in reason assert "wrong # of entries in index idx_messages_session" in reason
@@ -391,7 +389,7 @@ def test_repair_rebuilds_stale_btree_indexes(tmp_path):
# Post-repair the DB is genuinely healthy: detector and raw # Post-repair the DB is genuinely healthy: detector and raw
# integrity_check both agree, and the repaired index answers queries. # integrity_check both agree, and the repaired index answers queries.
assert hermes_state._db_opens_cleanly(db_path) is None assert hermes_state_repair._db_opens_cleanly(db_path) is None
raw = sqlite3.connect(str(db_path)) raw = sqlite3.connect(str(db_path))
assert raw.execute("PRAGMA integrity_check").fetchone()[0] == "ok" assert raw.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
n = raw.execute( n = raw.execute(
@@ -481,7 +479,7 @@ def test_repair_skips_surgery_while_another_process_holds_the_lock(
# No surgery ran: no backup was taken and the DB is still malformed. # No surgery ran: no backup was taken and the DB is still malformed.
assert report["backup_path"] is None assert report["backup_path"] is None
assert not list(tmp_path.glob("state.db.malformed-backup-*")) assert not list(tmp_path.glob("state.db.malformed-backup-*"))
assert hermes_state._db_opens_cleanly(db_path) is not None assert hermes_state_repair._db_opens_cleanly(db_path) is not None
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX flock test") @pytest.mark.skipif(sys.platform == "win32", reason="POSIX flock test")
@@ -503,7 +501,7 @@ def test_repair_reports_success_when_the_holder_already_healed_the_db(
_REPAIR_SCRIPT = """ _REPAIR_SCRIPT = """
import sys, json import sys, json
sys.path.insert(0, {root!r}) sys.path.insert(0, {root!r})
from hermes_state import repair_state_db_schema from hermes_state_repair import repair_state_db_schema
print(json.dumps(repair_state_db_schema({db!r})), flush=True) print(json.dumps(repair_state_db_schema({db!r})), flush=True)
""" """
@@ -597,8 +595,7 @@ def test_backup_refusal_hard_stops_the_repair(tmp_path, monkeypatch):
original_bytes = db_path.read_bytes() original_bytes = db_path.read_bytes()
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_repair, "_backup_db_file",
"_backup_db_file",
lambda p: (None, "a connection to it is still open in this process"), lambda p: (None, "a connection to it is still open in this process"),
) )
@@ -610,7 +607,7 @@ def test_backup_refusal_hard_stops_the_repair(tmp_path, monkeypatch):
assert "still open" in report["error"] assert "still open" in report["error"]
# No mutating strategy ran: the damaged source bytes are untouched. # No mutating strategy ran: the damaged source bytes are untouched.
assert db_path.read_bytes() == original_bytes assert db_path.read_bytes() == original_bytes
assert hermes_state._db_opens_cleanly(db_path) is not None assert hermes_state_repair._db_opens_cleanly(db_path) is not None
def test_backup_copy_failure_hard_stops_the_repair(tmp_path, monkeypatch): def test_backup_copy_failure_hard_stops_the_repair(tmp_path, monkeypatch):
@@ -620,8 +617,7 @@ def test_backup_copy_failure_hard_stops_the_repair(tmp_path, monkeypatch):
_corrupt_duplicate_fts(db_path) _corrupt_duplicate_fts(db_path)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_repair, "_backup_db_file",
"_backup_db_file",
lambda p: (None, "backup copy failed: [Errno 28] No space left on device"), lambda p: (None, "backup copy failed: [Errno 28] No space left on device"),
) )
@@ -671,7 +667,7 @@ def _configure_journal_mode(monkeypatch, tmp_path, mode) -> None:
yaml.safe_dump({"database": {"journal_mode": mode}}), encoding="utf-8", yaml.safe_dump({"database": {"journal_mode": mode}}), encoding="utf-8",
) )
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda **kwargs: False, hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda **kwargs: False,
) )
@@ -722,7 +718,7 @@ def test_repair_restore_matches_canonical_on_vulnerable_sqlite(
db_path = tmp_path / "state.db" db_path = tmp_path / "state.db"
_configure_journal_mode(monkeypatch, tmp_path, "wal") _configure_journal_mode(monkeypatch, tmp_path, "wal")
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda **kwargs: True hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda **kwargs: True
) )
_build_healthy_db(db_path) _build_healthy_db(db_path)
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
@@ -756,7 +752,7 @@ def test_repair_logs_mode_change_when_probe_succeeded(
with ( with (
patch.object( patch.object(
hermes_state, "_probe_journal_mode_for_repair", return_value="delete" hermes_state_repair, "_probe_journal_mode_for_repair", return_value="delete"
), ),
caplog.at_level(logging.WARNING, logger="hermes_state"), caplog.at_level(logging.WARNING, logger="hermes_state"),
): ):
@@ -813,7 +809,7 @@ def test_repair_restore_failure_is_nonfatal_and_logged(
raise sqlite3.OperationalError("database is locked") raise sqlite3.OperationalError("database is locked")
with ( with (
patch.object(hermes_state, "apply_wal_with_fallback", _refused), patch.object(hermes_state_wal, "apply_wal_with_fallback", _refused),
caplog.at_level(logging.WARNING, logger="hermes_state"), caplog.at_level(logging.WARNING, logger="hermes_state"),
): ):
report = repair_state_db_schema(db_path) report = repair_state_db_schema(db_path)
+2 -1
View File
@@ -14,7 +14,8 @@ from unittest.mock import MagicMock
import pytest import pytest
from hermes_state import SessionDB, StateDbCorruptError, _on_disk_journal_mode from hermes_state import SessionDB, StateDbCorruptError
from hermes_state_wal import _on_disk_journal_mode
class _NotADbOnce: class _NotADbOnce:
+10 -13
View File
@@ -29,11 +29,10 @@ from pathlib import Path
import pytest import pytest
import hermes_state import hermes_state
import hermes_state_repair
import hermes_state_holders import hermes_state_holders
from hermes_state import ( from hermes_state import SessionDB
SessionDB, from hermes_state_repair import repair_state_db_schema
repair_state_db_schema,
)
def _make_wal_db(tmp_path: Path) -> Path: def _make_wal_db(tmp_path: Path) -> Path:
@@ -101,7 +100,7 @@ def test_repair_checks_foreign_holders_before_opening_sqlite(tmp_path, monkeypat
def _unexpected_probe(*_args, **_kwargs): def _unexpected_probe(*_args, **_kwargs):
pytest.fail("repair opened SQLite before excluding foreign holders") pytest.fail("repair opened SQLite before excluding foreign holders")
monkeypatch.setattr(hermes_state, "_connect_repair_durable", _unexpected_probe) monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", _unexpected_probe)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -181,7 +180,7 @@ def test_uninspectable_watched_descriptor_blocks_repair_before_sqlite(
def _unexpected_probe(*_args, **_kwargs): def _unexpected_probe(*_args, **_kwargs):
pytest.fail("repair opened SQLite with unproven descriptor identity") pytest.fail("repair opened SQLite with unproven descriptor identity")
monkeypatch.setattr(hermes_state, "_connect_repair_durable", _unexpected_probe) monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", _unexpected_probe)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -227,8 +226,7 @@ def test_uninspectable_unknown_descriptor_uses_hermes_identity_at_repair_boundar
pytest.fail("repair opened SQLite with an unproven Hermes descriptor") pytest.fail("repair opened SQLite with an unproven Hermes descriptor")
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_repair, "_connect_repair_durable",
"_connect_repair_durable",
_unexpected_probe, _unexpected_probe,
) )
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -236,7 +234,7 @@ def test_uninspectable_unknown_descriptor_uses_hermes_identity_at_repair_boundar
assert report["repaired"] is False assert report["repaired"] is False
assert "live writer" in (report["error"] or "").lower() assert "live writer" in (report["error"] or "").lower()
else: else:
real_connect = hermes_state._connect_repair_durable real_connect = hermes_state_repair._connect_repair_durable
probe_reached = False probe_reached = False
def _record_probe(*args, **kwargs): def _record_probe(*args, **kwargs):
@@ -245,8 +243,7 @@ def test_uninspectable_unknown_descriptor_uses_hermes_identity_at_repair_boundar
return real_connect(*args, **kwargs) return real_connect(*args, **kwargs)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_repair, "_connect_repair_durable",
"_connect_repair_durable",
_record_probe, _record_probe,
) )
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -288,7 +285,7 @@ def test_uninspectable_watched_identity_blocks_alias_before_sqlite(
def _unexpected_probe(*_args, **_kwargs): def _unexpected_probe(*_args, **_kwargs):
pytest.fail("repair opened SQLite with an unproven watched identity") pytest.fail("repair opened SQLite with an unproven watched identity")
monkeypatch.setattr(hermes_state, "_connect_repair_durable", _unexpected_probe) monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", _unexpected_probe)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -332,7 +329,7 @@ def test_uninspectable_alias_descriptor_for_hermes_blocks_before_sqlite(
def _unexpected_probe(*_args, **_kwargs): def _unexpected_probe(*_args, **_kwargs):
pytest.fail("repair opened SQLite with an unproven Hermes alias fd") pytest.fail("repair opened SQLite with an unproven Hermes alias fd")
monkeypatch.setattr(hermes_state, "_connect_repair_durable", _unexpected_probe) monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", _unexpected_probe)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
+3 -12
View File
@@ -25,17 +25,8 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import hermes_state import hermes_state
from hermes_state import ( import hermes_state_repair
_MAX_MALFORMED_BACKUPS, from hermes_state_repair import _MAX_MALFORMED_BACKUPS, _MAX_PERSISTENT_REPAIR_ATTEMPTS, _backup_db_file, _existing_malformed_backups, _persistent_repair_attempts_exhausted, _prune_malformed_backups, _record_repair_outcome, _repair_ledger_path, repair_state_db_schema
_MAX_PERSISTENT_REPAIR_ATTEMPTS,
_backup_db_file,
_existing_malformed_backups,
_persistent_repair_attempts_exhausted,
_prune_malformed_backups,
_record_repair_outcome,
_repair_ledger_path,
repair_state_db_schema,
)
def _make_unrepairable_db(tmp_path: Path) -> Path: def _make_unrepairable_db(tmp_path: Path) -> Path:
@@ -76,7 +67,7 @@ class TestPersistentAttemptCap:
# Budget burned: the next call must refuse WITHOUT running surgery # Budget burned: the next call must refuse WITHOUT running surgery
# (and without taking another backup). # (and without taking another backup).
backups_before = len(_existing_malformed_backups(db)) backups_before = len(_existing_malformed_backups(db))
with patch.object(hermes_state, "_repair_state_db_schema_locked") as surgery: with patch.object(hermes_state_repair, "_repair_state_db_schema_locked") as surgery:
report = repair_state_db_schema(db) report = repair_state_db_schema(db)
surgery.assert_not_called() surgery.assert_not_called()
assert report["repaired"] is False assert report["repaired"] is False
+3 -13
View File
@@ -29,18 +29,8 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import hermes_state import hermes_state
from hermes_state import ( import hermes_state_repair
_MAX_MALFORMED_BACKUPS, from hermes_state_repair import _MAX_MALFORMED_BACKUPS, _MAX_PERSISTENT_REPAIR_ATTEMPTS, _REPAIR_BACKUP_MIN_FREE_BYTES, _backup_content_identity, _backup_db_file, _db_fingerprint, _existing_malformed_backups, _persistent_repair_attempts_exhausted, _record_repair_outcome, _repair_backup_headroom_bytes
_MAX_PERSISTENT_REPAIR_ATTEMPTS,
_REPAIR_BACKUP_MIN_FREE_BYTES,
_backup_content_identity,
_backup_db_file,
_db_fingerprint,
_existing_malformed_backups,
_persistent_repair_attempts_exhausted,
_record_repair_outcome,
_repair_backup_headroom_bytes,
)
def _damaged_db(tmp_path: Path, size: int = 200_000) -> Path: def _damaged_db(tmp_path: Path, size: int = 200_000) -> Path:
@@ -255,7 +245,7 @@ def test_repair_aborts_when_backup_refused_for_disk(tmp_path):
"Usage", (), {"total": 0, "used": 0, "free": _REPAIR_BACKUP_MIN_FREE_BYTES // 2} "Usage", (), {"total": 0, "used": 0, "free": _REPAIR_BACKUP_MIN_FREE_BYTES // 2}
)() )()
with patch("shutil.disk_usage", return_value=tight): with patch("shutil.disk_usage", return_value=tight):
report = hermes_state.repair_state_db_schema(db) report = hermes_state_repair.repair_state_db_schema(db)
assert not report.get("repaired") assert not report.get("repaired")
assert "free" in (report.get("error") or "").lower() assert "free" in (report.get("error") or "").lower()
+57 -57
View File
@@ -66,7 +66,8 @@ from types import SimpleNamespace
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import repair_state_db_schema import hermes_state_repair
from hermes_state_repair import repair_state_db_schema
PAGE_SIZE = 4096 PAGE_SIZE = 4096
@@ -130,7 +131,7 @@ def _leave_hot_wal_row(db_path: str) -> None:
def _probe_repair_lock_from_child(db_path: str, result) -> None: def _probe_repair_lock_from_child(db_path: str, result) -> None:
"""Attempt the repair lock with a short timeout from another process.""" """Attempt the repair lock with a short timeout from another process."""
hermes_state._REPAIR_LOCK_TIMEOUT_SECONDS = 0.5 hermes_state._REPAIR_LOCK_TIMEOUT_SECONDS = 0.5
with hermes_state._cross_process_repair_lock(Path(db_path)) as holding: with hermes_state_repair._cross_process_repair_lock(Path(db_path)) as holding:
result.put(holding) result.put(holding)
@@ -196,13 +197,13 @@ def test_strategies_never_receive_the_live_database(corrupt_db, monkeypatch):
"""Every strategy mutates its argument in place, so the property that """Every strategy mutates its argument in place, so the property that
makes them safe is simply that the argument is never the real file.""" makes them safe is simply that the argument is never the real file."""
seen: list[Path] = [] seen: list[Path] = []
real = hermes_state._run_repair_strategies real = hermes_state_repair._run_repair_strategies
def spy(path, report): def spy(path, report):
seen.append(path) seen.append(path)
return real(path, report) return real(path, report)
monkeypatch.setattr(hermes_state, "_run_repair_strategies", spy) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", spy)
repair_state_db_schema(corrupt_db) repair_state_db_schema(corrupt_db)
assert seen, "the repair path did not run at all" assert seen, "the repair path did not run at all"
@@ -259,7 +260,7 @@ def test_successful_repair_is_promoted_over_the_original(tmp_path, monkeypatch):
# Force the "already healthy" short-circuit off so the staging path runs, # Force the "already healthy" short-circuit off so the staging path runs,
# and have the strategy pass mark a repair after writing a marker row. # and have the strategy pass mark a repair after writing a marker row.
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda path: "forced-unhealthy"
) )
def fake_strategies(scratch_path, report): def fake_strategies(scratch_path, report):
@@ -271,7 +272,7 @@ def test_successful_repair_is_promoted_over_the_original(tmp_path, monkeypatch):
report["strategy"] = "test_strategy" report["strategy"] = "test_strategy"
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", fake_strategies)
report = repair_state_db_schema(db) report = repair_state_db_schema(db)
assert report["repaired"] is True assert report["repaired"] is True
@@ -303,7 +304,7 @@ def test_committed_writer_after_staging_is_never_lost(
_make_repair_test_db(db, journal_mode=journal_mode) _make_repair_test_db(db, journal_mode=journal_mode)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
ready = multiprocessing.get_context("spawn").Event() ready = multiprocessing.get_context("spawn").Event()
start = multiprocessing.get_context("spawn").Event() start = multiprocessing.get_context("spawn").Event()
@@ -329,7 +330,7 @@ def test_committed_writer_after_staging_is_never_lost(
report["strategy"] = "race_test" report["strategy"] = "race_test"
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", staged_strategy) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", staged_strategy)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
writer.join(20) writer.join(20)
if writer.is_alive(): if writer.is_alive():
@@ -361,11 +362,10 @@ def test_environmental_aborts_do_not_burn_repair_ledger(tmp_path, monkeypatch):
db = tmp_path / "state.db" db = tmp_path / "state.db"
_make_repair_test_db(db) _make_repair_test_db(db)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, hermes_state_repair, "_repair_scratch_space_error",
"_repair_scratch_space_error",
lambda _path: "temporary disk pressure", lambda _path: "temporary disk pressure",
) )
@@ -374,10 +374,10 @@ def test_environmental_aborts_do_not_burn_repair_ledger(tmp_path, monkeypatch):
assert report["repaired"] is False assert report["repaired"] is False
assert report["error"] == "temporary disk pressure" assert report["error"] == "temporary disk pressure"
ledger_path = hermes_state._repair_ledger_path(db) ledger_path = hermes_state_repair._repair_ledger_path(db)
assert not ledger_path.exists(), "environmental aborts must not consume attempts" assert not ledger_path.exists(), "environmental aborts must not consume attempts"
monkeypatch.setattr(hermes_state, "_repair_scratch_space_error", lambda _path: None) monkeypatch.setattr(hermes_state_repair, "_repair_scratch_space_error", lambda _path: None)
def successful_strategy(scratch_path, report): def successful_strategy(scratch_path, report):
with sqlite3.connect(str(scratch_path)) as conn: with sqlite3.connect(str(scratch_path)) as conn:
@@ -387,7 +387,7 @@ def test_environmental_aborts_do_not_burn_repair_ledger(tmp_path, monkeypatch):
report["strategy"] = "after_environmental_aborts" report["strategy"] = "after_environmental_aborts"
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", successful_strategy)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is True assert report["repaired"] is True
assert report["strategy"] == "after_environmental_aborts" assert report["strategy"] == "after_environmental_aborts"
@@ -397,7 +397,7 @@ def test_actual_strategy_failure_still_consumes_one_attempt(tmp_path, monkeypatc
db = tmp_path / "state.db" db = tmp_path / "state.db"
_make_repair_test_db(db) _make_repair_test_db(db)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
def failed_strategy(_scratch_path, report): def failed_strategy(_scratch_path, report):
@@ -405,10 +405,10 @@ def test_actual_strategy_failure_still_consumes_one_attempt(tmp_path, monkeypatc
report["strategy"] = None report["strategy"] = None
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", failed_strategy)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is False assert report["repaired"] is False
ledger = hermes_state._read_repair_ledger(db) ledger = hermes_state_repair._read_repair_ledger(db)
assert ledger["failed_attempts"] == 1 assert ledger["failed_attempts"] == 1
@@ -419,7 +419,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held(
db = tmp_path / "state.db" db = tmp_path / "state.db"
_make_repair_test_db(db) _make_repair_test_db(db)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
def failed_strategy(_scratch_path, report): def failed_strategy(_scratch_path, report):
@@ -427,10 +427,10 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held(
report["strategy"] = None report["strategy"] = None
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", failed_strategy)
observed = [] observed = []
lock_released = threading.Event() lock_released = threading.Event()
real_repair_lock = hermes_state._cross_process_repair_lock real_repair_lock = hermes_state_repair._cross_process_repair_lock
@contextlib.contextmanager @contextlib.contextmanager
def tracking_repair_lock(path): def tracking_repair_lock(path):
@@ -439,7 +439,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held(
lock_released.set() lock_released.set()
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_cross_process_repair_lock", tracking_repair_lock hermes_state_repair, "_cross_process_repair_lock", tracking_repair_lock
) )
def record_outcome(_db_path, *, repaired, fingerprint=None): def record_outcome(_db_path, *, repaired, fingerprint=None):
@@ -462,7 +462,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held(
probe.join(5) probe.join(5)
assert repaired is False assert repaired is False
monkeypatch.setattr(hermes_state, "_record_repair_outcome", record_outcome) monkeypatch.setattr(hermes_state_repair, "_record_repair_outcome", record_outcome)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is False assert report["repaired"] is False
@@ -484,9 +484,9 @@ def test_exhaustion_is_rechecked_after_acquiring_repair_lock(tmp_path, monkeypat
return len(exhaustion_checks) >= 2 return len(exhaustion_checks) >= 2
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_persistent_repair_attempts_exhausted", exhaustion_probe hermes_state_repair, "_persistent_repair_attempts_exhausted", exhaustion_probe
) )
monkeypatch.setattr(hermes_state, "_live_writer_holds_db", lambda _path: False) monkeypatch.setattr(hermes_state_repair, "_live_writer_holds_db", lambda _path: False)
surgery_calls = [] surgery_calls = []
def unexpected_surgery(_db_path, *, backup, report): def unexpected_surgery(_db_path, *, backup, report):
@@ -494,7 +494,7 @@ def test_exhaustion_is_rechecked_after_acquiring_repair_lock(tmp_path, monkeypat
return report return report
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_repair_state_db_schema_locked", unexpected_surgery hermes_state_repair, "_repair_state_db_schema_locked", unexpected_surgery
) )
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -513,7 +513,7 @@ def test_scratch_budget_counts_sidecars_in_vacuum_multiplier(tmp_path, monkeypat
db.write_bytes(b"m" * main_bytes) db.write_bytes(b"m" * main_bytes)
db.with_name(db.name + "-wal").write_bytes(b"w" * wal_bytes) db.with_name(db.name + "-wal").write_bytes(b"w" * wal_bytes)
total = 10_000_000_000 total = 10_000_000_000
headroom = hermes_state._repair_backup_headroom_bytes(total) headroom = hermes_state_repair._repair_backup_headroom_bytes(total)
# This exactly satisfies the obsolete ``snapshot + 2*main + headroom`` # This exactly satisfies the obsolete ``snapshot + 2*main + headroom``
# calculation, but is below the corrected ``3*snapshot + headroom``. # calculation, but is below the corrected ``3*snapshot + headroom``.
old_required = main_bytes + wal_bytes + (2 * main_bytes) + headroom old_required = main_bytes + wal_bytes + (2 * main_bytes) + headroom
@@ -523,7 +523,7 @@ def test_scratch_budget_counts_sidecars_in_vacuum_multiplier(tmp_path, monkeypat
lambda _path: SimpleNamespace(total=total, free=old_required), lambda _path: SimpleNamespace(total=total, free=old_required),
) )
error = hermes_state._repair_scratch_space_error(db) error = hermes_state_repair._repair_scratch_space_error(db)
assert error is not None assert error is not None
assert "VACUUM may need another" in error assert "VACUUM may need another" in error
@@ -536,7 +536,7 @@ def test_environmental_promotion_failures_do_not_burn_ledger(
db = tmp_path / "state.db" db = tmp_path / "state.db"
_make_repair_test_db(db) _make_repair_test_db(db)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
def successful_strategy(_scratch_path, report): def successful_strategy(_scratch_path, report):
@@ -544,8 +544,8 @@ def test_environmental_promotion_failures_do_not_burn_ledger(
report["strategy"] = "promotion_environment_test" report["strategy"] = "promotion_environment_test"
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", successful_strategy)
real_copy = hermes_state._copy_database_snapshot real_copy = hermes_state_repair._copy_database_snapshot
copy_calls = 0 copy_calls = 0
def fail_promotion_three_times(source, destination, **kwargs): def fail_promotion_three_times(source, destination, **kwargs):
@@ -556,17 +556,17 @@ def test_environmental_promotion_failures_do_not_burn_ledger(
return real_copy(source, destination, **kwargs) return real_copy(source, destination, **kwargs)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_copy_database_snapshot", fail_promotion_three_times hermes_state_repair, "_copy_database_snapshot", fail_promotion_three_times
) )
for _ in range(3): for _ in range(3):
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is False assert report["repaired"] is False
assert "could not be promoted" in report["error"] assert "could not be promoted" in report["error"]
ledger = hermes_state._read_repair_ledger(db) ledger = hermes_state_repair._read_repair_ledger(db)
assert ledger.get("failed_attempts", 0) == 0 assert ledger.get("failed_attempts", 0) == 0
monkeypatch.setattr(hermes_state, "_copy_database_snapshot", real_copy) monkeypatch.setattr(hermes_state_repair, "_copy_database_snapshot", real_copy)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is True assert report["repaired"] is True
assert report["strategy"] == "promotion_environment_test" assert report["strategy"] == "promotion_environment_test"
@@ -577,7 +577,7 @@ def test_corrupt_promotion_failure_consumes_one_attempt(tmp_path, monkeypatch):
db = tmp_path / "state.db" db = tmp_path / "state.db"
_make_repair_test_db(db) _make_repair_test_db(db)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
def successful_strategy(_scratch_path, report): def successful_strategy(_scratch_path, report):
@@ -585,8 +585,8 @@ def test_corrupt_promotion_failure_consumes_one_attempt(tmp_path, monkeypatch):
report["strategy"] = "corrupt-promotion-test" report["strategy"] = "corrupt-promotion-test"
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", successful_strategy)
real_copy = hermes_state._copy_database_snapshot real_copy = hermes_state_repair._copy_database_snapshot
calls = 0 calls = 0
def fail_promotion_with_corruption(source, destination, **kwargs): def fail_promotion_with_corruption(source, destination, **kwargs):
@@ -597,12 +597,12 @@ def test_corrupt_promotion_failure_consumes_one_attempt(tmp_path, monkeypatch):
return real_copy(source, destination, **kwargs) return real_copy(source, destination, **kwargs)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_copy_database_snapshot", fail_promotion_with_corruption hermes_state_repair, "_copy_database_snapshot", fail_promotion_with_corruption
) )
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is False assert report["repaired"] is False
assert hermes_state._read_repair_ledger(db)["failed_attempts"] == 1 assert hermes_state_repair._read_repair_ledger(db)["failed_attempts"] == 1
def test_snapshot_includes_committed_wal_frames(tmp_path): def test_snapshot_includes_committed_wal_frames(tmp_path):
@@ -617,7 +617,7 @@ def test_snapshot_includes_committed_wal_frames(tmp_path):
assert db.with_name(db.name + "-wal").exists() assert db.with_name(db.name + "-wal").exists()
scratch = tmp_path / "state.db.repair-scratch" scratch = tmp_path / "state.db.repair-scratch"
hermes_state._copy_database_snapshot(db, scratch) hermes_state_repair._copy_database_snapshot(db, scratch)
with sqlite3.connect(str(scratch)) as check: with sqlite3.connect(str(scratch)) as check:
assert check.execute("SELECT body FROM messages").fetchall() == [ assert check.execute("SELECT body FROM messages").fetchall() == [
("committed-only-in-wal",) ("committed-only-in-wal",)
@@ -646,7 +646,7 @@ def test_failed_wal_repair_preserves_committed_rows_semantically(
pytest.skip("SQLite/filesystem did not retain a hot WAL sidecar") pytest.skip("SQLite/filesystem did not retain a hot WAL sidecar")
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
strategy_calls = [] strategy_calls = []
@@ -657,7 +657,7 @@ def test_failed_wal_repair_preserves_committed_rows_semantically(
report["strategy"] = None report["strategy"] = None
return report return report
monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", failed_strategy)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
assert report["repaired"] is False assert report["repaired"] is False
assert strategy_calls == [True], "the test must exercise strategy failure" assert strategy_calls == [True], "the test must exercise strategy failure"
@@ -673,12 +673,12 @@ def test_snapshot_deadline_has_a_floor_and_scales_with_source_size(
tmp_path, monkeypatch tmp_path, monkeypatch
): ):
"""Large snapshots get more than the lock floor without huge fixtures.""" """Large snapshots get more than the lock floor without huge fixtures."""
deadline = getattr(hermes_state, "_repair_snapshot_timeout_seconds", None) deadline = getattr(hermes_state_repair, "_repair_snapshot_timeout_seconds", None)
assert callable(deadline), "repair snapshots need a size-scaled deadline" assert callable(deadline), "repair snapshots need a size-scaled deadline"
# Lower the throughput only for this arithmetic test so a 72 MiB fixture # Lower the throughput only for this arithmetic test so a 72 MiB fixture
# crosses the floor without allocating a multi-GB file. # crosses the floor without allocating a multi-GB file.
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND", 256 * 1024 hermes_state_repair, "_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND", 256 * 1024
) )
db = tmp_path / "state.db" db = tmp_path / "state.db"
@@ -711,7 +711,7 @@ def test_transactional_promotion_preserves_a_live_wal_reader(tmp_path):
reader.execute("BEGIN") reader.execute("BEGIN")
assert reader.execute("SELECT body FROM messages").fetchall() == [("old",)] assert reader.execute("SELECT body FROM messages").fetchall() == [("old",)]
hermes_state._copy_database_snapshot(scratch, live) hermes_state_repair._copy_database_snapshot(scratch, live)
assert reader.execute("SELECT body FROM messages").fetchall() == [("old",)] assert reader.execute("SELECT body FROM messages").fetchall() == [("old",)]
with sqlite3.connect(str(live)) as fresh: with sqlite3.connect(str(live)) as fresh:
@@ -742,7 +742,7 @@ def test_interrupted_snapshot_rolls_back_destination(tmp_path, monkeypatch):
monkeypatch.setattr(hermes_state.time, "monotonic", lambda: next(ticks)) monkeypatch.setattr(hermes_state.time, "monotonic", lambda: next(ticks))
with pytest.raises(TimeoutError): with pytest.raises(TimeoutError):
hermes_state._copy_database_snapshot(source, destination) hermes_state_repair._copy_database_snapshot(source, destination)
with sqlite3.connect(str(destination)) as conn: with sqlite3.connect(str(destination)) as conn:
assert conn.execute("SELECT value FROM marker").fetchall() == [("original",)] assert conn.execute("SELECT value FROM marker").fetchall() == [("original",)]
@@ -752,7 +752,7 @@ def test_failed_promotion_returns_failure_and_preserves_original(tmp_path, monke
db = tmp_path / "state.db" db = tmp_path / "state.db"
_write_populated_db(db) _write_populated_db(db)
before = hashlib.sha256(db.read_bytes()).hexdigest() before = hashlib.sha256(db.read_bytes()).hexdigest()
real_copy = hermes_state._copy_database_snapshot real_copy = hermes_state_repair._copy_database_snapshot
calls = 0 calls = 0
def fail_second_copy(source, destination, **kwargs): def fail_second_copy(source, destination, **kwargs):
@@ -768,10 +768,10 @@ def test_failed_promotion_returns_failure_and_preserves_original(tmp_path, monke
return report return report
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
monkeypatch.setattr(hermes_state, "_copy_database_snapshot", fail_second_copy) monkeypatch.setattr(hermes_state_repair, "_copy_database_snapshot", fail_second_copy)
monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies) monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", fake_strategies)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -785,12 +785,12 @@ def test_failed_promotion_returns_failure_and_preserves_original(tmp_path, monke
def test_scratch_space_guard_accounts_for_snapshot_and_vacuum(tmp_path, monkeypatch): def test_scratch_space_guard_accounts_for_snapshot_and_vacuum(tmp_path, monkeypatch):
db = tmp_path / "state.db" db = tmp_path / "state.db"
db.write_bytes(b"x" * 4096) db.write_bytes(b"x" * 4096)
headroom = hermes_state._repair_backup_headroom_bytes(10_000_000_000) headroom = hermes_state_repair._repair_backup_headroom_bytes(10_000_000_000)
free = (3 * db.stat().st_size) + headroom - 1 free = (3 * db.stat().st_size) + headroom - 1
usage = SimpleNamespace(total=10_000_000_000, free=free) usage = SimpleNamespace(total=10_000_000_000, free=free)
monkeypatch.setattr(shutil, "disk_usage", lambda _path: usage) monkeypatch.setattr(shutil, "disk_usage", lambda _path: usage)
error = hermes_state._repair_scratch_space_error(db) error = hermes_state_repair._repair_scratch_space_error(db)
assert error is not None assert error is not None
assert "VACUUM may need" in error assert "VACUUM may need" in error
@@ -808,7 +808,7 @@ def test_stale_scratch_is_removed_before_health_check(tmp_path, monkeypatch):
assert not scratch.exists() assert not scratch.exists()
return None return None
monkeypatch.setattr(hermes_state, "_db_opens_cleanly", fake_health) monkeypatch.setattr(hermes_state_repair, "_db_opens_cleanly", fake_health)
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -826,7 +826,7 @@ def test_stale_scratch_is_removed_before_space_check(tmp_path, monkeypatch):
checked_space = False checked_space = False
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
) )
def fake_space_check(_path): def fake_space_check(_path):
@@ -836,7 +836,7 @@ def test_stale_scratch_is_removed_before_space_check(tmp_path, monkeypatch):
return "forced low space" return "forced low space"
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_repair_scratch_space_error", fake_space_check hermes_state_repair, "_repair_scratch_space_error", fake_space_check
) )
report = repair_state_db_schema(db, backup=False) report = repair_state_db_schema(db, backup=False)
@@ -857,9 +857,9 @@ def test_stale_scratch_cleanup_failure_aborts_before_probe(tmp_path, monkeypatch
probed = True probed = True
return "forced-unhealthy" return "forced-unhealthy"
monkeypatch.setattr(hermes_state, "_db_opens_cleanly", fake_health) monkeypatch.setattr(hermes_state_repair, "_db_opens_cleanly", fake_health)
monkeypatch.setattr( monkeypatch.setattr(
hermes_state, "_unlink_db_triple", lambda _path: "scratch is locked" hermes_state_repair, "_unlink_db_triple", lambda _path: "scratch is locked"
) )
report = { report = {
"repaired": False, "repaired": False,
@@ -868,7 +868,7 @@ def test_stale_scratch_cleanup_failure_aborts_before_probe(tmp_path, monkeypatch
"error": None, "error": None,
} }
result = hermes_state._repair_state_db_schema_locked( result = hermes_state_repair._repair_state_db_schema_locked(
db, backup=False, report=report db, backup=False, report=report
) )
+4 -5
View File
@@ -16,10 +16,9 @@ import sys
import pytest import pytest
import hermes_state import hermes_state
from hermes_state import ( import hermes_state_wal
apply_database_pragmas, from hermes_state import apply_database_pragmas
resolve_synchronous_level, from hermes_state_wal import resolve_synchronous_level
)
def _wal_conn(tmp_path): def _wal_conn(tmp_path):
@@ -186,7 +185,7 @@ class TestMacOSFloor:
conn = _wal_conn(tmp_path) conn = _wal_conn(tmp_path)
try: try:
monkeypatch.setattr(sys, "platform", "darwin") monkeypatch.setattr(sys, "platform", "darwin")
hermes_state._enforce_macos_synchronous_full(conn) hermes_state_wal._enforce_macos_synchronous_full(conn)
assert _level(conn) == 2 assert _level(conn) == 2
with caplog.at_level("WARNING"): with caplog.at_level("WARNING"):
apply_database_pragmas(conn, db_label="state.db") apply_database_pragmas(conn, db_label="state.db")
+20 -16
View File
@@ -3936,7 +3936,7 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(launch_cwd)) monkeypatch.setenv("TERMINAL_CWD", str(launch_cwd))
monkeypatch.setattr(server, "_profile_home", lambda _profile: profile_home) monkeypatch.setattr(server, "_profile_home", lambda _profile: profile_home)
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda db_path=None: profile_db) monkeypatch.setattr("hermes_state_registry.acquire", lambda db_path=None: profile_db)
monkeypatch.setattr(server, "_get_db", lambda: launch_db) monkeypatch.setattr(server, "_get_db", lambda: launch_db)
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
monkeypatch.setattr(server, "_set_session_context", lambda target: []) monkeypatch.setattr(server, "_set_session_context", lambda target: [])
@@ -7895,7 +7895,7 @@ def test_ensure_session_db_row_stamps_profile_name(monkeypatch, tmp_path):
def close(self): def close(self):
pass pass
monkeypatch.setattr("hermes_state.get_shared_session_db", _ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", _ProfileDB)
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
server._ensure_session_db_row( server._ensure_session_db_row(
@@ -9792,7 +9792,7 @@ def test_config_set_model_recovers_failed_profile_resume_after_build_completes(
"hermes_cli.model_selection_guards.combined_selection_warning", "hermes_cli.model_selection_guards.combined_selection_warning",
lambda *args, **kwargs: None, lambda *args, **kwargs: None,
) )
monkeypatch.setattr("hermes_state.get_shared_session_db", FakeDb) monkeypatch.setattr("hermes_state_registry.acquire", FakeDb)
monkeypatch.setattr(server, "_make_agent", fake_make_agent) monkeypatch.setattr(server, "_make_agent", fake_make_agent)
monkeypatch.setattr(server, "_transfer_db_to_agent", barrier_transfer) monkeypatch.setattr(server, "_transfer_db_to_agent", barrier_transfer)
monkeypatch.setattr( monkeypatch.setattr(
@@ -14620,8 +14620,10 @@ def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch):
def _broken_shared(_db_path=None): def _broken_shared(_db_path=None):
raise RuntimeError("locking protocol") raise RuntimeError("locking protocol")
fake_mod.get_shared_session_db = _broken_shared
monkeypatch.setitem(sys.modules, "hermes_state", fake_mod) monkeypatch.setitem(sys.modules, "hermes_state", fake_mod)
fake_registry = types.ModuleType("hermes_state_registry")
fake_registry.acquire = _broken_shared
monkeypatch.setitem(sys.modules, "hermes_state_registry", fake_registry)
monkeypatch.setattr(server, "_db", None) monkeypatch.setattr(server, "_db", None)
monkeypatch.setattr(server, "_db_error", None) monkeypatch.setattr(server, "_db_error", None)
@@ -14643,8 +14645,10 @@ def test_ensure_session_db_row_false_when_store_unavailable(monkeypatch):
def _broken_shared(_db_path=None): def _broken_shared(_db_path=None):
raise RuntimeError("utf-8 boom") raise RuntimeError("utf-8 boom")
fake_mod.get_shared_session_db = _broken_shared
monkeypatch.setitem(sys.modules, "hermes_state", fake_mod) monkeypatch.setitem(sys.modules, "hermes_state", fake_mod)
fake_registry = types.ModuleType("hermes_state_registry")
fake_registry.acquire = _broken_shared
monkeypatch.setitem(sys.modules, "hermes_state_registry", fake_registry)
monkeypatch.setattr(server, "_db", None) monkeypatch.setattr(server, "_db", None)
monkeypatch.setattr(server, "_db_error", None) monkeypatch.setattr(server, "_db_error", None)
@@ -14996,7 +15000,7 @@ def test_session_list_honors_params_profile_opens_profile_db(monkeypatch, tmp_pa
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None) monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
resp = server.handle_request( resp = server.handle_request(
{ {
@@ -15037,7 +15041,7 @@ def test_session_most_recent_honors_params_profile(monkeypatch, tmp_path):
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None) monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB2) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB2)
resp = server.handle_request( resp = server.handle_request(
{ {
@@ -15164,7 +15168,7 @@ def test_session_delete_honors_params_profile_sessions_dir(monkeypatch, tmp_path
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None) monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
monkeypatch.setattr(server, "_get_db", lambda: None) monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
resp = server.handle_request( resp = server.handle_request(
{ {
@@ -15231,7 +15235,7 @@ def test_session_title_uses_session_profile_db_not_launch(monkeypatch, tmp_path)
"last_active": 1.0, "last_active": 1.0,
} }
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
try: try:
set_resp = server.handle_request( set_resp = server.handle_request(
{ {
@@ -15288,7 +15292,7 @@ def test_session_history_uses_session_profile_db(monkeypatch, tmp_path):
"last_active": 1.0, "last_active": 1.0,
} }
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
try: try:
resp = server.handle_request( resp = server.handle_request(
{"id": "1", "method": "session.history", "params": {"session_id": "sid"}} {"id": "1", "method": "session.history", "params": {"session_id": "sid"}}
@@ -15375,7 +15379,7 @@ def test_session_status_uses_session_profile_db(monkeypatch, tmp_path):
"last_active": 1.0, "last_active": 1.0,
} }
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
try: try:
resp = server.handle_request( resp = server.handle_request(
{"id": "1", "method": "session.status", "params": {"session_id": "sid"}} {"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
@@ -15417,7 +15421,7 @@ def test_teardown_ends_session_in_profile_db(monkeypatch, tmp_path):
seen["closed"] = True seen["closed"] = True
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
session = { session = {
"session_key": "ml-sess", "session_key": "ml-sess",
"profile_home": str(profile_home), "profile_home": str(profile_home),
@@ -15510,7 +15514,7 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path):
} }
server._sessions["parent"] = parent server._sessions["parent"] = parent
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None)) monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
def _fake_make_agent(*a, **k): def _fake_make_agent(*a, **k):
@@ -15932,7 +15936,7 @@ def test_session_branch_installs_parent_profile_secret_scope(monkeypatch, tmp_pa
} }
server._sessions["parent"] = parent server._sessions["parent"] = parent
monkeypatch.setattr(server, "_get_db", lambda: ProfileDB()) monkeypatch.setattr(server, "_get_db", lambda: ProfileDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None)) monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
def _fake_make_agent(*a, **k): def _fake_make_agent(*a, **k):
@@ -16049,7 +16053,7 @@ def test_session_branch_uses_persisted_display_history_after_compaction(monkeypa
} }
server._sessions["parent"] = parent server._sessions["parent"] = parent
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *args, **kwargs: (None, None)) monkeypatch.setattr(server, "_claim_active_session_slot", lambda *args, **kwargs: (None, None))
monkeypatch.setattr(server, "_make_agent", lambda *args, **kwargs: FakeAgent()) monkeypatch.setattr(server, "_make_agent", lambda *args, **kwargs: FakeAgent())
monkeypatch.setattr(server, "_set_session_context", lambda *args, **kwargs: {}) monkeypatch.setattr(server, "_set_session_context", lambda *args, **kwargs: {})
@@ -16109,7 +16113,7 @@ def test_pending_title_finalizer_uses_session_profile_db(monkeypatch, tmp_path):
seen["closed"] = True seen["closed"] = True
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
session = { session = {
"session_key": "ml-sess", "session_key": "ml-sess",
"pending_title": "deferred-title", "pending_title": "deferred-title",
+2 -2
View File
@@ -151,9 +151,9 @@ class TestBrowseShape:
return [] return []
db = _DB() db = _DB()
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda: db) monkeypatch.setattr("hermes_state_registry.acquire", lambda: db)
monkeypatch.setattr( monkeypatch.setattr(
"hermes_state.release_or_close", "hermes_state_registry.release_or_close",
lambda _: setattr(db, "released", db.released + 1), lambda _: setattr(db, "released", db.released + 1),
) )
@@ -55,7 +55,7 @@ def homes(monkeypatch, tmp_path):
homes = {name: tmp_path / name for name in ("a", "b")} homes = {name: tmp_path / name for name in ("a", "b")}
for home in homes.values(): for home in homes.values():
home.mkdir() home.mkdir()
monkeypatch.setattr("hermes_state.get_shared_session_db", _DB) monkeypatch.setattr("hermes_state_registry.acquire", _DB)
monkeypatch.setattr(server, "_get_db", lambda: _DB()) monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_profile_home", lambda p: homes.get(p) if p else None) monkeypatch.setattr(server, "_profile_home", lambda p: homes.get(p) if p else None)
monkeypatch.setattr(server, "_profile_configured_cwd", lambda _home: str(tmp_path)) monkeypatch.setattr(server, "_profile_configured_cwd", lambda _home: str(tmp_path))
@@ -178,7 +178,7 @@ def test_lazy_recall_open_is_owned_by_the_agent(monkeypatch):
opened.append(db) opened.append(db)
return db return db
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) monkeypatch.setattr("hermes_state_registry.acquire", _factory)
agent = _bare_agent(_session_db=None, _persist_disabled=False) agent = _bare_agent(_session_db=None, _persist_disabled=False)
got = agent._get_session_db_for_recall() got = agent._get_session_db_for_recall()
@@ -272,7 +272,7 @@ def build_env(monkeypatch, tmp_path):
opened.append(db) opened.append(db)
return db return db
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) monkeypatch.setattr("hermes_state_registry.acquire", _factory)
for name, value in [ for name, value in [
("_set_session_context", lambda _key: []), ("_set_session_context", lambda _key: []),
("_clear_session_context", lambda _tokens: None), ("_clear_session_context", lambda _tokens: None),

Some files were not shown because too many files have changed in this diff Show More