diff --git a/cli.py b/cli.py index 58702f1967..2edadb4966 100644 --- a/cli.py +++ b/cli.py @@ -2835,8 +2835,12 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix self._session_db = None self._session_db_unavailable = False try: - from hermes_state import SessionDB - self._session_db = SessionDB() + # Registry handle, not a bare SessionDB(): goals/loops/heartbeat acquire the same + # path a moment later from the REPL thread, and a second writer repeats the full + # open (the /proc-wide deleted-WAL scan, ~4k readlinks) while the render thread + # holds the GIL — that repeat was the post-banner freeze before the first prompt. + from hermes_state_registry import acquire + self._session_db = acquire() except Exception as e: # Without a store the transcript is NOT persisted while the chat looks healthy, # so surface it prominently rather than only logging. diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 93bdd481c7..606089336a 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -513,8 +513,8 @@ class CLIAgentSetupMixin: logger=logger, single_query=getattr(self, "_single_query_mode", False)) if self._session_db is None: try: - from hermes_state import SessionDB - self._session_db = SessionDB() + from hermes_state_registry import acquire + self._session_db = acquire() except Exception as e: logger.warning("SQLite session store not available — session will NOT be indexed: %s", e) if ( diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 625ace7d41..bcbddee47d 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1176,8 +1176,8 @@ class CLICommandsMixin: return _cp(" Agent is busy. Wait for the current turn to finish, then retry /handoff.") if not self._session_db: with suppress(Exception): - from hermes_state import SessionDB - self._session_db = SessionDB() + from hermes_state_registry import acquire + self._session_db = acquire() if not self._session_db: return _cp(_db_unavailable_line()) # Ensure the session row exists (an empty session has flushed nothing yet): the gateway diff --git a/tests/test_cli_session_store_shared_registry.py b/tests/test_cli_session_store_shared_registry.py new file mode 100644 index 0000000000..625274dd58 --- /dev/null +++ b/tests/test_cli_session_store_shared_registry.py @@ -0,0 +1,37 @@ +"""The CLI's session store must be the registry's shared handle for state.db. + +``_init_session_store`` used to construct a bare ``SessionDB()``. The goal/loop/heartbeat +managers acquire the same path through ``hermes_state_registry`` from the REPL thread a +moment later, so a bare handle meant a SECOND full open — including the /proc-wide +deleted-WAL sidecar scan (~4k readlinks, each a GIL round-trip against the busy startup +threads) — which showed up as the post-banner freeze before the first prompt. +""" + +from types import SimpleNamespace + +import hermes_cli.goals as goals +from cli import HermesCLI + + +def test_cli_session_store_is_the_registry_handle_goals_reuse(monkeypatch): + import hermes_state_registry + + monkeypatch.setattr(goals, "_DB_CACHE", {}) + constructed = [] + real_open = hermes_state_registry._open_session_db + + def recording_open(path): + constructed.append(path) + return real_open(path) + + monkeypatch.setattr(hermes_state_registry, "_open_session_db", recording_open) + + cli = SimpleNamespace() + try: + HermesCLI._init_session_store(cli) + assert cli._session_db is not None and not cli._session_db_unavailable + # goals/loops/heartbeat go through the registry: same object, no second open. + assert goals._get_session_db() is cli._session_db + assert len(constructed) == 1 + finally: + hermes_state_registry.close_all()