From 53db5972019b0401f6c9d460b6fc46188023ab15 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:46:50 -0700 Subject: [PATCH] =?UTF-8?q?simplify(compat):=20hermes=5Fstate=20=E2=80=94?= =?UTF-8?q?=20drop=2081=20re-exports=20+=203=20registry=20aliases=20+=203?= =?UTF-8?q?=20shims,=20repoint=2045=20callers=20+=2060=20test=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 34abf954bda: 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.). Frozen updater surface untouched (update_cmd_maint imports only SessionDB from hermes_state). --- agent/error_surface.py | 2 +- agent/session_persistence.py | 6 +- agent/verification_evidence.py | 2 +- cron/delivery_queue.py | 2 +- cron/executions.py | 2 +- cron/scheduler.py | 12 +- gateway/channel_directory.py | 4 +- gateway/delivery_ledger.py | 2 +- gateway/hosted_room_policy_checkpoint.py | 2 +- gateway/hosted_rooms_common.py | 2 +- gateway/mirror.py | 8 +- gateway/platforms/api_server.py | 2 +- .../platforms/api_server_run_idempotency.py | 2 +- gateway/run.py | 11 +- gateway/run_shutdown.py | 4 +- gateway/session_persistence.py | 7 +- gateway/session_transcript.py | 5 +- gateway/shutdown_flush.py | 6 +- gateway/slash_commands_status.py | 6 +- hermes_cli/console_engine.py | 3 +- hermes_cli/doctor_state.py | 6 +- hermes_cli/kanban_db_connect.py | 14 +-- hermes_cli/projects_db.py | 2 +- hermes_cli/session_recovery.py | 6 +- hermes_cli/web_routers/sessions.py | 3 +- hermes_state.py | 97 ++++----------- hermes_state_common.py | 6 +- hermes_state_compression.py | 2 +- hermes_state_dbfile.py | 12 +- hermes_state_gateway.py | 3 +- hermes_state_maintenance.py | 7 +- hermes_state_messages.py | 14 +-- hermes_state_registry.py | 6 - hermes_state_repair.py | 33 ++--- hermes_state_sessions.py | 15 --- hermes_state_titles.py | 4 - hermes_state_wal.py | 11 +- mcp_serve.py | 6 +- plugins/memory/holographic/store.py | 2 +- plugins/platforms/discord/recovery.py | 2 +- run_agent.py | 6 +- .../agent/test_compression_orphan_recovery.py | 3 +- tests/conftest.py | 10 +- tests/cron/test_cleanup_timeout.py | 4 +- tests/cron/test_cron_provider_pin.py | 6 +- tests/cron/test_cron_request_overrides.py | 2 +- tests/cron/test_scheduler.py | 28 ++--- .../test_scheduler_cron_session_isolation.py | 2 +- tests/cron/test_script_claim_heartbeat.py | 2 +- tests/cron/test_sessiondb_init_hang.py | 14 +-- tests/gateway/test_hosted_rooms.py | 8 +- tests/gateway/test_mirror.py | 4 +- tests/gateway/test_orphan_exit_grace.py | 4 +- tests/gateway/test_session.py | 2 +- tests/gateway/test_session_db_recovery.py | 5 +- tests/gateway/test_shutdown_flush.py | 4 +- tests/gateway/test_startup_watchdog.py | 4 +- tests/gateway/test_watchdog_review_76354.py | 12 +- tests/hermes_cli/test_kanban_db.py | 21 ++-- tests/hermes_cli/test_session_recovery.py | 3 +- .../test_sqlite3_cli_salvage_gate.py | 4 +- .../test_deleted_wal_generation_guard.py | 18 ++- .../hermes_state/test_isolation_marker_env.py | 8 +- .../test_live_db_guard_ancestry.py | 7 +- .../test_compression_closed_adoption.py | 12 +- .../run_agent/test_exit_cleanup_interrupt.py | 2 +- .../test_token_persistence_non_cli.py | 4 +- .../test_turn_completion_explainer.py | 15 +-- ...ompression_lease_refresh_before_publish.py | 3 +- tests/state/test_disk_full_error.py | 2 +- tests/state/test_fts_rebuild_admission.py | 14 ++- .../state/test_fts_trigram_cron_exclusion.py | 3 +- .../test_fts_trigram_subagent_exclusion.py | 3 +- .../test_session_git_metadata_generation.py | 3 +- tests/state/test_session_turn_lease.py | 3 +- tests/state/test_state_db_lock_fail_closed.py | 10 +- tests/state/test_state_db_wal_unlink_race.py | 21 ++-- tests/test_conftest_wal_gate.py | 2 +- tests/test_fts_cjk_bigram.py | 5 +- tests/test_guest_durability_barriers.py | 2 +- tests/test_hermes_state.py | 58 +++++---- tests/test_hermes_state_wal_fallback.py | 20 ++- tests/test_journal_mode_config.py | 37 +++--- tests/test_journal_mode_upgrade_warning.py | 40 +++--- tests/test_message_reactions.py | 6 +- tests/test_session_db_read_conn_pool.py | 5 +- tests/test_session_system_prompt_dedup.py | 3 +- tests/test_session_workspace_binding.py | 5 +- tests/test_sqlite_wal_reset_gate.py | 39 +++--- tests/test_state_db_malformed_repair.py | 44 +++---- tests/test_state_db_notadb_fail_closed.py | 3 +- .../test_state_db_repair_live_writer_guard.py | 23 ++-- tests/test_state_db_repair_loop_cap.py | 15 +-- tests/test_state_db_repair_loop_mtime.py | 16 +-- tests/test_state_db_repair_non_destructive.py | 114 +++++++++--------- tests/test_state_synchronous_pragma.py | 9 +- tests/test_tui_gateway_server.py | 36 +++--- tests/tools/test_session_search.py | 4 +- .../test_resume_live_profile_scope.py | 2 +- .../test_session_db_ownership_teardown.py | 4 +- .../test_session_resume_db_ownership.py | 16 +-- tools/async_delegation.py | 2 +- tools/delegate_tool.py | 6 +- tools/react_to_message_tool.py | 6 +- tools/session_search_tool.py | 5 +- tui_gateway/compute_host.py | 6 +- tui_gateway/methods_profiles.py | 7 +- tui_gateway/methods_prompt.py | 2 +- tui_gateway/methods_session.py | 8 +- tui_gateway/server.py | 12 +- tui_gateway/session_workdir.py | 8 +- 111 files changed, 558 insertions(+), 629 deletions(-) diff --git a/agent/error_surface.py b/agent/error_surface.py index 1c6532f708..089c649ced 100644 --- a/agent/error_surface.py +++ b/agent/error_surface.py @@ -82,7 +82,7 @@ def _surface(layer: str, code: str, retryable: bool, provider: str = "", model: def _disk_full(candidate: Any) -> bool: 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)) except Exception: # pragma: no cover - defensive import guard diff --git a/agent/session_persistence.py b/agent/session_persistence.py index 773406520a..544883d6a1 100644 --- a/agent/session_persistence.py +++ b/agent/session_persistence.py @@ -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 # 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. - from hermes_state import ( - CompressionSessionClosedError, StateDbCorruptError, StateDbReplacedError, classify_persistence_error, - divert_session_transcript_jsonl, - ) + from hermes_state import StateDbCorruptError, StateDbReplacedError, classify_persistence_error, divert_session_transcript_jsonl + from hermes_state_errors import CompressionSessionClosedError agent._last_persistence_error_cause = classify_persistence_error(e) if isinstance(e, (StateDbReplacedError, StateDbCorruptError)): # A replaced/quarantined handle will not take this batch again — keep it on disk. diff --git a/agent/verification_evidence.py b/agent/verification_evidence.py index 3ef2db03fd..468deb2a9e 100644 --- a/agent/verification_evidence.py +++ b/agent/verification_evidence.py @@ -112,7 +112,7 @@ def _db_path() -> Path: 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.parent.mkdir(parents=True, exist_ok=True) diff --git a/cron/delivery_queue.py b/cron/delivery_queue.py index 635a295b2f..689fd620d6 100644 --- a/cron/delivery_queue.py +++ b/cron/delivery_queue.py @@ -89,7 +89,7 @@ def _transaction() -> Iterator[sqlite3.Connection]: pass conn.row_factory = sqlite3.Row try: - from hermes_state import apply_wal_with_fallback + from hermes_state_wal import apply_wal_with_fallback conn.execute("PRAGMA busy_timeout=5000") apply_wal_with_fallback(conn, db_label="cron/deliveries.db") diff --git a/cron/executions.py b/cron/executions.py index e7577155e0..f905153379 100644 --- a/cron/executions.py +++ b/cron/executions.py @@ -44,7 +44,7 @@ def prepare_ledger( conn: sqlite3.Connection, *, db_label: str, synchronous_full: bool = True ) -> None: """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.execute("PRAGMA busy_timeout=5000") diff --git a/cron/scheduler.py b/cron/scheduler.py index ec71aadc02..9fcbdeea42 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -60,7 +60,7 @@ def _close_late_session_db_result(future: "concurrent.futures.Future") -> None: with contextlib.suppress(Exception): db = future.result() if db is not None: - from hermes_state import release_or_close + from hermes_state_registry import release_or_close 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. _session_db_timeout = _get_session_db_timeout() try: - from hermes_state import get_shared_session_db + from hermes_state_registry import acquire if _session_db_timeout <= 0: - return get_shared_session_db() + return acquire() _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 # instead of the process-global default. _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: return _session_db_future.result(timeout=_session_db_timeout) 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. _explainer_variants = [] try: - from hermes_state import PERSISTENCE_ERROR_CAUSES as _causes + from hermes_state_errors import PERSISTENCE_ERROR_CAUSES as _causes except Exception: _causes = ("locked", "disk", "unknown") 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: logger.debug("Job '%s': failed to end session: %s", job_id, e) try: - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(_session_db) except (Exception, KeyboardInterrupt) as e: logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e) diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 7688762786..67d397fc10 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -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]]: """Pull channels/contacts from state.db gateway session rows.""" def _origins() -> Iterable[Tuple[Dict[str, Any], Any]]: - from hermes_state import get_shared_session_db, release_or_close - db = get_shared_session_db() + from hermes_state_registry import acquire, release_or_close + db = acquire() try: lister = getattr(db, "list_gateway_sessions", None) if not callable(lister): diff --git a/gateway/delivery_ledger.py b/gateway/delivery_ledger.py index 95c35ef937..585290555e 100644 --- a/gateway/delivery_ledger.py +++ b/gateway/delivery_ledger.py @@ -62,7 +62,7 @@ def _connect() -> sqlite3.Connection: 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)") conn.execute( """CREATE TABLE IF NOT EXISTS delivery_obligations ( diff --git a/gateway/hosted_room_policy_checkpoint.py b/gateway/hosted_room_policy_checkpoint.py index 9def25f146..97d538395c 100644 --- a/gateway/hosted_room_policy_checkpoint.py +++ b/gateway/hosted_room_policy_checkpoint.py @@ -106,7 +106,7 @@ class HostedRoomPolicyCheckpoint: conn.execute(ddl) 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) conn = sqlite3.connect(self.db_path, timeout=10) conn.row_factory = sqlite3.Row diff --git a/gateway/hosted_rooms_common.py b/gateway/hosted_rooms_common.py index dde9b828aa..85c863b31f 100644 --- a/gateway/hosted_rooms_common.py +++ b/gateway/hosted_rooms_common.py @@ -113,7 +113,7 @@ def connect( from the journal-mode pragma is retried (it may ignore the busy timeout while another 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.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path, timeout=10) diff --git a/gateway/mirror.py b/gateway/mirror.py index bc11358073..6df2b905a4 100644 --- a/gateway/mirror.py +++ b/gateway/mirror.py @@ -74,8 +74,8 @@ def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = Non for pre-migration databases. """ try: - from hermes_state import get_shared_session_db, release_or_close - db = get_shared_session_db() + from hermes_state_registry import acquire, release_or_close + db = acquire() try: 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 @@ -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: """Append a message to the SQLite session database.""" 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: db.append_message(session_id=session_id, role=message.get("role", "assistant"), content=message.get("content")) finally: diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 5c26f35cfe..f241380f69 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -685,7 +685,7 @@ class ResponseStore: self._conn = sqlite3.connect(":memory:", check_same_thread=False) self._db_path = None # 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") self._conn.execute( "CREATE TABLE IF NOT EXISTS responses (" diff --git a/gateway/platforms/api_server_run_idempotency.py b/gateway/platforms/api_server_run_idempotency.py index 232714131a..22e9f580f1 100644 --- a/gateway/platforms/api_server_run_idempotency.py +++ b/gateway/platforms/api_server_run_idempotency.py @@ -80,7 +80,7 @@ class RunIdempotencyStore: "process memory, so replay will not survive a restart: %s", exc) self._conn = sqlite3.connect(":memory:", check_same_thread=False) 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") self._conn.execute( """CREATE TABLE IF NOT EXISTS run_idempotency ( diff --git a/gateway/run.py b/gateway/run.py index ed3a9320ca..8f93ea28de 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3629,7 +3629,8 @@ class GatewayRunner( after recording that recoverable state so ``__init__`` can record ``_session_db_init_error`` for the #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 path = Path(_default_db_path()) 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. raise RuntimeError("SessionStore SQLite handle unavailable") try: - return AsyncSessionDB(get_shared_session_db()) + return AsyncSessionDB(acquire()) except Exception as exc: logger.warning("SQLite session store not available: %s", exc) raise @@ -3698,7 +3699,7 @@ class GatewayRunner( return # Shared instances no-op on close() (the registry owns the lifecycle). Release the refcount # instead (#90837). - from hermes_state import release_or_close + from hermes_state_registry import release_or_close try: release_or_close(inner) 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() 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_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 {}) if _sess_cfg.get("auto_archive", False): - _adb = get_shared_session_db() + _adb = acquire() try: _adb.maybe_auto_archive( idle_days=float(_sess_cfg.get("auto_archive_days", 3)), diff --git a/gateway/run_shutdown.py b/gateway/run_shutdown.py index 1e83f0b7cc..e691a87032 100644 --- a/gateway/run_shutdown.py +++ b/gateway/run_shutdown.py @@ -1656,8 +1656,8 @@ class GatewayShutdownMixin: # 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 # (#90837). - from hermes_state import close_shared_session_dbs - closed = close_shared_session_dbs() + from hermes_state_registry import close_all + closed = close_all() if closed: logger.debug("Closed %d shared SessionDB instance(s) at shutdown", closed) diff --git a/gateway/session_persistence.py b/gateway/session_persistence.py index 3a2b829e4e..95c0ef5757 100644 --- a/gateway/session_persistence.py +++ b/gateway/session_persistence.py @@ -51,13 +51,14 @@ class SessionPersistenceMixin: 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. """ - 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()) def _open(): 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: if not _is_live_system_guard(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 restart). Drained under the lock, closed outside it; a pinned handle is the pinner's.""" 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: release_or_close(db) except Exception as exc: diff --git a/gateway/session_transcript.py b/gateway/session_transcript.py index c36e5856b6..ff9e795d01 100644 --- a/gateway/session_transcript.py +++ b/gateway/session_transcript.py @@ -243,9 +243,8 @@ class SessionTranscriptMixin: try: self._append_transcript_message(session_id, msg) except Exception as exc: - from hermes_state import ( - CompressionSessionClosedError, StateDbCorruptError, StateDbReplacedError, - ) + from hermes_state import StateDbCorruptError, StateDbReplacedError + from hermes_state_errors import CompressionSessionClosedError if isinstance(exc, (StateDbReplacedError, StateDbCorruptError)): self._divert_transcript_after_db_replaced(session_id, queue_session_id, exc) return diff --git a/gateway/shutdown_flush.py b/gateway/shutdown_flush.py index 960463c23a..7c9d141a2a 100644 --- a/gateway/shutdown_flush.py +++ b/gateway/shutdown_flush.py @@ -209,8 +209,8 @@ def recover_pending_to_db(session_db=None) -> int: return 0 own_db = session_db is None if own_db: - from hermes_state import get_shared_session_db - session_db = get_shared_session_db() + from hermes_state_registry import acquire + session_db = acquire() recovered = 0 try: for path in flush_files: @@ -224,7 +224,7 @@ def recover_pending_to_db(session_db=None) -> int: finally: if own_db: # shutdown cancellation/interrupt must not strand an owned DB with contextlib.suppress(Exception): - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(session_db) if recovered: logger.info("Recovered %d pending message(s) from shutdown flush", recovered) diff --git a/gateway/slash_commands_status.py b/gateway/slash_commands_status.py index 81a9eeebdc..ab21e5df6f 100644 --- a/gateway/slash_commands_status.py +++ b/gateway/slash_commands_status.py @@ -612,16 +612,16 @@ class GatewayStatusCommandsMixin: days = int(flag) if flag.isdigit() else days i += 1 try: - from hermes_state import get_shared_session_db + from hermes_state_registry import acquire from agent.insights import InsightsEngine def _run_insights(): - db = get_shared_session_db() + db = acquire() try: engine = InsightsEngine(db) return engine.format_gateway(engine.generate(days=days, source=source)) finally: - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(db) # Not a bare hop: ``SessionDB()`` resolves ``get_hermes_home()`` at call time, a diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 66dc711bcc..30ae31e606 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -709,7 +709,8 @@ def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> None: ns = _parse( "sessions repair", args, (("--check-only",), 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 if not db_path.exists(): print(f"No session database at {db_path} (nothing to repair).") diff --git a/hermes_cli/doctor_state.py b/hermes_cli/doctor_state.py index 200ad5e0f8..940a545327 100644 --- a/hermes_cli/doctor_state.py +++ b/hermes_cli/doctor_state.py @@ -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] if not should_fix: 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) if not report.get("repaired"): 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)") # 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. - 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 # surfaced (and repaired in place with --fix). See #50502. _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 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)): - 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)) for _kind, _text, _detail in rows: if _kind != "warn": diff --git a/hermes_cli/kanban_db_connect.py b/hermes_cli/kanban_db_connect.py index 048d534272..5162d672e7 100644 --- a/hermes_cli/kanban_db_connect.py +++ b/hermes_cli/kanban_db_connect.py @@ -231,12 +231,12 @@ def _maybe_checkpoint_wal(conn: sqlite3.Connection, db_path: Path) -> None: key = str(db_path) now = time.monotonic() 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: return # Claim the slot first so concurrent same-process ticks don't # double-checkpoint on the boundary. - _kb._LAST_WAL_CHECKPOINT[key] = now + _LAST_WAL_CHECKPOINT[key] = now try: row = conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone() _kb._log.debug( @@ -582,7 +582,7 @@ def repair_db(db_path: Optional[Path] = None, *, board: Optional[str] = None) -> if _missing_or_empty(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) if messages is None: # 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 with _INIT_LOCK: # WAL doesn't work on network filesystems; the helper falls back to - # DELETE with one ERROR log (see hermes_state._WAL_INCOMPAT_MARKERS). - from hermes_state import apply_wal_with_fallback + # DELETE with one ERROR log (see hermes_state_wal._WAL_INCOMPAT_MARKERS). + from hermes_state_wal import apply_wal_with_fallback apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})") # FULL (not NORMAL): fsync before each checkpoint to narrow the # 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, ) - 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 # fails actionably instead of "attempt to write a readonly database". # 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 -# 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 diff --git a/hermes_cli/projects_db.py b/hermes_cli/projects_db.py index 91019cd07b..8feb802fc2 100644 --- a/hermes_cli/projects_db.py +++ b/hermes_cli/projects_db.py @@ -123,7 +123,7 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: conn = sqlite3.connect(str(path)) try: 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") conn.execute("PRAGMA foreign_keys=ON") diff --git a/hermes_cli/session_recovery.py b/hermes_cli/session_recovery.py index db92ba3a9b..7b09f11901 100644 --- a/hermes_cli/session_recovery.py +++ b/hermes_cli/session_recovery.py @@ -16,7 +16,9 @@ from contextlib import contextmanager from pathlib import Path 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] @@ -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 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 that window, and the copy's ``close()`` would cancel its POSIX advisory locks -- the failure class diff --git a/hermes_cli/web_routers/sessions.py b/hermes_cli/web_routers/sessions.py index 4939201f64..0b5a4e2105 100644 --- a/hermes_cli/web_routers/sessions.py +++ b/hermes_cli/web_routers/sessions.py @@ -21,7 +21,8 @@ from hermes_cli.web_deps import late from hermes_cli.web_models import ( BulkDeleteSessions, SessionImport, SessionOwnerBackfill, SessionPrune, SessionRename) 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() search_router = APIRouter() diff --git a/hermes_state.py b/hermes_state.py index b9215f561b..d4bf1a6a90 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -24,73 +24,35 @@ from contextlib import contextmanager from pathlib import Path 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 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) - AUTO_VACUUM_MIN_FREELIST_RATIO, _FTS_TRIGGERS, escape_like as _escape_like, FTS_CJK_STALE_KEY, - FTS_REBUILD_DEFERRAL_KEY, FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, FTS_TRIGRAM_SQL, LEGACY_FTS_SQL, - LEGACY_FTS_TRIGRAM_SQL, SCHEMA_SQL, SCHEMA_VERSION, stat_db_file_identity as _stat_db_file_identity, +from hermes_state_common import escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity +from hermes_state_errors import ( + _DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY, + _STATE_DB_REPLACED_MSG, DeletedWalGenerationError, SessionCompressionInProgressError, StateDbCorruptError, + 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) - _DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_APPLICATION_ID_OFFSET, - _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY, _STATE_DB_REPLACED_MSG, PERSISTENCE_ERROR_CAUSES, - 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 ( + _STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _real_platform_state_root, + _set_last_init_error, get_last_init_error, ) -from hermes_state_guard import ( # noqa: F401 (re-exported; tests patch hermes_state.) - _STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _process_looks_like_pytest, - _real_platform_state_root, _running_under_pytest, _set_last_init_error, get_last_init_error, -) -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_readpool import _READ_POOL_MAX, _proc_fd_targets, _read_budget_for +from hermes_state_sessions import SessionSessionsMixin +from hermes_state_fts import SessionFtsSetupMixin, load_fts5_cjk_extension from hermes_state_portability import SessionPortabilityMixin from hermes_state_telegram import SessionTelegramTopicsMixin from hermes_state_schema import SessionSchemaMixin import hermes_state_holders as _state_holders -from hermes_state_dbfile import ( # noqa: F401 (re-exported; tests patch hermes_state.) - _canonical_sqlite_path, _concrete_state_db_holder_pids, _connect_tracked_db, - _is_inactive_orphan_desktop_holder, _read_sqlite_application_id, _stat_sqlite_sidecar_identity, _watched_sqlite_sidecar_paths, - collect_state_db_stats, count_db_holders, is_zeroed_state_db, iter_deleted_sqlite_sidecar_holders, - quarantine_cross_process_lock, quarantine_zeroed_state_db, refuse_deleted_wal_generation, +from hermes_state_dbfile import ( + _canonical_sqlite_path, _connect_tracked_db, _read_sqlite_application_id, _stat_sqlite_sidecar_identity, + _watched_sqlite_sidecar_paths, is_zeroed_state_db, quarantine_cross_process_lock, quarantine_zeroed_state_db, + refuse_deleted_wal_generation, ) from hermes_state_messages import SessionMessagesMixin -from hermes_state_wal import ( # noqa: F401 (re-exported; tests patch hermes_state.) - WalUnsupportedError, _WAL_INCOMPAT_MARKERS, _apply_macos_checkpoint_barrier, _apply_synchronous_pragma, - _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.) - _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_wal import _WAL_INCOMPAT_MARKERS, apply_database_pragmas, apply_wal_with_fallback +from hermes_state_repair import _claim_repair_attempt, preflight_db_writability, repair_state_db_schema from hermes_state_titles import SessionTitlesMixin from hermes_state_usage import SessionUsageMixin 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 -# 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]]: """Compatibility delegate to the state-holder authority.""" return _state_holders.foreign_state_db_holders(db_path) -# ── Process-wide shared SessionDB registry (#90837) ── The registry itself lives in -# hermes_state_registry.py — a bounded module owning acquisition, generation identity, refcounting, -# retirement, and teardown. These re-exports keep the historical import path (``from hermes_state import -# get_shared_session_db``) working for every call site and test that imports from here. Routing rules (see -# 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, -) +# ── Process-wide shared SessionDB registry (#90837) ── lives in hermes_state_registry.py (acquire / +# release / close_all / release_or_close). Long-lived in-process callers (gateway, tui_gateway, cron, +# in-process tools) share ONE writer connection per resolved path via hermes_state_registry.acquire(); CLI +# one-shots, recovery flows, and read-only cross-profile opens use SessionDB() directly with their own close(). class SessionDB( @@ -509,8 +464,8 @@ class SessionDB( self._token_writer_thread: Optional[threading.Thread] = None self._token_writer_stop = self._token_writer_busy = False self._token_atexit_hook: Optional[Callable[[], None]] = None - # Opened via get_shared_session_db(): close() releases a refcount instead. - # Set True when this instance is opened via get_shared_session_db(). Makes close() a no-op so the + # Opened via hermes_state_registry.acquire(): close() releases a refcount instead. + # 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). self._shared_registry_owned = False initialization_complete = False @@ -1174,7 +1129,7 @@ class SessionDB( Drains queued token deltas first (the background writer needs the connection). Read-only connections 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 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 diff --git a/hermes_state_common.py b/hermes_state_common.py index 97f72bba68..3868fb1979 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -1,5 +1,5 @@ """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 errno @@ -817,7 +817,7 @@ END; # 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()`, # `_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 # only when provably dead, indeterminate liveness defers. `.fts_rebuild.lock` is distinct from # `.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._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 -# 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 # 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 diff --git a/hermes_state_compression.py b/hermes_state_compression.py index dc696ca19e..dd5dd1b12a 100644 --- a/hermes_state_compression.py +++ b/hermes_state_compression.py @@ -198,7 +198,7 @@ class SessionCompressionMixin: See #75316. ``None`` = unbounded (no internal flush happened). See #47202. """ - from hermes_state import CompressionSessionBusyError + from hermes_state_errors import CompressionSessionBusyError def _do(conn): if require_lease_refresh and compression_lock_holder: conn.execute( diff --git a/hermes_state_dbfile.py b/hermes_state_dbfile.py index b2787d9872..5e10dd4f17 100644 --- a/hermes_state_dbfile.py +++ b/hermes_state_dbfile.py @@ -2,10 +2,9 @@ Header probes (application_id / zeroed-file detection), deleted-WAL-sidecar holder scans, quarantine of zeroed databases, ``collect_state_db_stats`` and -holder-process classification. Every name is re-imported into ``hermes_state`` -so ``hermes_state.`` keeps resolving — and tests that monkeypatch it keep -intercepting, because intra-module calls to patched helpers go through a lazy -``from hermes_state import ...`` at call time. +holder-process classification. Helpers that hermes_state itself imports and +calls (``_connect_tracked_db`` & co) are looked up lazily from ``hermes_state`` at +call time, so tests that monkeypatch ``hermes_state.`` keep intercepting. """ 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]": """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 header = _pread_db_header(db_path, end) 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: """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.""" - 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): return logger.error(_DELETED_WAL_GENERATION_MSG) diff --git a/hermes_state_gateway.py b/hermes_state_gateway.py index 1b381063a6..27f330badf 100644 --- a/hermes_state_gateway.py +++ b/hermes_state_gateway.py @@ -147,7 +147,8 @@ class SessionGatewayMixin: Fails closed: anything whose parent, age, argv, or network connections 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: return [] try: diff --git a/hermes_state_maintenance.py b/hermes_state_maintenance.py index 4cd202744d..90735feaf4 100644 --- a/hermes_state_maintenance.py +++ b/hermes_state_maintenance.py @@ -28,7 +28,7 @@ def _like(value: str) -> str: 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) return [clause], list(params) @@ -107,7 +107,8 @@ class SessionMaintenanceMixin: def _write_guards_reject(self, conn, sid: str, **kwargs) -> bool: """True when a live turn lease / compression lock protects ``sid``; expired or 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: self._check_transcript_write_guards( 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). 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} maintenance_lock = _try_acquire_auto_maintenance_lock(self.db_path) if maintenance_lock is None: diff --git a/hermes_state_messages.py b/hermes_state_messages.py index ef8f2f529a..9f09886b5f 100644 --- a/hermes_state_messages.py +++ b/hermes_state_messages.py @@ -189,7 +189,8 @@ class SessionMessagesMixin: #74478 patience note below). User-initiated transcript mutations may opt in to rejecting an active 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 # 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 @@ -410,15 +411,6 @@ class SessionMessagesMixin: (session_id, role, int(offset))) 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]: """Role of the active message at *row_id* in *session_id*, or ``None``.""" 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 either way; only the durability of the dropped turns differs. """ - from hermes_state import CompressionSessionClosedError + from hermes_state_errors import CompressionSessionClosedError def _do(conn): if reject_active_turn_lease: self._check_transcript_write_guards( diff --git a/hermes_state_registry.py b/hermes_state_registry.py index 51cc6ef8cf..6f8d694f58 100644 --- a/hermes_state_registry.py +++ b/hermes_state_registry.py @@ -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: """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.""" diff --git a/hermes_state_repair.py b/hermes_state_repair.py index f84a1db981..50cc8890b0 100644 --- a/hermes_state_repair.py +++ b/hermes_state_repair.py @@ -1,7 +1,6 @@ """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 -``from hermes_state import ...`` at call time so monkeypatches there still intercept. +Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_repair.``. """ 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: """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.""" - 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 for candidate in (source_path, *_sidecars(source_path)): 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): """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. - return contextlib.closing(_connect(db_path, timeout=timeout)) + return contextlib.closing(_connect_repair_durable(db_path, timeout=timeout)) def _reapply_durability_barriers(conn: sqlite3.Connection) -> bool: """Best-effort (re)application of the macOS write barriers; True if accepted. Call before ``VACUUM``/``REINDEX`` once the schema parses: a connection opened 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: _apply_macos_checkpoint_barrier(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 applies the configured ``database.synchronous`` level, a per-connection pragma that otherwise only 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) with contextlib.suppress(Exception): 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: """Zero-timeout connection holding ``locking_mode=EXCLUSIVE`` after a rolled-back *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. - conn = _connect(db_path, timeout=0.0) + conn = _connect_repair_durable(db_path, timeout=0.0) try: for statement in ("PRAGMA locking_mode=EXCLUSIVE", begin, "ROLLBACK"): 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 # 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. - from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state. - conn = _connect(db_path) + conn = _connect_repair_durable(db_path) try: with contextlib.closing(conn): # 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 (``hermes_state_holders``) fails closed on deleted-WAL-generation, uninspectable, or unknown holders.""" import hermes_state_holders as _state_holders - from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state. - return _state_holders.live_writer_holds_db(db_path, connect_repair_durable=_connect) + return _state_holders.live_writer_holds_db(db_path, connect_repair_durable=_connect_repair_durable) 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. """ - 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} # 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. @@ -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 probed (malformed header, concurrent opener's locks — both expected on the repair path); callers then fall back to ``database.journal_mode``.""" - from hermes_state import _on_disk_journal_mode + from hermes_state_wal import _on_disk_journal_mode try: with _repair_conn(db_path) as 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 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: if conn is None: 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 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") 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}") @@ -1074,7 +1063,7 @@ _REPAIR_STRATEGIES = ( 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, 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: try: with _repair_conn(db_path) as conn: diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index ff27a0e244..a7039236bc 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -601,21 +601,6 @@ class SessionSessionsMixin: ("", 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( self, session_id: str, model_config_json: str, model: Optional[str] = None, ) -> None: diff --git a/hermes_state_titles.py b/hermes_state_titles.py index 5925593dba..1305a2a22c 100644 --- a/hermes_state_titles.py +++ b/hermes_state_titles.py @@ -127,10 +127,6 @@ class SessionTitlesMixin: raise ValueError(f"invalid automatic title source: {source!r}") 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]: """Get the title for a session, or None.""" row = self._read_one("SELECT title FROM sessions WHERE id = ?", (session_id,)) diff --git a/hermes_state_wal.py b/hermes_state_wal.py index 12fc2c55c2..414722cbb8 100644 --- a/hermes_state_wal.py +++ b/hermes_state_wal.py @@ -1,7 +1,6 @@ """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 -``from hermes_state import ...`` at call time so monkeypatches there still intercept. +Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_wal.``. """ 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 # 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.``; ``_log_once`` resolves them there. +# line would repeat per connection). Tests clear these via ``hermes_state_wal.``. _wal_fallback_warned_paths: set[str] = set() _wal_fallback_warned_lock = threading.Lock() _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: """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 — 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 @@ -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 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() # 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: """Emit ``_ONCE_LOGS[kind]`` once per (process, db_label). Callable *args* are resolved only after the dedupe check, so install-method probes run once.""" - import hermes_state lock, set_name, level, message = _ONCE_LOGS[kind] - seen = getattr(hermes_state, set_name) + seen = globals()[set_name] with lock: if db_label in seen: return diff --git a/mcp_serve.py b/mcp_serve.py index 6209fd7601..c99b18be45 100644 --- a/mcp_serve.py +++ b/mcp_serve.py @@ -78,8 +78,8 @@ def _close_quietly(db, what: str) -> None: def _get_session_db(): """SessionDB instance for reading message transcripts, or None.""" try: - from hermes_state import get_shared_session_db - return get_shared_session_db() + from hermes_state_registry import acquire + return acquire() except Exception as e: logger.debug("SessionDB unavailable: %s", e) return None @@ -96,7 +96,7 @@ def _load_session_messages(session_id: str): return None, f"Failed to read messages: {e}" finally: try: - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(db) except Exception: logger.debug("Failed to close MCP SessionDB", exc_info=True) diff --git a/plugins/memory/holographic/store.py b/plugins/memory/holographic/store.py index fa236e80dd..600e991591 100644 --- a/plugins/memory/holographic/store.py +++ b/plugins/memory/holographic/store.py @@ -125,7 +125,7 @@ class MemoryStore: 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.""" - 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)") self._conn.executescript(_SCHEMA) if "hrr_vector" not in {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}: diff --git a/plugins/platforms/discord/recovery.py b/plugins/platforms/discord/recovery.py index 6c005b4eed..e052cfd7e6 100644 --- a/plugins/platforms/discord/recovery.py +++ b/plugins/platforms/discord/recovery.py @@ -53,7 +53,7 @@ class DiscordRecoveryStore: return default 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") conn.executescript(""" CREATE TABLE IF NOT EXISTS discord_messages ( diff --git a/run_agent.py b/run_agent.py index 76ac04e091..63a76014cd 100644 --- a/run_agent.py +++ b/run_agent.py @@ -289,9 +289,9 @@ class AIAgent( if self._session_db is not None: return self._session_db 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 return self._session_db except Exception: @@ -998,7 +998,7 @@ class AIAgent( self._owns_session_db = False # Shared instances no-op on close(); release the refcount so the registry closes on the last caller. # See #90837. - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(session_db) def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None: diff --git a/tests/agent/test_compression_orphan_recovery.py b/tests/agent/test_compression_orphan_recovery.py index 8770c6c7be..963f3dbf9b 100644 --- a/tests/agent/test_compression_orphan_recovery.py +++ b/tests/agent/test_compression_orphan_recovery.py @@ -3,7 +3,8 @@ from types import SimpleNamespace 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): diff --git a/tests/conftest.py b/tests/conftest.py index fb62a748c2..94d4ce5239 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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. # HERMES_TEST_ISOLATION is OUR marker: exported here (before any test module # 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. # 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 @@ -647,7 +647,7 @@ def _neutralize_macos_keychain_creds(request, monkeypatch): # ── Kanban write guard (#69283) ───────────────────────────────────────────── # When hermetic isolation is bypassed (stale checkout, wrong rootdir, direct # 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 # fixture rewires the environment). A deny-list is used instead of an # 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). # A half-imported module has no callers yet either — nothing to guard # this round; the next test's fixture will patch the completed module. - _orig_connect = getattr(_kdb, "connect", None) - if _orig_connect is None: + _orig_connect = getattr(_kdbc, "connect", None) + if _orig_connect is None or getattr(_kdb, "kanban_db_path", None) is None: return 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." ) - monkeypatch.setattr(_kdb, "connect", _guarded_connect) + monkeypatch.setattr(_kdbc, "connect", _guarded_connect) # ── Live state.db write guard ─────────────────────────────────────────────── diff --git a/tests/cron/test_cleanup_timeout.py b/tests/cron/test_cleanup_timeout.py index 3957fe7bc7..608abe820e 100644 --- a/tests/cron/test_cleanup_timeout.py +++ b/tests/cron/test_cleanup_timeout.py @@ -58,7 +58,7 @@ def test_run_job_bounds_sessiondb_finalization(tmp_path): patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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("run_agent.AIAgent") as mock_agent_cls, \ 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("hermes_cli.env_loader.load_hermes_dotenv"), \ 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("run_agent.AIAgent") as mock_agent_cls, \ patch("cron.scheduler._cron_cleanup_timeout_seconds", return_value=0.02), \ diff --git a/tests/cron/test_cron_provider_pin.py b/tests/cron/test_cron_provider_pin.py index 0ea915fb7a..1e014fe85f 100644 --- a/tests/cron/test_cron_provider_pin.py +++ b/tests/cron/test_cron_provider_pin.py @@ -54,7 +54,7 @@ def _run_with_current_provider(job, current_provider, tmp_path): patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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={ @@ -259,7 +259,7 @@ def _run_with_current_provider_and_model( patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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={ @@ -423,7 +423,7 @@ class TestRuntimeResolutionTargetModel: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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", side_effect=_capture, diff --git a/tests/cron/test_cron_request_overrides.py b/tests/cron/test_cron_request_overrides.py index 4ac934cd14..6cf8d34d5c 100644 --- a/tests/cron/test_cron_request_overrides.py +++ b/tests/cron/test_cron_request_overrides.py @@ -35,7 +35,7 @@ class TestRunJobRequestOverrides: with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ 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( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 0c5127149f..1cbb2870eb 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -553,7 +553,7 @@ class TestRunJobSessionPersistence: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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={ @@ -611,7 +611,7 @@ class TestRunJobSessionPersistence: patch("cron.scheduler_delivery._resolve_origin", return_value=None), patch("hermes_cli.env_loader.load_hermes_dotenv"), 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={ @@ -753,7 +753,7 @@ class TestRunJobSessionPersistence: with patch("cron.scheduler._hermes_home", tmp_path), \ 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( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ @@ -813,7 +813,7 @@ class TestRunJobSessionPersistence: with patch("cron.scheduler._hermes_home", tmp_path), \ 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( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ @@ -874,7 +874,7 @@ class TestRunJobSessionPersistence: with patch("cron.scheduler._hermes_home", tmp_path), \ 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( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ @@ -924,7 +924,7 @@ class TestRunJobSessionPersistence: 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.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( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ @@ -985,7 +985,7 @@ class TestRunJobSessionPersistence: with patch("cron.scheduler._hermes_home", tmp_path), \ 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( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ @@ -1083,7 +1083,7 @@ class TestRunJobConfigEnvVarExpansion: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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=self._RUNTIME), \ patch("run_agent.AIAgent") as mock_agent_cls: @@ -1233,7 +1233,7 @@ class TestRunJobConfigEnvVarExpansion: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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=self._RUNTIME), \ patch("run_agent.AIAgent") as mock_agent_cls: @@ -1278,7 +1278,7 @@ class TestRunJobModelResolution: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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=self._RUNTIME), \ patch("run_agent.AIAgent") as mock_agent_cls: @@ -1304,7 +1304,7 @@ class TestRunJobModelResolution: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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=self._RUNTIME), \ patch("run_agent.AIAgent") as mock_agent_cls: @@ -1336,7 +1336,7 @@ class TestRunJobModelResolution: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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=self._RUNTIME), \ patch("run_agent.AIAgent") as mock_agent_cls: @@ -1361,7 +1361,7 @@ class TestRunJobModelResolution: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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=self._RUNTIME), \ patch("run_agent.AIAgent") as mock_agent_cls: @@ -1406,7 +1406,7 @@ class TestRunJobSkillBacked: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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={ diff --git a/tests/cron/test_scheduler_cron_session_isolation.py b/tests/cron/test_scheduler_cron_session_isolation.py index adf00e04d2..5ba43caa14 100644 --- a/tests/cron/test_scheduler_cron_session_isolation.py +++ b/tests/cron/test_scheduler_cron_session_isolation.py @@ -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, "_get_approval_mode", lambda: "manual") 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( "hermes_constants.resolve_reasoning_config", lambda *_args, **_kwargs: None diff --git a/tests/cron/test_script_claim_heartbeat.py b/tests/cron/test_script_claim_heartbeat.py index dcea36c7b5..4cde43b381 100644 --- a/tests/cron/test_script_claim_heartbeat.py +++ b/tests/cron/test_script_claim_heartbeat.py @@ -250,7 +250,7 @@ def test_long_running_script_refreshes_owned_claim_in_profile_store( with ( 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) profile_claim = jobs.get_job("long-script")["run_claim"] diff --git a/tests/cron/test_sessiondb_init_hang.py b/tests/cron/test_sessiondb_init_hang.py index 806707f337..b2f0f46465 100644 --- a/tests/cron/test_sessiondb_init_hang.py +++ b/tests/cron/test_sessiondb_init_hang.py @@ -102,7 +102,7 @@ class TestSessionDbInitTimeout: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME, @@ -131,7 +131,7 @@ class TestSessionDbInitTimeout: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \ - patch("hermes_state.get_shared_session_db"), \ + patch("hermes_state_registry.acquire"), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME, @@ -166,7 +166,7 @@ class TestSessionDbInitTimeout: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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, @@ -209,7 +209,7 @@ class TestSessionDbInitTimeout: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \ - patch("hermes_state.get_shared_session_db"), \ + patch("hermes_state_registry.acquire"), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME, @@ -259,7 +259,7 @@ class TestDispatchGuardReleasedAfterHang: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ patch("hermes_cli.env_loader.reset_secret_source_cache"), \ - patch("hermes_state.get_shared_session_db"), \ + patch("hermes_state_registry.acquire"), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME, @@ -357,7 +357,7 @@ class TestLateSessionDbClosedAfterTimeout: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ @@ -415,7 +415,7 @@ class TestSessionDbInitAfterEarlyReturns: patch("cron.scheduler_delivery._resolve_origin", return_value=None), \ patch("hermes_cli.env_loader.load_hermes_dotenv"), \ 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( "cron.scheduler._run_job_script_with_claim_heartbeat", return_value=(True, '{"wakeAgent": false}'), diff --git a/tests/gateway/test_hosted_rooms.py b/tests/gateway/test_hosted_rooms.py index 1c252a8b1b..65c2272423 100644 --- a/tests/gateway/test_hosted_rooms.py +++ b/tests/gateway/test_hosted_rooms.py @@ -11,6 +11,7 @@ import pytest from gateway import hosted_room_driver as driver from gateway import hosted_rooms as rooms import hermes_state +import hermes_state_wal from gateway.hosted_room_policy_checkpoint import HostedRoomPolicyCheckpoint from hermes_state import SessionDB @@ -167,7 +168,7 @@ def test_first_database_open_retries_only_transient_journal_lock( tmp_path, monkeypatch, ): - original = hermes_state.apply_wal_with_fallback + original = hermes_state_wal.apply_wal_with_fallback attempts = 0 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") 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 attempts == 3 @@ -197,8 +198,7 @@ def test_first_database_open_does_not_retry_other_journal_errors( ) monkeypatch.setattr( - hermes_state, - "apply_wal_with_fallback", + hermes_state_wal, "apply_wal_with_fallback", configured_delete_refusal, ) diff --git a/tests/gateway/test_mirror.py b/tests/gateway/test_mirror.py index 671d9e9883..27e9a39207 100644 --- a/tests/gateway/test_mirror.py +++ b/tests/gateway/test_mirror.py @@ -125,9 +125,9 @@ class TestAppendToSqlite: mock_db = MagicMock() released = [] - with patch("hermes_state.get_shared_session_db", return_value=mock_db), \ + with patch("hermes_state_registry.acquire", return_value=mock_db), \ patch( - "hermes_state.release_or_close", + "hermes_state_registry.release_or_close", side_effect=lambda db: released.append(db), ): _append_to_sqlite("sess_1", {"role": "assistant", "content": "hello"}) diff --git a/tests/gateway/test_orphan_exit_grace.py b/tests/gateway/test_orphan_exit_grace.py index 5030e95758..b2b612b155 100644 --- a/tests/gateway/test_orphan_exit_grace.py +++ b/tests/gateway/test_orphan_exit_grace.py @@ -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 landing mid-checkpoint leaves half-written b-tree pages — macOS ``fsync`` 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 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 hermes_state_wal + from hermes_cli.gateway import ( _ORPHAN_EXIT_GRACE_SECONDS, _await_gateway_exit, diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index e8563fc6ea..348ee5ee9b 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -1475,7 +1475,7 @@ class TestGatewaySessionDbRecovery: def test_transcript_reroute_migrates_remaining_backlog_to_child(self): import threading from types import SimpleNamespace - from hermes_state import CompressionSessionClosedError + from hermes_state_errors import CompressionSessionClosedError class FakeDb: def get_compression_tip(self, session_id): diff --git a/tests/gateway/test_session_db_recovery.py b/tests/gateway/test_session_db_recovery.py index 0b13dfed3e..0420bbda4e 100644 --- a/tests/gateway/test_session_db_recovery.py +++ b/tests/gateway/test_session_db_recovery.py @@ -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: import hermes_state + import hermes_state_registry from gateway.run import GatewayRunner, _SESSION_DB_UNPINNED from gateway.session import SessionStore 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) 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) store = object.__new__(SessionStore) @@ -153,7 +154,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch, runner_opened.append(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)) runner = object.__new__(GatewayRunner) runner._session_db_pinned = _SESSION_DB_UNPINNED diff --git a/tests/gateway/test_shutdown_flush.py b/tests/gateway/test_shutdown_flush.py index f966ea896d..2f79303b57 100644 --- a/tests/gateway/test_shutdown_flush.py +++ b/tests/gateway/test_shutdown_flush.py @@ -123,9 +123,9 @@ def test_recover_closes_owned_db_when_unexpected_exception_escapes( raise KeyboardInterrupt db = InterruptingDB() - monkeypatch.setattr("hermes_state.get_shared_session_db", lambda: db) + monkeypatch.setattr("hermes_state_registry.acquire", lambda: db) 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): diff --git a/tests/gateway/test_startup_watchdog.py b/tests/gateway/test_startup_watchdog.py index 7b0f261a98..5aec6ccd0b 100644 --- a/tests/gateway/test_startup_watchdog.py +++ b/tests/gateway/test_startup_watchdog.py @@ -19,6 +19,8 @@ from pathlib import Path import pytest +import hermes_state_repair + import hermes_startup_watchdog as sw from hermes_startup_watchdog import ( SERVICE_RESTART_EXIT_CODE, @@ -453,7 +455,7 @@ class TestProgressLease: 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 diff --git a/tests/gateway/test_watchdog_review_76354.py b/tests/gateway/test_watchdog_review_76354.py index 94eaaa3c85..bd9afe5d2c 100644 --- a/tests/gateway/test_watchdog_review_76354.py +++ b/tests/gateway/test_watchdog_review_76354.py @@ -16,6 +16,16 @@ from agent.session_activity import ActivityProvenance, build_activity_snapshot 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 ──────── @@ -77,7 +87,7 @@ def test_s1_clear_labels_noop_skips_transaction(tmp_path, monkeypatch): calls.clear() db.clear_session_activity_labels(sid) assert len(calls) == 1 - activity = db.get_session_activity(sid) + activity = _activity_snapshot(db, sid) assert activity["last_activity_description"] == "" diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index b0028fef40..06d437a826 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -15,6 +15,7 @@ from pathlib import Path import pytest import hermes_state +import hermes_state_wal 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) 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. 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): @@ -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 # 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( - _hermes_state, "is_sqlite_wal_reset_vulnerable", + _hermes_state_wal, "is_sqlite_wal_reset_vulnerable", 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 kb._INITIALIZED_PATHS.clear() - hermes_state._wal_fallback_warned_paths.clear() + hermes_state_wal._wal_fallback_warned_paths.clear() 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) 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. monkeypatch.setattr( - hermes_state, "is_sqlite_wal_reset_vulnerable", + hermes_state_wal, "is_sqlite_wal_reset_vulnerable", 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) 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: 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 # Regression coverage for #33159 (kanban.db FD leak — gateway crashes after # ~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). # `connect_closing()` is the leak-safe replacement. # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_session_recovery.py b/tests/hermes_cli/test_session_recovery.py index 42b5db314b..c9549018d7 100644 --- a/tests/hermes_cli/test_session_recovery.py +++ b/tests/hermes_cli/test_session_recovery.py @@ -12,7 +12,8 @@ from types import SimpleNamespace import pytest 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.session_recovery import ( SessionRecoverySafetyError, diff --git a/tests/hermes_cli/test_sqlite3_cli_salvage_gate.py b/tests/hermes_cli/test_sqlite3_cli_salvage_gate.py index ced5461eef..5098e12ab4 100644 --- a/tests/hermes_cli/test_sqlite3_cli_salvage_gate.py +++ b/tests/hermes_cli/test_sqlite3_cli_salvage_gate.py @@ -26,6 +26,8 @@ from unittest.mock import patch import pytest +import hermes_state_repair + from hermes_cli.session_lost_and_found import ( _parse_sqlite3_cli_version, _wal_reset_vulnerable, @@ -245,7 +247,7 @@ class TestGuidanceNeverNamesLiveDb: must not embed a raw sqlite3 command against the live path.""" 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 "sessions recover --source" in body assert "--inspect-only" in body diff --git a/tests/hermes_state/test_deleted_wal_generation_guard.py b/tests/hermes_state/test_deleted_wal_generation_guard.py index cdbd201220..867e7b6e73 100644 --- a/tests/hermes_state/test_deleted_wal_generation_guard.py +++ b/tests/hermes_state/test_deleted_wal_generation_guard.py @@ -15,22 +15,18 @@ from pathlib import Path import pytest import hermes_state -from hermes_state import ( - DeletedWalGenerationError, - SessionDB, - classify_persistence_error, - iter_deleted_sqlite_sidecar_holders, - refuse_deleted_wal_generation, -) +import hermes_state_wal +from hermes_state import DeletedWalGenerationError, SessionDB, classify_persistence_error, refuse_deleted_wal_generation +from hermes_state_dbfile import iter_deleted_sqlite_sidecar_holders @pytest.fixture def force_wal(monkeypatch): """Pin WAL so this host's vulnerable SQLite still matches production topology.""" 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: @@ -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): - monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "delete") + monkeypatch.setattr(hermes_state_wal, "resolve_journal_mode", lambda: "delete") 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" a = _make_db(path, "s", "from-a") diff --git a/tests/hermes_state/test_isolation_marker_env.py b/tests/hermes_state/test_isolation_marker_env.py index 3c5e7751cf..d919bd486d 100644 --- a/tests/hermes_state/test_isolation_marker_env.py +++ b/tests/hermes_state/test_isolation_marker_env.py @@ -20,6 +20,7 @@ import sys from pathlib import Path import hermes_state +import hermes_state_guard REPO_ROOT = Path(__file__).resolve().parents[2] @@ -27,13 +28,14 @@ _CHILD_PROBE = r""" import json, os, sys sys.path.insert(0, {repo!r}) import hermes_state as hs +import hermes_state_guard fired = False try: hs._ensure_test_isolation(hs._real_platform_state_root() / "state.db") except RuntimeError: fired = True print(json.dumps({{ - "armed": hs._running_under_pytest(), + "armed": hermes_state_guard._running_under_pytest(), "fired": fired, }})) """ @@ -78,7 +80,7 @@ def test_marker_alone_reports_test_context(monkeypatch): monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("PYTEST_VERSION", raising=False) 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): @@ -87,7 +89,7 @@ def test_no_signals_reports_production(monkeypatch): monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("PYTEST_VERSION", 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(): diff --git a/tests/hermes_state/test_live_db_guard_ancestry.py b/tests/hermes_state/test_live_db_guard_ancestry.py index b6c853eb43..a904a914f1 100644 --- a/tests/hermes_state/test_live_db_guard_ancestry.py +++ b/tests/hermes_state/test_live_db_guard_ancestry.py @@ -29,6 +29,7 @@ from pathlib import Path import pytest import hermes_state +import hermes_state_guard REPO_ROOT = Path(__file__).resolve().parents[2] @@ -137,7 +138,7 @@ class TestPytestProcessRecognition: ], ) 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( "cmdline", @@ -150,11 +151,11 @@ class TestPytestProcessRecognition: ], ) 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): class _Denied: def cmdline(self): raise PermissionError("access denied") - assert not hermes_state._process_looks_like_pytest(_Denied()) + assert not hermes_state_guard._process_looks_like_pytest(_Denied()) diff --git a/tests/run_agent/test_compression_closed_adoption.py b/tests/run_agent/test_compression_closed_adoption.py index 7309588467..430523abf9 100644 --- a/tests/run_agent/test_compression_closed_adoption.py +++ b/tests/run_agent/test_compression_closed_adoption.py @@ -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 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") 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: - from hermes_state import ( - PERSISTENCE_ERROR_CAUSES, - CompressionSessionClosedError, - classify_persistence_error, - ) + from hermes_state import classify_persistence_error + from hermes_state_errors import CompressionSessionClosedError, PERSISTENCE_ERROR_CAUSES cause = classify_persistence_error(CompressionSessionClosedError("session-abc")) 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: - 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( "session_persistence_failed", diff --git a/tests/run_agent/test_exit_cleanup_interrupt.py b/tests/run_agent/test_exit_cleanup_interrupt.py index fa3b472f9d..d33d120369 100644 --- a/tests/run_agent/test_exit_cleanup_interrupt.py +++ b/tests/run_agent/test_exit_cleanup_interrupt.py @@ -48,7 +48,7 @@ class TestCronJobCleanup: "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(sched_delivery, "_resolve_origin", return_value=None), \ patch.object(scheduler, "_resolve_delivery_target", return_value=None), \ diff --git a/tests/run_agent/test_token_persistence_non_cli.py b/tests/run_agent/test_token_persistence_non_cli.py index 4795b60303..c6bf1a4a9e 100644 --- a/tests/run_agent/test_token_persistence_non_cli.py +++ b/tests/run_agent/test_token_persistence_non_cli.py @@ -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.SessionDB = FakeSessionDB - hermes_state.get_shared_session_db = lambda db_path=None: sentinel_db 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") diff --git a/tests/run_agent/test_turn_completion_explainer.py b/tests/run_agent/test_turn_completion_explainer.py index 8bd1900d7b..70ffca0bf7 100644 --- a/tests/run_agent/test_turn_completion_explainer.py +++ b/tests/run_agent/test_turn_completion_explainer.py @@ -17,6 +17,7 @@ suite (we patch ``agent.process_bootstrap.OpenAI`` and drive ``agent.client``), pass identically in CI and locally. """ +import hermes_state_errors import os import uuid 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(): - """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', ...) must classify as 'disk' — the two classifiers can never drift apart.""" 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', so it must classify by exception type (and by phrase for RPC-wrapped strings). This is the exact failure mode of issue #81227.""" - from hermes_state import ( - CompressionSessionBusyError, - SessionCompressionInProgressError, - ) + from hermes_state import SessionCompressionInProgressError + from hermes_state_errors import CompressionSessionBusyError from hermes_state import 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(): - 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( SessionTurnLeaseLostError( @@ -309,7 +309,8 @@ def test_classify_persistence_error_turn_lease_lost_is_distinct(): def test_persistence_error_causes_tuple_matches_classifier(): """PERSISTENCE_ERROR_CAUSES must cover every value the classifier can 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 = ( "database is locked", diff --git a/tests/state/test_compression_lease_refresh_before_publish.py b/tests/state/test_compression_lease_refresh_before_publish.py index d99ef33a5d..68ddd8367d 100644 --- a/tests/state/test_compression_lease_refresh_before_publish.py +++ b/tests/state/test_compression_lease_refresh_before_publish.py @@ -11,7 +11,8 @@ from unittest.mock import patch import pytest -from hermes_state import SessionDB, CompressionSessionBusyError +from hermes_state import SessionDB +from hermes_state_errors import CompressionSessionBusyError def _setup_db(tmp_path): diff --git a/tests/state/test_disk_full_error.py b/tests/state/test_disk_full_error.py index ac4e11738c..6cfbef2cce 100644 --- a/tests/state/test_disk_full_error.py +++ b/tests/state/test_disk_full_error.py @@ -5,7 +5,7 @@ from __future__ import annotations import errno import sqlite3 -from hermes_state import is_disk_full_error +from hermes_state_errors import is_disk_full_error def test_enospc_oserror(): diff --git a/tests/state/test_fts_rebuild_admission.py b/tests/state/test_fts_rebuild_admission.py index cac3ef1690..7604071aa0 100644 --- a/tests/state/test_fts_rebuild_admission.py +++ b/tests/state/test_fts_rebuild_admission.py @@ -27,7 +27,8 @@ from pathlib import Path import pytest 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( sys.platform == "win32", reason="POSIX flock child-process harness" @@ -338,9 +339,9 @@ class TestOrphanedHolderStalenessBreak: import os, sys, time sys.path.insert(0, {repo!r}) 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 pid = os.fork() if pid == 0: @@ -358,9 +359,9 @@ os._exit(1) grandchild = int(proc.stdout.readline().strip().split()[1]) proc.wait(timeout=10) 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 finally: with contextlib.suppress(OSError): @@ -463,6 +464,7 @@ class TestNonContentionErrnoFailsFast: import fcntl import hermes_state + import hermes_state_repair monkeypatch.setattr(hermes_state, "_REPAIR_LOCK_TIMEOUT_SECONDS", 30.0) @@ -471,7 +473,7 @@ class TestNonContentionErrnoFailsFast: monkeypatch.setattr(fcntl, "flock", _flock) 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 time.monotonic() - t0 < 2.0 diff --git a/tests/state/test_fts_trigram_cron_exclusion.py b/tests/state/test_fts_trigram_cron_exclusion.py index 5e115cc67d..703d1247bb 100644 --- a/tests/state/test_fts_trigram_cron_exclusion.py +++ b/tests/state/test_fts_trigram_cron_exclusion.py @@ -6,7 +6,8 @@ import sqlite3 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 diff --git a/tests/state/test_fts_trigram_subagent_exclusion.py b/tests/state/test_fts_trigram_subagent_exclusion.py index 00d1a63fa6..49ac8cbab0 100644 --- a/tests/state/test_fts_trigram_subagent_exclusion.py +++ b/tests/state/test_fts_trigram_subagent_exclusion.py @@ -9,7 +9,8 @@ from __future__ import annotations 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 diff --git a/tests/state/test_session_git_metadata_generation.py b/tests/state/test_session_git_metadata_generation.py index b97727725c..aa15767999 100644 --- a/tests/state/test_session_git_metadata_generation.py +++ b/tests/state/test_session_git_metadata_generation.py @@ -5,7 +5,8 @@ from __future__ import annotations import sqlite3 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): diff --git a/tests/state/test_session_turn_lease.py b/tests/state/test_session_turn_lease.py index 8020eaa0dc..a38f48c7ba 100644 --- a/tests/state/test_session_turn_lease.py +++ b/tests/state/test_session_turn_lease.py @@ -11,7 +11,8 @@ from types import SimpleNamespace import pytest 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): diff --git a/tests/state/test_state_db_lock_fail_closed.py b/tests/state/test_state_db_lock_fail_closed.py index a98a23f7cf..ecbe128646 100644 --- a/tests/state/test_state_db_lock_fail_closed.py +++ b/tests/state/test_state_db_lock_fail_closed.py @@ -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): * `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 *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 hermes_state +import hermes_state_repair 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: @@ -130,7 +132,7 @@ def test_repair_lock_fails_closed_when_lock_file_is_unopenable(tmp_path): db_path = tmp_path / "state.db" _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 @@ -145,7 +147,7 @@ def test_repair_skips_surgery_when_lock_file_is_unopenable(tmp_path): db_path = tmp_path / "state.db" _build_healthy_db(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() _make_unopenable(db_path.with_name(db_path.name + ".repair.lock")) diff --git a/tests/state/test_state_db_wal_unlink_race.py b/tests/state/test_state_db_wal_unlink_race.py index 5f8bf5271c..442b9bb565 100644 --- a/tests/state/test_state_db_wal_unlink_race.py +++ b/tests/state/test_state_db_wal_unlink_race.py @@ -12,7 +12,8 @@ import sqlite3 import pytest 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): @@ -31,9 +32,9 @@ def test_wal_restoration_reuses_exclusive_repair_connection(tmp_path, monkeypatc def fail_if_reopened(_path): 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 # runtime (WAL, or DELETE on WAL-reset-vulnerable SQLite builds); the # 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.""" db = tmp_path / "state.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. - 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): report["repaired"] = True report["strategy"] = "test_strategy" return report - monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies) + monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", fake_strategies) events: list[str] = [] - real_guard = hermes_state._exclusive_repair_db_guard - real_connect = hermes_state._connect_repair_durable + real_guard = hermes_state_repair._exclusive_repair_db_guard + real_connect = hermes_state_repair._connect_repair_durable from contextlib import contextmanager @@ -74,8 +75,8 @@ def test_repair_never_reopens_after_the_guard_releases(tmp_path, monkeypatch): events.append("connect") return real_connect(path, *a, **kw) - monkeypatch.setattr(hermes_state, "_exclusive_repair_db_guard", tracing_guard) - monkeypatch.setattr(hermes_state, "_connect_repair_durable", tracing_connect) + monkeypatch.setattr(hermes_state_repair, "_exclusive_repair_db_guard", tracing_guard) + monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", tracing_connect) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is True diff --git a/tests/test_conftest_wal_gate.py b/tests/test_conftest_wal_gate.py index 1e93ca53a8..8cdcacf023 100644 --- a/tests/test_conftest_wal_gate.py +++ b/tests/test_conftest_wal_gate.py @@ -14,7 +14,7 @@ import sqlite3 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 diff --git a/tests/test_fts_cjk_bigram.py b/tests/test_fts_cjk_bigram.py index 5b4ecf0b1d..01c2dfa312 100644 --- a/tests/test_fts_cjk_bigram.py +++ b/tests/test_fts_cjk_bigram.py @@ -11,7 +11,8 @@ from pathlib import Path 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 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.""" 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)) db_path = tmp_path / "state.db" diff --git a/tests/test_guest_durability_barriers.py b/tests/test_guest_durability_barriers.py index 21103de33c..bfa29bca47 100644 --- a/tests/test_guest_durability_barriers.py +++ b/tests/test_guest_durability_barriers.py @@ -12,7 +12,7 @@ import sqlite3 import pytest import hermes_state -from hermes_state import apply_durability_barriers +from hermes_state_repair import apply_durability_barriers def _config(monkeypatch, database_section): diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index dba894bd96..bb676562b9 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -10,14 +10,21 @@ from unittest import mock import pytest import hermes_state -from agent.session_activity import ActivityProvenance -from hermes_state import ( - FTS_SQL, - FTS_STORAGE_VERSION, - SCHEMA_SQL, - SCHEMA_VERSION, - SessionDB, -) +import hermes_state_wal +import hermes_state_common +from agent.session_activity import ActivityProvenance, build_activity_snapshot +from hermes_state import SessionDB +from hermes_state_common import FTS_SQL, FTS_STORAGE_VERSION, SCHEMA_SQL, SCHEMA_VERSION + + +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): @@ -1741,7 +1748,7 @@ class TestSanitizeTitle: class TestSchemaInit: def test_wal_mode(self, db): """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") mode = cursor.fetchone()[0].lower() @@ -1796,7 +1803,7 @@ class TestSchemaInit: This is the architectural invariant: SCHEMA_SQL declares the 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) 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_provenance"] == "unknown" - activity = db.get_session_activity("s1") + activity = _activity_snapshot(db, "s1") assert activity["last_activity_at"] == heartbeat assert activity["last_activity_description"] == "starting API call #1" assert "phase" not in activity @@ -2405,7 +2412,7 @@ class TestListSessionsRich: assert row["last_activity_at"] == heartbeat assert row["last_activity_description"] == "" 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_description"] == "" assert activity["last_activity_provenance"] == "unknown" @@ -2448,7 +2455,7 @@ class TestListSessionsRich: rows = db.list_gateway_sessions(active_only=True) assert len(rows) == 1 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" 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): """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, "_freelist_ratio", lambda: AUTO_VACUUM_MIN_FREELIST_RATIO) @@ -3148,7 +3155,7 @@ class TestVacuum: def test_freelist_ratio_reads_real_pragmas(self, db): """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.append_message(session_id="keep", role="user", content="hi") @@ -3534,7 +3541,7 @@ class TestFTS5ToolCallMigration: assert len(session_db.search_messages("LEGACYARG")) == 1, \ "v23 optimize must index tool_calls JSON into FTS" # 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( "SELECT version FROM schema_version LIMIT 1" ).fetchone() @@ -3806,7 +3813,7 @@ class TestFTSExternalContentMigration: Mirrors what happened when ``_ensure_fts_schema`` ran inside ``_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 db._drop_fts_triggers(conn) @@ -3871,7 +3878,7 @@ class TestFTSExternalContentMigration: assert db.fts_rebuild_status() is None assert db.fts_optimize_available() is False assert db.get_meta("fts_storage_version") == str( - hermes_state.FTS_STORAGE_VERSION + hermes_state_common.FTS_STORAGE_VERSION ) assert db._conn.execute( "SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'" @@ -3913,7 +3920,7 @@ class TestFTSExternalContentMigration: "INSERT INTO state_meta (key, value) VALUES " "('fts_storage_version', ?) " "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() @@ -3928,7 +3935,7 @@ class TestFTSExternalContentMigration: assert result["ok"] is True assert len(db.search_messages("deployment")) == 1 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 finally: @@ -4275,14 +4282,14 @@ class TestApplyWalProbe: import hermes_state 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): """Probe sees 'delete', then set-pragma runs and returns 'wal'.""" import sqlite3 - from hermes_state import apply_wal_with_fallback + from hermes_state_wal import apply_wal_with_fallback class _TracingConn(sqlite3.Connection): def __init__(self, *a, **kw): @@ -4315,7 +4322,7 @@ class TestApplyWalProbe: import sys import threading 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" errors = [] @@ -4361,7 +4368,7 @@ class TestApplyWalProbe: 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.""" import sqlite3 - from hermes_state import apply_wal_with_fallback + from hermes_state_wal import apply_wal_with_fallback class _TracingConn(sqlite3.Connection): def __init__(self, *a, **kw): @@ -5699,8 +5706,7 @@ class TestPerformancePragmasEndToEnd: # path. Force WAL eligibility so _get_read_conn is truly exercised # (established pattern used by the WAL tests above). monkeypatch.setattr( - hermes_state, - "is_sqlite_wal_reset_vulnerable", + hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False, ) home = tmp_path / "hermes_home" diff --git a/tests/test_hermes_state_wal_fallback.py b/tests/test_hermes_state_wal_fallback.py index b71dd32b61..d43098aef0 100644 --- a/tests/test_hermes_state_wal_fallback.py +++ b/tests/test_hermes_state_wal_fallback.py @@ -19,13 +19,9 @@ from unittest.mock import patch import pytest import hermes_state -from hermes_state import ( - SessionDB, - WalUnsupportedError, - apply_wal_with_fallback, - format_session_db_unavailable, - get_last_init_error, -) +import hermes_state_wal +from hermes_state import SessionDB, format_session_db_unavailable, get_last_init_error +from hermes_state_wal import WalUnsupportedError, apply_wal_with_fallback # ``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) def _reset_wal_fallback_warned_paths(): """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 - hermes_state._wal_fallback_warned_paths.clear() + hermes_state_wal._wal_fallback_warned_paths.clear() @pytest.fixture(autouse=True) def _assume_fixed_sqlite(monkeypatch): """NFS-fallback tests assume a SQLite build without the WAL-reset bug.""" 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 - hermes_state._wal_reset_bug_warned_paths.clear() + hermes_state_wal._wal_reset_bug_warned_paths.clear() class TestApplyWalWithFallback: diff --git a/tests/test_journal_mode_config.py b/tests/test_journal_mode_config.py index d23132fae2..d83bf77e1e 100644 --- a/tests/test_journal_mode_config.py +++ b/tests/test_journal_mode_config.py @@ -5,6 +5,8 @@ from __future__ import annotations import sqlite3 import pytest + +import hermes_state_wal import yaml @@ -24,7 +26,7 @@ def _configure_mode(monkeypatch: pytest.MonkeyPatch, tmp_path, mode: object) -> def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( - "hermes_state.is_sqlite_wal_reset_vulnerable", + "hermes_state_wal.is_sqlite_wal_reset_vulnerable", 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.""" import hermes_state - hermes_state._delete_overridden_warned_paths.clear() + hermes_state_wal._delete_overridden_warned_paths.clear() 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(): @@ -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): - from hermes_state import resolve_journal_mode + from hermes_state_wal import resolve_journal_mode _configure_mode(monkeypatch, tmp_path, "DELETE") assert resolve_journal_mode() == "delete" 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") 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"}]) 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) 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( 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}) assert resolve_journal_mode() == "wal" 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") _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): - from hermes_state import apply_wal_with_fallback + from hermes_state_wal import apply_wal_with_fallback _configure_mode(monkeypatch, tmp_path, "wal") _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): """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") monkeypatch.setattr( - "hermes_state.is_sqlite_wal_reset_vulnerable", + "hermes_state_wal.is_sqlite_wal_reset_vulnerable", lambda **kwargs: True, ) 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 delete had no effect (otherwise the DB silently stays WAL and the protection 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") db_path = tmp_path / "existing-wal.db" @@ -135,7 +137,7 @@ def test_configured_delete_never_live_downgrades_existing_wal(monkeypatch, tmp_p try: assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal" monkeypatch.setattr( - "hermes_state.is_sqlite_wal_reset_vulnerable", + "hermes_state_wal.is_sqlite_wal_reset_vulnerable", lambda **kwargs: True, ) 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 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.""" - from hermes_state import apply_wal_with_fallback + from hermes_state_wal import apply_wal_with_fallback _configure_mode(monkeypatch, tmp_path, "delete") _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): """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.""" - from hermes_state import apply_wal_with_fallback + from hermes_state_wal import apply_wal_with_fallback _configure_mode(monkeypatch, tmp_path, "delete") monkeypatch.setattr( - "hermes_state.is_sqlite_wal_reset_vulnerable", + "hermes_state_wal.is_sqlite_wal_reset_vulnerable", lambda **kwargs: True, ) 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 governs the WAL-refusal fallback paths), so the override warning fires and no 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") _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.platforms.api_server import ResponseStore from hermes_cli import kanban_db, projects_db + from hermes_cli import kanban_db_connect as kbc from hermes_state import SessionDB from plugins.memory.holographic.store import MemoryStore from plugins.platforms.discord.recovery import DiscordRecoveryStore diff --git a/tests/test_journal_mode_upgrade_warning.py b/tests/test_journal_mode_upgrade_warning.py index 0d1e561e44..9341a8699f 100644 --- a/tests/test_journal_mode_upgrade_warning.py +++ b/tests/test_journal_mode_upgrade_warning.py @@ -26,6 +26,8 @@ from __future__ import annotations import sqlite3 import pytest + +import hermes_state_wal import yaml @@ -42,7 +44,7 @@ def _configure_mode(monkeypatch: pytest.MonkeyPatch, tmp_path, mode: object) -> def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( - "hermes_state.is_sqlite_wal_reset_vulnerable", + "hermes_state_wal.is_sqlite_wal_reset_vulnerable", lambda **kwargs: False, ) @@ -64,16 +66,16 @@ def _reset_dedup(): """Order-independence: the warning is deduped per process per db_label.""" import hermes_state - hermes_state._journal_upgrade_warned_paths.clear() + hermes_state_wal._journal_upgrade_warned_paths.clear() yield - hermes_state._journal_upgrade_warned_paths.clear() + hermes_state_wal._journal_upgrade_warned_paths.clear() class TestTheContentProbe: """``_database_has_content`` is what keeps fresh installs quiet.""" 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")) try: @@ -82,7 +84,7 @@ class TestTheContentProbe: conn.close() 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" _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 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:") try: @@ -113,7 +115,7 @@ class TestTheWarningFires: def test_an_existing_delete_database_warns_when_flipped( 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") _disable_vulnerable_gate(monkeypatch) @@ -139,7 +141,7 @@ class TestTheWarningFires: Telling an operator their mode changed, without telling them which 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") _disable_vulnerable_gate(monkeypatch) @@ -165,7 +167,7 @@ class TestTheWarningFires: (managed_uv repairs it on update, citing ~2600x slower appends), so 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") _disable_vulnerable_gate(monkeypatch) @@ -183,7 +185,7 @@ class TestTheWarningFires: self, monkeypatch, tmp_path, caplog ): """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") _disable_vulnerable_gate(monkeypatch) @@ -205,7 +207,7 @@ class TestTheWarningFires: self, monkeypatch, tmp_path, caplog ): """#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") _disable_vulnerable_gate(monkeypatch) @@ -236,7 +238,7 @@ class TestTheWarningStaysQuiet: every opener applies WAL before creating any schema -- so without 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") _disable_vulnerable_gate(monkeypatch) @@ -252,7 +254,7 @@ class TestTheWarningStaysQuiet: def test_an_existing_wal_database_is_silent(self, monkeypatch, tmp_path, caplog): """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") _disable_vulnerable_gate(monkeypatch) @@ -271,7 +273,7 @@ class TestTheWarningStaysQuiet: def test_configured_delete_is_silent(self, monkeypatch, tmp_path, caplog): """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") _disable_vulnerable_gate(monkeypatch) @@ -299,11 +301,11 @@ class TestTheWarningStaysQuiet: the SQLite upgrade -- warning here would blame the guard that was 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") monkeypatch.setattr( - "hermes_state.is_sqlite_wal_reset_vulnerable", + "hermes_state_wal.is_sqlite_wal_reset_vulnerable", lambda **kwargs: True, ) path = tmp_path / "vulnerable.db" @@ -327,7 +329,7 @@ class TestTheExistingContractIsUnchanged: """Behaviour preservation for the rules this change sits next to.""" 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") path = tmp_path / "existing-wal.db" @@ -335,7 +337,7 @@ class TestTheExistingContractIsUnchanged: try: assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal" monkeypatch.setattr( - "hermes_state.is_sqlite_wal_reset_vulnerable", + "hermes_state_wal.is_sqlite_wal_reset_vulnerable", lambda **kwargs: True, ) 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( 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") _disable_vulnerable_gate(monkeypatch) diff --git a/tests/test_message_reactions.py b/tests/test_message_reactions.py index 0cae4dbc8b..6028c521d4 100644 --- a/tests/test_message_reactions.py +++ b/tests/test_message_reactions.py @@ -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): """The agent reacts to "the message that triggered me" without an id.""" 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!") 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 # agent's default target — it always means the latest USER message. 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 diff --git a/tests/test_session_db_read_conn_pool.py b/tests/test_session_db_read_conn_pool.py index acfb8cff83..9d00ce58be 100644 --- a/tests/test_session_db_read_conn_pool.py +++ b/tests/test_session_db_read_conn_pool.py @@ -35,6 +35,7 @@ holds POSIX locks on that inode, so raw descriptor counts lag the real connection count and make such assertions flaky. """ +import hermes_state_readpool import queue import threading @@ -540,10 +541,10 @@ def test_peak_is_bounded_across_many_database_files(tmp_path): finally: for d in dbs: 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" ) - hermes_state._process_read_permits.release() + hermes_state_readpool._process_read_permits.release() @pytest.mark.requires_wal diff --git a/tests/test_session_system_prompt_dedup.py b/tests/test_session_system_prompt_dedup.py index 966920a76c..bed724a442 100644 --- a/tests/test_session_system_prompt_dedup.py +++ b/tests/test_session_system_prompt_dedup.py @@ -8,7 +8,8 @@ import time import pytest -from hermes_state import SCHEMA_VERSION, SessionDB +from hermes_state import SessionDB +from hermes_state_common import SCHEMA_VERSION @pytest.fixture() diff --git a/tests/test_session_workspace_binding.py b/tests/test_session_workspace_binding.py index 36e7f2ec3d..c854266edb 100644 --- a/tests/test_session_workspace_binding.py +++ b/tests/test_session_workspace_binding.py @@ -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 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. """ -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(): diff --git a/tests/test_sqlite_wal_reset_gate.py b/tests/test_sqlite_wal_reset_gate.py index 1dafce8115..6d3b0e96dc 100644 --- a/tests/test_sqlite_wal_reset_gate.py +++ b/tests/test_sqlite_wal_reset_gate.py @@ -18,18 +18,15 @@ from types import SimpleNamespace import pytest import hermes_state -from hermes_state import ( - apply_wal_with_fallback, - is_sqlite_wal_reset_vulnerable, - sqlite_source_id, -) +import hermes_state_wal +from hermes_state_wal import apply_wal_with_fallback, is_sqlite_wal_reset_vulnerable, sqlite_source_id @pytest.fixture(autouse=True) def _reset_wal_reset_bug_warnings(): - hermes_state._wal_reset_bug_warned_paths.clear() + hermes_state_wal._wal_reset_bug_warned_paths.clear() yield - hermes_state._wal_reset_bug_warned_paths.clear() + hermes_state_wal._wal_reset_bug_warned_paths.clear() class TestIsSqliteWalResetVulnerable: @@ -61,7 +58,7 @@ class TestIsSqliteWalResetVulnerable: class TestApplyWalWalResetGate: def test_fresh_db_uses_delete_when_vulnerable(self, tmp_path, monkeypatch, caplog): 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")) 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.""" 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" seed = sqlite3.connect(str(path)) @@ -109,7 +106,7 @@ class TestApplyWalWalResetGate: def test_warning_deduped_per_label(self, tmp_path, monkeypatch, caplog): 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"): 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.""" 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" seed = sqlite3.connect(str(db)) @@ -215,7 +212,7 @@ class TestNoDowngradeUnderConcurrentOpeners: as 'not WAL' and flipping anyway (the incident's exact confusion). Assertions run WHILE the holder's exclusive lock is live.""" 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" seed = sqlite3.connect(str(db)) @@ -266,7 +263,7 @@ class TestNoDowngradeUnderConcurrentOpeners: """No concurrent openers → the vulnerable-SQLite DELETE gate still applies exactly as before.""" 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")) try: @@ -287,7 +284,7 @@ class TestNoDowngradeUnderConcurrentOpeners: between probe and flip), the gate returns the observed mode instead of raising or waiting the lock out.""" 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): @@ -316,11 +313,10 @@ class TestNoDowngradeUnderConcurrentOpeners: refuse to downgrade when the mode probe is blocked by a concurrent opener's exclusive lock — raise, never flip blind.""" monkeypatch.setattr( - hermes_state, - "is_sqlite_wal_reset_vulnerable", + hermes_state_wal, "is_sqlite_wal_reset_vulnerable", 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" seed = sqlite3.connect(str(db)) try: @@ -358,11 +354,10 @@ class TestNoDowngradeUnderConcurrentOpeners: """The filesystem-incompat fallback must not downgrade when the on-disk mode cannot be verified (possible concurrent openers).""" monkeypatch.setattr( - hermes_state, - "is_sqlite_wal_reset_vulnerable", + hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False, ) - hermes_state._wal_fallback_warned_paths.clear() + hermes_state_wal._wal_fallback_warned_paths.clear() class _LockedProbeConnection(sqlite3.Connection): 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.setattr("hermes_constants.get_hermes_home", lambda: home) 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) args = SimpleNamespace(fix=False, ack=None) diff --git a/tests/test_state_db_malformed_repair.py b/tests/test_state_db_malformed_repair.py index df0f6b422b..02bdff0a30 100644 --- a/tests/test_state_db_malformed_repair.py +++ b/tests/test_state_db_malformed_repair.py @@ -23,11 +23,10 @@ from pathlib import Path import pytest import hermes_state -from hermes_state import ( - SessionDB, - is_malformed_db_error, - repair_state_db_schema, -) +import hermes_state_repair +import hermes_state_wal +from hermes_state import SessionDB, is_malformed_db_error +from hermes_state_repair import repair_state_db_schema 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): 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( - hermes_state, - "repair_state_db_schema", + hermes_state, "repair_state_db_schema", 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): """``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" _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): """_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" _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): """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" _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") # 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 "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 # 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)) assert raw.execute("PRAGMA integrity_check").fetchone()[0] == "ok" 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. assert report["backup_path"] is None 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") @@ -503,7 +501,7 @@ def test_repair_reports_success_when_the_holder_already_healed_the_db( _REPAIR_SCRIPT = """ import sys, json 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) """ @@ -597,8 +595,7 @@ def test_backup_refusal_hard_stops_the_repair(tmp_path, monkeypatch): original_bytes = db_path.read_bytes() monkeypatch.setattr( - hermes_state, - "_backup_db_file", + hermes_state_repair, "_backup_db_file", 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"] # No mutating strategy ran: the damaged source bytes are untouched. 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): @@ -620,8 +617,7 @@ def test_backup_copy_failure_hard_stops_the_repair(tmp_path, monkeypatch): _corrupt_duplicate_fts(db_path) monkeypatch.setattr( - hermes_state, - "_backup_db_file", + hermes_state_repair, "_backup_db_file", 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", ) 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" _configure_journal_mode(monkeypatch, tmp_path, "wal") 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) conn = sqlite3.connect(str(db_path)) @@ -756,7 +752,7 @@ def test_repair_logs_mode_change_when_probe_succeeded( with ( 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"), ): @@ -813,7 +809,7 @@ def test_repair_restore_failure_is_nonfatal_and_logged( raise sqlite3.OperationalError("database is locked") 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"), ): report = repair_state_db_schema(db_path) diff --git a/tests/test_state_db_notadb_fail_closed.py b/tests/test_state_db_notadb_fail_closed.py index 9fce19e3e1..a1cd2ee197 100644 --- a/tests/test_state_db_notadb_fail_closed.py +++ b/tests/test_state_db_notadb_fail_closed.py @@ -14,7 +14,8 @@ from unittest.mock import MagicMock 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: diff --git a/tests/test_state_db_repair_live_writer_guard.py b/tests/test_state_db_repair_live_writer_guard.py index 161592e1cb..4f0b477d46 100644 --- a/tests/test_state_db_repair_live_writer_guard.py +++ b/tests/test_state_db_repair_live_writer_guard.py @@ -29,11 +29,10 @@ from pathlib import Path import pytest import hermes_state +import hermes_state_repair import hermes_state_holders -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_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): 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) @@ -181,7 +180,7 @@ def test_uninspectable_watched_descriptor_blocks_repair_before_sqlite( def _unexpected_probe(*_args, **_kwargs): 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) @@ -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") monkeypatch.setattr( - hermes_state, - "_connect_repair_durable", + hermes_state_repair, "_connect_repair_durable", _unexpected_probe, ) 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 "live writer" in (report["error"] or "").lower() else: - real_connect = hermes_state._connect_repair_durable + real_connect = hermes_state_repair._connect_repair_durable probe_reached = False 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) monkeypatch.setattr( - hermes_state, - "_connect_repair_durable", + hermes_state_repair, "_connect_repair_durable", _record_probe, ) 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): 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) @@ -332,7 +329,7 @@ def test_uninspectable_alias_descriptor_for_hermes_blocks_before_sqlite( def _unexpected_probe(*_args, **_kwargs): 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) diff --git a/tests/test_state_db_repair_loop_cap.py b/tests/test_state_db_repair_loop_cap.py index 0eafbcb7a3..9e84e91ebe 100644 --- a/tests/test_state_db_repair_loop_cap.py +++ b/tests/test_state_db_repair_loop_cap.py @@ -25,17 +25,8 @@ from pathlib import Path from unittest.mock import patch import hermes_state -from hermes_state 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, -) +import hermes_state_repair +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 def _make_unrepairable_db(tmp_path: Path) -> Path: @@ -76,7 +67,7 @@ class TestPersistentAttemptCap: # Budget burned: the next call must refuse WITHOUT running surgery # (and without taking another backup). 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) surgery.assert_not_called() assert report["repaired"] is False diff --git a/tests/test_state_db_repair_loop_mtime.py b/tests/test_state_db_repair_loop_mtime.py index e6fa8724c0..08e8e74775 100644 --- a/tests/test_state_db_repair_loop_mtime.py +++ b/tests/test_state_db_repair_loop_mtime.py @@ -29,18 +29,8 @@ from pathlib import Path from unittest.mock import patch import hermes_state -from hermes_state 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, -) +import hermes_state_repair +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 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} )() 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 "free" in (report.get("error") or "").lower() diff --git a/tests/test_state_db_repair_non_destructive.py b/tests/test_state_db_repair_non_destructive.py index 0b00e3699c..2a1e12ecf0 100644 --- a/tests/test_state_db_repair_non_destructive.py +++ b/tests/test_state_db_repair_non_destructive.py @@ -66,7 +66,8 @@ from types import SimpleNamespace import pytest 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 @@ -130,7 +131,7 @@ def _leave_hot_wal_row(db_path: str) -> None: def _probe_repair_lock_from_child(db_path: str, result) -> None: """Attempt the repair lock with a short timeout from another process.""" 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) @@ -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 makes them safe is simply that the argument is never the real file.""" seen: list[Path] = [] - real = hermes_state._run_repair_strategies + real = hermes_state_repair._run_repair_strategies def spy(path, report): seen.append(path) 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) 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, # and have the strategy pass mark a repair after writing a marker row. 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): @@ -271,7 +272,7 @@ def test_successful_repair_is_promoted_over_the_original(tmp_path, monkeypatch): report["strategy"] = "test_strategy" 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) 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) 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() start = multiprocessing.get_context("spawn").Event() @@ -329,7 +330,7 @@ def test_committed_writer_after_staging_is_never_lost( report["strategy"] = "race_test" 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) writer.join(20) 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" _make_repair_test_db(db) 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, - "_repair_scratch_space_error", + hermes_state_repair, "_repair_scratch_space_error", 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["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" - 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): 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" 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) assert report["repaired"] is True 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" _make_repair_test_db(db) 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): @@ -405,10 +405,10 @@ def test_actual_strategy_failure_still_consumes_one_attempt(tmp_path, monkeypatc report["strategy"] = None 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) 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 @@ -419,7 +419,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held( db = tmp_path / "state.db" _make_repair_test_db(db) 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): @@ -427,10 +427,10 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held( report["strategy"] = None return report - monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy) + monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", failed_strategy) observed = [] 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 def tracking_repair_lock(path): @@ -439,7 +439,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held( lock_released.set() 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): @@ -462,7 +462,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held( probe.join(5) 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) 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 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 = [] 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 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) @@ -513,7 +513,7 @@ def test_scratch_budget_counts_sidecars_in_vacuum_multiplier(tmp_path, monkeypat db.write_bytes(b"m" * main_bytes) db.with_name(db.name + "-wal").write_bytes(b"w" * wal_bytes) 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`` # calculation, but is below the corrected ``3*snapshot + 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), ) - error = hermes_state._repair_scratch_space_error(db) + error = hermes_state_repair._repair_scratch_space_error(db) assert error is not None 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" _make_repair_test_db(db) 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): @@ -544,8 +544,8 @@ def test_environmental_promotion_failures_do_not_burn_ledger( report["strategy"] = "promotion_environment_test" return report - monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy) - real_copy = hermes_state._copy_database_snapshot + monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", successful_strategy) + real_copy = hermes_state_repair._copy_database_snapshot copy_calls = 0 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) 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): report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False 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 - 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) assert report["repaired"] is True 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" _make_repair_test_db(db) 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): @@ -585,8 +585,8 @@ def test_corrupt_promotion_failure_consumes_one_attempt(tmp_path, monkeypatch): report["strategy"] = "corrupt-promotion-test" return report - monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy) - real_copy = hermes_state._copy_database_snapshot + monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", successful_strategy) + real_copy = hermes_state_repair._copy_database_snapshot calls = 0 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) 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) 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): @@ -617,7 +617,7 @@ def test_snapshot_includes_committed_wal_frames(tmp_path): assert db.with_name(db.name + "-wal").exists() 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: assert check.execute("SELECT body FROM messages").fetchall() == [ ("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") monkeypatch.setattr( - hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" + hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) strategy_calls = [] @@ -657,7 +657,7 @@ def test_failed_wal_repair_preserves_committed_rows_semantically( report["strategy"] = None 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) assert report["repaired"] is False 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 ): """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" # Lower the throughput only for this arithmetic test so a 72 MiB fixture # crosses the floor without allocating a multi-GB file. 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" @@ -711,7 +711,7 @@ def test_transactional_promotion_preserves_a_live_wal_reader(tmp_path): reader.execute("BEGIN") 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",)] 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)) 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: 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" _write_populated_db(db) before = hashlib.sha256(db.read_bytes()).hexdigest() - real_copy = hermes_state._copy_database_snapshot + real_copy = hermes_state_repair._copy_database_snapshot calls = 0 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 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, "_run_repair_strategies", fake_strategies) + monkeypatch.setattr(hermes_state_repair, "_copy_database_snapshot", fail_second_copy) + monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", fake_strategies) 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): db = tmp_path / "state.db" 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 usage = SimpleNamespace(total=10_000_000_000, free=free) 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 "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() 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) @@ -826,7 +826,7 @@ def test_stale_scratch_is_removed_before_space_check(tmp_path, monkeypatch): checked_space = False 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): @@ -836,7 +836,7 @@ def test_stale_scratch_is_removed_before_space_check(tmp_path, monkeypatch): return "forced low space" 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) @@ -857,9 +857,9 @@ def test_stale_scratch_cleanup_failure_aborts_before_probe(tmp_path, monkeypatch probed = True return "forced-unhealthy" - monkeypatch.setattr(hermes_state, "_db_opens_cleanly", fake_health) + monkeypatch.setattr(hermes_state_repair, "_db_opens_cleanly", fake_health) monkeypatch.setattr( - hermes_state, "_unlink_db_triple", lambda _path: "scratch is locked" + hermes_state_repair, "_unlink_db_triple", lambda _path: "scratch is locked" ) report = { "repaired": False, @@ -868,7 +868,7 @@ def test_stale_scratch_cleanup_failure_aborts_before_probe(tmp_path, monkeypatch "error": None, } - result = hermes_state._repair_state_db_schema_locked( + result = hermes_state_repair._repair_state_db_schema_locked( db, backup=False, report=report ) diff --git a/tests/test_state_synchronous_pragma.py b/tests/test_state_synchronous_pragma.py index 6c565e1eae..ce34c8d025 100644 --- a/tests/test_state_synchronous_pragma.py +++ b/tests/test_state_synchronous_pragma.py @@ -16,10 +16,9 @@ import sys import pytest import hermes_state -from hermes_state import ( - apply_database_pragmas, - resolve_synchronous_level, -) +import hermes_state_wal +from hermes_state import apply_database_pragmas +from hermes_state_wal import resolve_synchronous_level def _wal_conn(tmp_path): @@ -186,7 +185,7 @@ class TestMacOSFloor: conn = _wal_conn(tmp_path) try: monkeypatch.setattr(sys, "platform", "darwin") - hermes_state._enforce_macos_synchronous_full(conn) + hermes_state_wal._enforce_macos_synchronous_full(conn) assert _level(conn) == 2 with caplog.at_level("WARNING"): apply_database_pragmas(conn, db_label="state.db") diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index fdab36972d..f32b829cd4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3936,7 +3936,7 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path): monkeypatch.setenv("TERMINAL_CWD", str(launch_cwd)) 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, "_enable_gateway_prompts", lambda: None) 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): 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") 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", 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, "_transfer_db_to_agent", barrier_transfer) monkeypatch.setattr( @@ -14620,8 +14620,10 @@ def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch): def _broken_shared(_db_path=None): raise RuntimeError("locking protocol") - fake_mod.get_shared_session_db = _broken_shared 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_error", None) @@ -14643,8 +14645,10 @@ def test_ensure_session_db_row_false_when_store_unavailable(monkeypatch): def _broken_shared(_db_path=None): raise RuntimeError("utf-8 boom") - fake_mod.get_shared_session_db = _broken_shared 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_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, "_get_db", lambda: LaunchDB()) - monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) + monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB) 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, "_get_db", lambda: LaunchDB()) - monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB2) + monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB2) 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, "_get_db", lambda: None) - monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) + monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB) 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, } monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) - monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) + monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB) try: set_resp = server.handle_request( { @@ -15288,7 +15292,7 @@ def test_session_history_uses_session_profile_db(monkeypatch, tmp_path): "last_active": 1.0, } monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) - monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) + monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB) try: resp = server.handle_request( {"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, } monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) - monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB) + monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB) try: resp = server.handle_request( {"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 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_key": "ml-sess", "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 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)) 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 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)) 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 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, "_make_agent", lambda *args, **kwargs: FakeAgent()) 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 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_key": "ml-sess", "pending_title": "deferred-title", diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index 6e807962d2..5eec8be08c 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -151,9 +151,9 @@ class TestBrowseShape: return [] db = _DB() - monkeypatch.setattr("hermes_state.get_shared_session_db", lambda: db) + monkeypatch.setattr("hermes_state_registry.acquire", lambda: db) monkeypatch.setattr( - "hermes_state.release_or_close", + "hermes_state_registry.release_or_close", lambda _: setattr(db, "released", db.released + 1), ) diff --git a/tests/tui_gateway/test_resume_live_profile_scope.py b/tests/tui_gateway/test_resume_live_profile_scope.py index f6fd13d84c..98e350d173 100644 --- a/tests/tui_gateway/test_resume_live_profile_scope.py +++ b/tests/tui_gateway/test_resume_live_profile_scope.py @@ -55,7 +55,7 @@ def homes(monkeypatch, tmp_path): homes = {name: tmp_path / name for name in ("a", "b")} for home in homes.values(): 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, "_profile_home", lambda p: homes.get(p) if p else None) monkeypatch.setattr(server, "_profile_configured_cwd", lambda _home: str(tmp_path)) diff --git a/tests/tui_gateway/test_session_db_ownership_teardown.py b/tests/tui_gateway/test_session_db_ownership_teardown.py index da565a9df7..63aa67cbfd 100644 --- a/tests/tui_gateway/test_session_db_ownership_teardown.py +++ b/tests/tui_gateway/test_session_db_ownership_teardown.py @@ -178,7 +178,7 @@ def test_lazy_recall_open_is_owned_by_the_agent(monkeypatch): opened.append(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) got = agent._get_session_db_for_recall() @@ -272,7 +272,7 @@ def build_env(monkeypatch, tmp_path): opened.append(db) return db - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) for name, value in [ ("_set_session_context", lambda _key: []), ("_clear_session_context", lambda _tokens: None), diff --git a/tests/tui_gateway/test_session_resume_db_ownership.py b/tests/tui_gateway/test_session_resume_db_ownership.py index 3e4aa9cbfb..53e7df166f 100644 --- a/tests/tui_gateway/test_session_resume_db_ownership.py +++ b/tests/tui_gateway/test_session_resume_db_ownership.py @@ -90,7 +90,7 @@ def profile_dbs(monkeypatch, tmp_path): opened.append(db) return db - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) monkeypatch.setattr( server, "_profile_home", lambda profile: profile_home if profile else None ) @@ -146,7 +146,7 @@ def test_deferred_desktop_resume_keeps_stored_workspace_provenance( profile_dbs.append(db) return db - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) resp = _resume(session_id="s1", profile="work", source="desktop") session = server._sessions[resp["result"]["session_id"]] @@ -166,7 +166,7 @@ def test_resume_closes_profile_db_when_reopen_fails(profile_dbs, monkeypatch): profile_dbs.append(db) return db - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) resp = _resume(session_id="s1", profile="work") @@ -189,7 +189,7 @@ def test_resume_closes_profile_db_on_live_session_fast_path(profile_dbs, monkeyp profile_dbs.append(db) return db - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) live_session = {} with server._sessions_lock: server._sessions["live-sid"] = live_session @@ -225,7 +225,7 @@ def test_resume_closes_profile_db_on_deferred_cold_resume(profile_dbs, monkeypat profile_dbs.append(db) return db - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {}) resp = _resume(session_id="s1", profile="work") @@ -258,7 +258,7 @@ def test_resume_hands_profile_db_to_deferred_history_worker(profile_dbs, monkeyp profile_dbs.append(db) return db - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {}) monkeypatch.setattr(server, "_start_agent_build", lambda *_args, **_kwargs: None) @@ -300,7 +300,7 @@ def test_resume_keeps_profile_db_open_after_ownership_transfer(profile_dbs, monk def _fake_init_session(sid, key, agent, history, session_db=None, **_kwargs): captured["init_db"] = session_db - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) monkeypatch.setattr(server, "_make_agent", _fake_make_agent) monkeypatch.setattr(server, "_init_session", _fake_init_session) monkeypatch.setattr(server, "_set_session_context", lambda _target: []) @@ -345,7 +345,7 @@ def test_resume_drops_half_built_session_when_init_session_raises( server._sessions[sid] = {"agent": agent, "session_key": key} raise RuntimeError("database is locked") - monkeypatch.setattr("hermes_state.get_shared_session_db", _factory) + monkeypatch.setattr("hermes_state_registry.acquire", _factory) monkeypatch.setattr( server, "_make_agent", lambda *a, **k: types.SimpleNamespace(model="test") ) diff --git a/tools/async_delegation.py b/tools/async_delegation.py index 5ce576f59a..4fef9cc7e0 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -95,7 +95,7 @@ def _connect() -> sqlite3.Connection: def _initialize_schema(conn: sqlite3.Connection) -> None: - from hermes_state import apply_durability_barriers + from hermes_state_repair import apply_durability_barriers # Preserve the journal mode SessionDB configured on state.db: forcing WAL from # every short-lived connection collides with live transcript/FTS writers. apply_durability_barriers(conn) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 4f73e911f7..77b54d38a0 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -97,9 +97,9 @@ def _open_child_session_db(parent_agent) -> Any: if parent_session_db is None: return None with _quiet("subagent: failed to open dedicated SessionDB; child persistence disabled", exc_info=True): - from hermes_state import get_shared_session_db + from hermes_state_registry import acquire _parent_db_path = getattr(parent_session_db, "db_path", None) - return get_shared_session_db(_parent_db_path) if _parent_db_path is not None else get_shared_session_db() + return acquire(_parent_db_path) if _parent_db_path is not None else acquire() return None def _build_child_agent( @@ -191,7 +191,7 @@ def _build_child_agent( # No child close() will ever run: release the dedicated handle here. if child_session_db is not None: with _quiet(None): - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(child_session_db) raise child._print_fn = getattr(parent_agent, "_print_fn", None) diff --git a/tools/react_to_message_tool.py b/tools/react_to_message_tool.py index f8056fccae..0308119ba0 100644 --- a/tools/react_to_message_tool.py +++ b/tools/react_to_message_tool.py @@ -15,8 +15,8 @@ from tools.registry import registry, tool_error def _open_session_db(): """Open the SessionDB for the profile owning this turn, or ``None``.""" try: - from hermes_state import get_shared_session_db - return get_shared_session_db() + from hermes_state_registry import acquire + return acquire() except Exception: return None @@ -54,7 +54,7 @@ def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) - return json.dumps({"success": True, "row_id": int(row_id), "reactions": reactions}, ensure_ascii=False) finally: with contextlib.suppress(Exception): - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(db) diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 99c9a26e4b..b8a34be6e8 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -534,10 +534,11 @@ def session_search(query: str = "", role_filter: str = None, limit: int = 3, db= current_session_id: str = None, session_id: str = None, around_message_id: int = None, window: int = 5, sort: str = None, profile: str = None, detail: str = "adaptive") -> str: """Run session search, closing DBs opened here. Positional order is frozen for old callers.""" - from hermes_state import format_session_db_unavailable, get_shared_session_db, release_or_close + from hermes_state import format_session_db_unavailable + from hermes_state_registry import acquire, release_or_close owned_dbs: List[Any] = [] if db is None: - db = _quiet(get_shared_session_db, None, "SessionDB unavailable for session_search") + db = _quiet(acquire, None, "SessionDB unavailable for session_search") if db is None: return tool_error(format_session_db_unavailable(), success=False) owned_dbs.append(db) diff --git a/tui_gateway/compute_host.py b/tui_gateway/compute_host.py index 52b5370339..b5189353ab 100644 --- a/tui_gateway/compute_host.py +++ b/tui_gateway/compute_host.py @@ -282,12 +282,12 @@ class ComputeHost: if profile_home: from hermes_constants import set_hermes_home_override from agent.secret_scope import build_profile_secret_scope, set_secret_scope - from hermes_state import get_shared_session_db + from hermes_state_registry import acquire home_token = set_hermes_home_override(profile_home) secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home))) # DEDICATED handle — ours only until _make_agent succeeds, then the agent owns # it. A RAISING _make_agent is the one path where nothing takes it (``owns_db``). - session_db = get_shared_session_db(Path(profile_home) / "state.db") + session_db = acquire(Path(profile_home) / "state.db") owns_db = True agent = server._make_agent( sid, key, session_id=key, model_override=frame.get("model_override"), @@ -302,7 +302,7 @@ class ComputeHost: finally: if owns_db and session_db is not None: with contextlib.suppress(Exception): - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(session_db) if home_token is not None: with contextlib.suppress(Exception): diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index f56542448b..9ff3b9eed2 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -122,14 +122,15 @@ def _resurrect_recoverable_canonical(db, profile_path, session_id): return False tip_id = _try(lambda: db.get_compression_tip(session_id), None) or session_id tip = (_try(lambda: db.get_session(tip_id), None) or row) if tip_id != session_id else row - from hermes_state import SessionDB, get_shared_session_db + from hermes_state import SessionDB + from hermes_state_registry import acquire if (tip.get("end_reason") or "") not in SessionDB.RECOVERABLE_END_REASONS: return False - wdb = get_shared_session_db(Path(profile_path) / "state.db") + wdb = acquire(Path(profile_path) / "state.db") try: return bool(wdb.unarchive_recoverable_session(session_id)) finally: - _best_effort(lambda: _lazy("hermes_state", "release_or_close")(wdb)) + _best_effort(lambda: _lazy("hermes_state_registry", "release_or_close")(wdb)) except Exception: return False diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index 0ffc6616eb..0b4af43ed9 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -451,7 +451,7 @@ def _persist_session_row_for_submit(rid, session): "was not saved; repair state.db and try again") _persist_branch_seed(session) except Exception as exc: - from hermes_state import is_disk_full_error + from hermes_state_errors import is_disk_full_error with session["history_lock"]: session["running"] = False session["last_active"] = time.time() diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 605db33aab..1c2e13efdf 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -94,14 +94,14 @@ def _make_agent_in_context(sid: str, key: str, **kwargs): def _profile_session_db(profile_home): """``(db, owns)``: a DEDICATED handle on ``profile_home``'s state.db, else the shared launch db.""" if profile_home: - from hermes_state import get_shared_session_db - return get_shared_session_db(Path(profile_home) / "state.db"), True + from hermes_state_registry import acquire + return acquire(Path(profile_home) / "state.db"), True return _get_db(), False def _release_db(db) -> None: with contextlib.suppress(Exception): - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(db) @@ -249,7 +249,7 @@ def _persist_branch(db, new_key: str, parent_key: str, title: str, history: list **{field: msg.get(field) for field in copy_fields}} for msg in history], chunk_rows=500) db.set_session_title(new_key, title) except Exception as exc: - from hermes_state import is_disk_full_error + from hermes_state_errors import is_disk_full_error if compensate and not is_disk_full_error(exc): try: db.delete_session(new_key) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 22a29eaeda..5fa58c8a6e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -374,9 +374,9 @@ _start_idle_reaper() def _get_db(): global _db, _db_error if _db is None: - from hermes_state import get_shared_session_db + from hermes_state_registry import acquire try: - _db, _db_error = get_shared_session_db(), None + _db, _db_error = acquire(), None except Exception as exc: _db_error = str(exc) logger.warning("TUI session store unavailable — continuing without state.db features: %s", exc) @@ -409,10 +409,10 @@ def _open_profile_session_db(profile_home): """Open a DEDICATED handle on ``profile_home``'s ``state.db`` — FAIL CLOSED: a silent fallback to the launch ``state.db`` would bleed rows into the wrong profile's store exactly when the profile store is briefly unopenable (locked, mid-restore); callers let the error abort the build (→ ``agent_error``).""" - from hermes_state import get_shared_session_db + from hermes_state_registry import acquire db_path = Path(profile_home) / "state.db" try: - return get_shared_session_db(db_path) + return acquire(db_path) except Exception as exc: raise RuntimeError(f"profile session store unavailable: {db_path}: {exc}") from exc @@ -428,8 +428,8 @@ def _profile_db(params: dict | None = None): db, owns = _get_db(), False else: try: - from hermes_state import get_shared_session_db - db, owns = get_shared_session_db(Path(profile_home) / "state.db"), True + from hermes_state_registry import acquire + db, owns = acquire(Path(profile_home) / "state.db"), True except Exception as exc: logger.warning("TUI profile session store unavailable for %s: %s", profile, exc) db, owns = None, False diff --git a/tui_gateway/session_workdir.py b/tui_gateway/session_workdir.py index a18ce8bcf6..0122e5eabc 100644 --- a/tui_gateway/session_workdir.py +++ b/tui_gateway/session_workdir.py @@ -291,7 +291,7 @@ def _ensure_session_db_row(session: dict) -> bool: def _workdir_reraise_disk_full(exc: BaseException, log_msg: str) -> None: """Re-raise a disk-full write error (the caller must surface it); debug-log the rest.""" - from hermes_state import is_disk_full_error + from hermes_state_errors import is_disk_full_error if is_disk_full_error(exc): raise exc logger.debug(log_msg, exc_info=True) @@ -341,8 +341,8 @@ def _workdir_owner_db(session: dict, fail_log: str): db, close_db = None, False if profile_home := session.get("profile_home"): try: - from hermes_state import get_shared_session_db - db, close_db = get_shared_session_db(Path(profile_home) / "state.db"), True + from hermes_state_registry import acquire + db, close_db = acquire(Path(profile_home) / "state.db"), True except Exception: logger.debug(fail_log, exc_info=True) db = _WORKDIR_DB_OPEN_FAILED @@ -353,7 +353,7 @@ def _workdir_owner_db(session: dict, fail_log: str): finally: if close_db and db is not None: with contextlib.suppress(Exception): - from hermes_state import release_or_close + from hermes_state_registry import release_or_close release_or_close(db)