diff --git a/CHANGELOG.md b/CHANGELOG.md index 42919130..137a8a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- When the session index is missing, WebUI now starts a background rebuild while preserving the first sidebar full-scan result, so the index is primed for later requests without temporarily hiding existing sessions. + ## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults) ### Fixed diff --git a/api/models.py b/api/models.py index fcd12ff5..3cd6cb24 100644 --- a/api/models.py +++ b/api/models.py @@ -51,6 +51,8 @@ _STALE_TMP_AGE_SECONDS = 3600 # 1 hour # Serializes index writers so concurrent Session.save() calls cannot race on # stale baselines while still allowing LOCK to be released before disk I/O. _INDEX_WRITE_LOCK = threading.RLock() +_SESSION_INDEX_REBUILD_LOCK = threading.Lock() +_SESSION_INDEX_REBUILD_THREAD = None def _cleanup_stale_tmp_files() -> None: @@ -74,6 +76,32 @@ def _cleanup_stale_tmp_files() -> None: pass # SESSION_DIR may not exist yet; that's fine +def _rebuild_session_index_background() -> None: + try: + _write_session_index(updates=None) + except Exception: + logger.debug("Background session-index rebuild failed", exc_info=True) + + +def _start_session_index_rebuild_thread() -> None: + """Start one background full-index rebuild if the index is missing.""" + global _SESSION_INDEX_REBUILD_THREAD + with _SESSION_INDEX_REBUILD_LOCK: + if SESSION_INDEX_FILE.exists(): + return + if ( + _SESSION_INDEX_REBUILD_THREAD is not None + and _SESSION_INDEX_REBUILD_THREAD.is_alive() + ): + return + _SESSION_INDEX_REBUILD_THREAD = threading.Thread( + target=_rebuild_session_index_background, + name="session-index-rebuild", + daemon=True, + ) + _SESSION_INDEX_REBUILD_THREAD.start() + + def _index_entry_exists(session_id: str, in_memory_ids=None) -> bool: """Return True if an index entry still has backing state. @@ -2236,6 +2264,9 @@ def all_sessions(diag=None): active_stream_ids = _active_stream_ids() # Phase C: try index first for O(1) read; fall back to full scan _diag_stage(diag, "all_sessions.index_exists") + if not SESSION_INDEX_FILE.exists(): + _diag_stage(diag, "all_sessions.start_index_rebuild") + _start_session_index_rebuild_thread() if SESSION_INDEX_FILE.exists(): try: _diag_stage(diag, "all_sessions.read_index") diff --git a/tests/test_issue2863_session_index_prime.py b/tests/test_issue2863_session_index_prime.py new file mode 100644 index 00000000..62d42c76 --- /dev/null +++ b/tests/test_issue2863_session_index_prime.py @@ -0,0 +1,41 @@ +"""Regression tests for #2863 missing session-index background rebuild.""" +from __future__ import annotations + +import json +import time + + +def test_missing_index_starts_background_rebuild_while_preserving_first_scan(monkeypatch, tmp_path): + import api.models as models + from api.models import all_sessions + + session_dir = tmp_path / "sessions" + session_dir.mkdir(parents=True) + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") + models.SESSIONS.clear() + + for idx in range(3): + payload = { + "session_id": f"issue2863{idx}", + "title": f"Session {idx}", + "workspace": str(tmp_path), + "model": "test-model", + "messages": [{"role": "user", "content": f"hello {idx}", "timestamp": time.time() + idx}], + "created_at": time.time() + idx, + "updated_at": time.time() + idx, + } + (session_dir / f"issue2863{idx}.json").write_text(json.dumps(payload), encoding="utf-8") + + rows = all_sessions() + + assert {row["session_id"] for row in rows} == {"issue28630", "issue28631", "issue28632"} + + thread = models._SESSION_INDEX_REBUILD_THREAD + assert thread is not None + thread.join(timeout=5) + assert not thread.is_alive() + + index = json.loads(models.SESSION_INDEX_FILE.read_text(encoding="utf-8")) + assert {row["session_id"] for row in index} == {"issue28630", "issue28631", "issue28632"} + assert {row["session_id"] for row in all_sessions()} == {"issue28630", "issue28631", "issue28632"}