From 5ca028d16aef8623ac9bfe8b6147ef9874728251 Mon Sep 17 00:00:00 2001 From: fangliquan Date: Tue, 8 Sep 2026 21:50:58 +0800 Subject: [PATCH] fix(sessions): restore trigram after deferred bootstrap --- hermes_state_schema.py | 19 ++++++++++++++++++- .../test_fts_fresh_bootstrap_admission.py | 17 +++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/hermes_state_schema.py b/hermes_state_schema.py index b8a078c455..85e9f21d85 100644 --- a/hermes_state_schema.py +++ b/hermes_state_schema.py @@ -547,10 +547,12 @@ class SessionSchemaMixin: """Body of :meth:`_recover_stale_fts`; caller holds rebuild authority. One write transaction, so no canonical writer slips between rebuild and trigger restoration.""" try: - include_trigram = self._fts_table_probe(cursor, "messages_fts_trigram") is True + trigram_present = self._fts_table_probe(cursor, "messages_fts_trigram") is True except (sqlite3.DatabaseError, UnicodeDecodeError): # A corrupt vtable may fail even a LIMIT 0 probe; still include it in the drop-and-recreate. include_trigram = True + else: + include_trigram = trigram_present or (not legacy and self._trigram_tokenizer_available(cursor)) drop_sql = "".join(f"DROP TRIGGER IF EXISTS {trigger};" for trigger in _FTS_TRIGGERS) if include_trigram: @@ -590,6 +592,21 @@ class SessionSchemaMixin: logger.warning("Rebuilt stale state.db FTS indexes from canonical messages and restored sync triggers.") return True + def _trigram_tokenizer_available(self, cursor: sqlite3.Cursor) -> bool: + """Probe trigram support without publishing a persistent FTS object.""" + probe = "temp.hermes_fts5_trigram_probe" + cursor.execute(f"DROP TABLE IF EXISTS {probe}") + try: + cursor.execute(f"CREATE VIRTUAL TABLE {probe} USING fts5(content, tokenize='trigram')") + except sqlite3.OperationalError as exc: + if not self._is_trigram_unavailable_error(exc): + raise + self._warn_trigram_unavailable(exc) + return False + finally: + cursor.execute(f"DROP TABLE IF EXISTS {probe}") + return True + # ── Declarative column reconciliation ────────────────────────────────── @staticmethod diff --git a/tests/state/test_fts_fresh_bootstrap_admission.py b/tests/state/test_fts_fresh_bootstrap_admission.py index e3e91a2ffc..a963e7d83d 100644 --- a/tests/state/test_fts_fresh_bootstrap_admission.py +++ b/tests/state/test_fts_fresh_bootstrap_admission.py @@ -1,5 +1,6 @@ """Fresh state.db FTS bootstrap must honor cross-process rebuild admission.""" +import sqlite3 import subprocess import sys from pathlib import Path @@ -22,6 +23,17 @@ with fts_rebuild_admission(Path(sys.argv[1]), timeout_seconds=0) as acquired: """ +def _trigram_supported() -> bool: + conn = sqlite3.connect(":memory:") + try: + conn.execute("CREATE VIRTUAL TABLE trigram_probe USING fts5(content, tokenize='trigram')") + except sqlite3.OperationalError: + return False + finally: + conn.close() + return True + + def test_fresh_fts_bootstrap_does_not_publish_schema_without_admission(tmp_path, monkeypatch): """A fresh opener that loses admission leaves no partially initialized FTS surface.""" db_path = tmp_path / "state.db" @@ -53,6 +65,11 @@ def test_fresh_fts_bootstrap_does_not_publish_schema_without_admission(tmp_path, try: assert recovered._fts_stale is False assert recovered._fts_enabled is True + if _trigram_supported(): + assert recovered._trigram_available is True + assert recovered._conn.execute( + "SELECT 1 FROM sqlite_master WHERE name = 'messages_fts_trigram'" + ).fetchone() is not None recovered.create_session("s1", source="test") recovered.append_message("s1", "user", "fresh bootstrap recovered") assert recovered.search_messages("recovered")