diff --git a/hermes_state.py b/hermes_state.py index b25bd60978..07fbcbcd5d 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -5758,13 +5758,26 @@ class SessionDB: like_cursor = self._conn.execute(like_sql, like_params) matches = [dict(row) for row in like_cursor.fetchall()] else: - with self._lock: - try: + try: + with self._lock: + cursor = self._conn.execute(sql, params) + matches = [dict(row) for row in cursor.fetchall()] + except sqlite3.OperationalError: + # FTS5 query syntax error despite sanitization — return empty + return [] + except sqlite3.DatabaseError as exc: + # A corrupt FTS index raises the malformed / "fts5: corrupt + # structure record" class on the MATCH read, the same class the + # write path self-heals (#66296). OperationalError (query + # syntax) is a subclass caught above; this arm is the corruption + # parent. Rebuild the index in place once — the lock is released + # here, so rebuild_fts() can re-acquire it — and retry, so + # search self-heals for read-only sessions (cron/CLI history + # search) that never trigger a write to repair it first. + if not self._try_runtime_fts_rebuild(exc): + raise + with self._lock: cursor = self._conn.execute(sql, params) - except sqlite3.OperationalError: - # FTS5 query syntax error despite sanitization — return empty - return [] - else: matches = [dict(row) for row in cursor.fetchall()] # Add surrounding context (1 message before + after each match). diff --git a/tests/state/test_fts_runtime_rebuild.py b/tests/state/test_fts_runtime_rebuild.py index 45c7a7c53b..cf91b474e1 100644 --- a/tests/state/test_fts_runtime_rebuild.py +++ b/tests/state/test_fts_runtime_rebuild.py @@ -93,6 +93,29 @@ class TestRuntimeFtsRebuild: raw.close() assert len(hits) == 1 + def test_search_messages_self_heals_after_fts_corruption(self, db, tmp_path): + """A read-only session that only SEARCHES (no write after corruption) + must self-heal too. The MATCH read raises the corruption class + (DatabaseError / 'fts5: corrupt structure record'), NOT the + OperationalError that search_messages caught — so before this fix the + search crashed until a write or restart rebuilt the index. + """ + if not db._fts_enabled: + pytest.skip("FTS5 unavailable in this build") + db.create_session("s1", source="test") + db.append_message("s1", "user", "a searchable needle here") + + _corrupt_fts(tmp_path / "state.db") + # Injected via a raw connection, so no write on THIS instance has + # consumed the one-shot rebuild yet. + assert db._fts_runtime_rebuild_attempted is False + + results = db.search_messages("needle") + + assert db._fts_runtime_rebuild_attempted is True # the search rebuilt it + assert results # non-empty: the rebuilt index matched the query + assert any("needle" in (r.get("snippet") or "") for r in results) + def test_rebuild_is_one_shot_per_instance(self, db, tmp_path): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build")