diff --git a/hermes_state_search.py b/hermes_state_search.py index 4f7744fb4cfc2..117840a4610a9 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -1208,7 +1208,8 @@ def rebuild_fts(self) -> int: Uses the FTS5 ``'rebuild'`` command, which rewrites the internal b-tree segments from the content rows. Unlike ``optimize_fts`` (which merges existing segments), ``rebuild`` discards and recreates - the index data entirely. See #50502. + the index data entirely — the more destructive of the two, so it is quarantined the same way. See + #50502. A full structural rebuild must never run concurrently in two processes sharing one state.db — that interleaving has structurally corrupted the database in production (PR #93200) — so this admits through the cross-process ``fts_rebuild_admission`` authority and FAILS CLOSED: if another process @@ -1217,6 +1218,8 @@ def rebuild_fts(self) -> int: path, which retries in-process from the gateway housekeeping tick (``retry_deferred_fts_recovery``) and at next startup. """ + self._raise_if_db_corrupt() + self._raise_if_db_replaced() rebuilt = 0 with fts_rebuild_admission(self.db_path) as admitted: if not admitted: diff --git a/tests/hermes_state/test_state_db_corrupt_quarantine.py b/tests/hermes_state/test_state_db_corrupt_quarantine.py index 05475ba3aacec..4228abf6603a9 100644 --- a/tests/hermes_state/test_state_db_corrupt_quarantine.py +++ b/tests/hermes_state/test_state_db_corrupt_quarantine.py @@ -307,3 +307,26 @@ def test_optimize_fts_refuses_when_quarantined(self, tmp_path, flag_name, expect _clear_flag(db, flag_name) db._conn = real_conn db.close() + + @pytest.mark.parametrize("flag_name,expected_exc", _QUARANTINE_FLAGS) + def test_rebuild_fts_refuses_when_quarantined(self, tmp_path, flag_name, expected_exc): + """rebuild_fts() is reachable outside _execute_write's own quarantine check — the gateway's + FTS-corruption transcript-retry path (gateway/session_transcript.py::_rebuild_fts_once) + calls it directly. Unlike optimize_fts ("merges existing segments"), rebuild_fts "discards + and recreates the index data entirely" — strictly more destructive — so it must refuse at + least as eagerly.""" + db = SessionDB(db_path=tmp_path / "state.db") + real_conn = db._conn + try: + db.create_session(session_id="s1", source="cli", model="test") + db.append_message("s1", role="user", content="hello world") + recorder = _RecordingConn(real_conn) + db._conn = recorder + _force_flag(db, flag_name) + with pytest.raises(expected_exc): + db.rebuild_fts() + assert recorder.recorded == [] + finally: + _clear_flag(db, flag_name) + db._conn = real_conn + db.close()