From 5ad2b4c6dab78e6e5522c8fc02bcbb89a555f47e Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Thu, 16 Apr 2026 18:51:07 +0800 Subject: [PATCH] fix(session): degrade gracefully when SQLite lacks FTS5 --- hermes_state.py | 23 +++++++++++++++++--- tests/test_hermes_state.py | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 2b6cedeaaf3..7242e6b179c 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -380,6 +380,7 @@ class SessionDB: self._lock = threading.Lock() self._write_count = 0 + self._fts_enabled = False try: self._conn = sqlite3.connect( str(self.db_path), @@ -388,7 +389,6 @@ class SessionDB: # handles contention instead of sitting in SQLite's internal # busy handler for up to 30s. timeout=1.0, - # Autocommit mode: Python's default isolation_level="" # auto-starts transactions on DML, which conflicts with our # explicit BEGIN IMMEDIATE. None = we manage transactions # ourselves. @@ -724,8 +724,22 @@ class SessionDB: # FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably) try: cursor.execute("SELECT * FROM messages_fts LIMIT 0") - except sqlite3.OperationalError: - cursor.executescript(FTS_SQL) + self._fts_enabled = True + except sqlite3.OperationalError as exc: + if "no such table" not in str(exc).lower(): + raise + try: + cursor.executescript(FTS_SQL) + self._fts_enabled = True + except sqlite3.OperationalError as fts_exc: + err = str(fts_exc).lower() + if "fts5" not in err and "no such module" not in err: + raise + logger.warning( + "SQLite FTS5 unavailable for %s; full-text search disabled: %s", + self.db_path, + fts_exc, + ) # Trigram FTS5 for CJK/substring search try: @@ -2317,6 +2331,9 @@ class SessionDB: ignores ``sort``. The trigram CJK path honours ``sort`` like the main FTS5 path. """ + if not self._fts_enabled: + return [] + if not query or not query.strip(): return [] diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index cec3c13f0da..d14f065aec9 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1,11 +1,31 @@ """Tests for hermes_state.py — SessionDB SQLite CRUD, FTS5 search, export.""" +import sqlite3 import time import pytest from hermes_state import SessionDB +class _NoFtsCursor(sqlite3.Cursor): + """Simulate a SQLite build without the fts5 module.""" + + def execute(self, sql, parameters=()): + if sql.strip() == "SELECT * FROM messages_fts LIMIT 0": + raise sqlite3.OperationalError("no such table: messages_fts") + return super().execute(sql, parameters) + + def executescript(self, sql_script): + if "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5" in sql_script: + raise sqlite3.OperationalError("no such module: fts5") + return super().executescript(sql_script) + + +class _NoFtsConnection(sqlite3.Connection): + def cursor(self, factory=None): + return super().cursor(factory or _NoFtsCursor) + + @pytest.fixture() def db(tmp_path): """Create a SessionDB with a temp database file.""" @@ -135,6 +155,29 @@ class TestSessionLifecycle: child = db.get_session("child") assert child["parent_session_id"] == "parent" + def test_db_initializes_without_fts5_module(self, tmp_path, monkeypatch): + real_connect = sqlite3.connect + + def connect_without_fts(*args, **kwargs): + kwargs["factory"] = _NoFtsConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts) + + db = SessionDB(db_path=tmp_path / "state.db") + try: + assert db._fts_enabled is False + + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="hello from sqlite without fts") + + messages = db.get_messages("s1") + assert len(messages) == 1 + assert messages[0]["content"] == "hello from sqlite without fts" + assert db.search_messages("hello") == [] + finally: + db.close() + # ========================================================================= # Message storage