diff --git a/src/kiro_crew/memory.py b/src/kiro_crew/memory.py index 2a9d0c2f80e2..eb4cd8c32626 100644 --- a/src/kiro_crew/memory.py +++ b/src/kiro_crew/memory.py @@ -1193,15 +1193,23 @@ def _try_create_db(self) -> sqlite3.Connection: # the same once-per-store latch as the restrict itself. prepare_owner_only_sqlite(self._index_db) conn = sqlite3.connect(str(self._index_db), timeout=_DB_BUSY_TIMEOUT_SECS) - # Wait out transient 'database is locked' contention instead of letting - # it surface (where the self-heal would misread it as corruption). - conn.execute(f"PRAGMA busy_timeout={int(_DB_BUSY_TIMEOUT_SECS * 1000)}") - conn.execute( - "CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(" - "path, content, tokenize='porter unicode61')" - ) - if restrict: # and cover whatever SQLite just created; retry next open on failure - self._index_owner_only = self._restrict_index_files() + try: + # Wait out transient 'database is locked' contention instead of letting + # it surface (where the self-heal would misread it as corruption). + conn.execute(f"PRAGMA busy_timeout={int(_DB_BUSY_TIMEOUT_SECS * 1000)}") + conn.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(" + "path, content, tokenize='porter unicode61')" + ) + if restrict: # and cover whatever SQLite just created; retry next open on failure + self._index_owner_only = self._restrict_index_files() + except BaseException: + # The connection is never handed out, but the raised error's traceback + # keeps it open while _get_db's self-heal deletes the index -- and on + # Windows an open handle makes that delete fail, so the index is never + # rebuilt. + conn.close() + raise return conn def _restrict_index_files(self) -> bool: diff --git a/test/test_memory_selfheal.py b/test/test_memory_selfheal.py index b54f75650074..dc5371041e7a 100644 --- a/test/test_memory_selfheal.py +++ b/test/test_memory_selfheal.py @@ -15,6 +15,8 @@ import threading from datetime import date, datetime, timedelta +import pytest + from kiro_crew import memory as memory_module from kiro_crew._sqlite_compat import sqlite3 from kiro_crew.memory import MemoryStore, _is_corruption_error @@ -117,6 +119,36 @@ def test_genuine_corruption_still_self_heals(self, tmp_path): results = store.search("Python") assert len(results) >= 1 + def test_self_heal_closes_the_connection_that_found_the_corruption(self, tmp_path, monkeypatch): + """The open that met the corruption is closed before the index is deleted. + + Windows refuses to delete a file that has an open handle, so a connection + left to the collector keeps the corrupt index in place and every later + open meets it again. + """ + store = MemoryStore(workspace=tmp_path) + store.init() + (tmp_path / "memory_index.db").write_bytes(b"this is not a valid sqlite database") + + opened: list[sqlite3.Connection] = [] + real_connect = memory_module.sqlite3.connect + + def recording_connect(*args, **kwargs): + conn = real_connect(*args, **kwargs) + opened.append(conn) + return conn + + monkeypatch.setattr(memory_module.sqlite3, "connect", recording_connect) + conn = store._get_db() + try: + assert len(opened) >= 2, "the corrupt index was never opened and healed" + assert opened[-1] is conn + for stale in opened[:-1]: + with pytest.raises(sqlite3.ProgrammingError, match="closed"): + stale.execute("SELECT 1") + finally: + conn.close() + def test_connection_has_busy_timeout(self, tmp_path): """The connection must carry a non-zero busy_timeout to wait out locks.""" store = MemoryStore(workspace=tmp_path) diff --git a/test/windows-expected-failures.txt b/test/windows-expected-failures.txt index 464674153866..8b7426d4698d 100644 --- a/test/windows-expected-failures.txt +++ b/test/windows-expected-failures.txt @@ -106,7 +106,6 @@ test/test_mcp_discovery_probe_stdio.py::TestProbeServerStderrCapture::test_stder test/test_mcp_discovery_probe_stdio.py::TestProbeServerStderrCapture::test_successful_probe_does_not_mention_stderr # Needs symlink-creation privilege: builds an in-vault FILE symlink # (Alias.md -> One.md); a junction is directory-only and cannot model it. -test/test_memory_selfheal.py::TestFtsSelfHealGating::test_genuine_corruption_still_self_heals test/test_pip_deps_consistency.py::test_all_unguarded_third_party_imports_are_declared # Needs symlink-creation privilege: plants a FILE symlink at the output path to # prove the writer replaces it rather than writing through. atomic_write already