diff --git a/CHANGELOG.md b/CHANGELOG.md index 91255be..5b15c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.38.1] - 2026-06-14 + +Startup reliability fixes for the MCP server and for the Oracle on Windows. + +### Fixed + +- **MCP server intermittently marked "× Failed to connect" on startup.** `VectorStore` + and `EmbeddingIndex` now initialize their chromadb/Ollama backends **lazily on first + use** (lock-guarded, idempotent) instead of eagerly in `build_runtime()`. This cuts + `build_runtime` startup from **~20s to <1s**, so it no longer exceeds Claude Code's + default MCP handshake timeout. The heavy init is warmed in the background by the MCP + `lifespan` and otherwise happens on the first semantic-search / memory call (under the + larger tool timeout). Status views (`c3_status`) stay non-blocking. No external timeout + override (`MCP_TIMEOUT`) is needed anymore. +- **Oracle server startup crash on Windows.** `run_oracle` printed a `→` in its banner, + raising `UnicodeEncodeError` on consoles using the cp1252 code page; `stdout`/`stderr` + are now reconfigured to UTF-8 at startup (covers the banners and the logging handler). + ## [2.38.0] - 2026-06-14 Oracle activity reporting — the Oracle can now produce a cross-project "what happened diff --git a/cli/c3.py b/cli/c3.py index 7cc7f8b..c2e678e 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -85,7 +85,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.38.0" +__version__ = "2.38.1" def _command_deps() -> CommandDeps: diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 57bea93..9131bfc 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -120,12 +120,19 @@ def _bg_build(): services.indexer.build_index() except Exception: pass - # After code index is built, build embedding index - if services.embedding_index and services.embedding_index.ready: + # After code index is built, build embedding index. build() lazily + # inits its chromadb/ollama backends, kept off the handshake path. + if services.embedding_index: try: services.embedding_index.build(services.indexer) except Exception: pass + # Warm SLTM vector store so the first memory call isn't slow. + if services.vector_store: + try: + services.vector_store.warm() + except Exception: + pass # Build doc index for Local RAG Pipeline if services.doc_index: try: @@ -136,15 +143,23 @@ def _bg_build(): threading.Thread(target=_bg_build, daemon=True, name="c3-initial-index").start() else: services.indexer._load_index() - # Build/update embedding index in background - if services.embedding_index and services.embedding_index.ready: + # Build/update embedding index + warm SLTM in background. Deferred off + # the handshake path: build()/warm() lazily init the heavy backends, so + # this must NOT gate on .ready synchronously here. + if services.embedding_index or services.vector_store: import threading def _bg_embed(): - try: - services.embedding_index.build(services.indexer) - except Exception: - pass + if services.embedding_index: + try: + services.embedding_index.build(services.indexer) + except Exception: + pass + if services.vector_store: + try: + services.vector_store.warm() + except Exception: + pass threading.Thread(target=_bg_embed, daemon=True, name="c3-embed-index").start() diff --git a/pyproject.toml b/pyproject.toml index 256358c..bb09d15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.38.0" +version = "2.38.1" description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer." readme = "README.md" requires-python = ">=3.10" diff --git a/services/embedding_index.py b/services/embedding_index.py index 90b4d0d..4a6a7b3 100644 --- a/services/embedding_index.py +++ b/services/embedding_index.py @@ -42,11 +42,33 @@ def __init__( self._lock = threading.Lock() self._chunk_map: dict[str, dict] = {} # chunk_id -> metadata - self._init_backends() - self._load_hashes() + # Heavy backend init (chromadb import/client + ollama probe) and hash + # load are deferred to first use so build_runtime stays fast and the MCP + # handshake doesn't time out. See _ensure_ready(). + self._initialized = False + self._init_lock = threading.Lock() # ── Backend init ────────────────────────────────────── + def _ensure_ready(self): + """Lazily init chromadb/ollama backends + file hashes on first use. + + Deferred from __init__ so build_runtime (and the MCP handshake) stays + fast. Idempotent and thread-safe via double-checked locking. + """ + if self._initialized: + return + with self._init_lock: + if self._initialized: + return + self._init_backends() + self._load_hashes() + self._initialized = True + + def warm(self): + """Pre-initialize backends (used for background warm-up).""" + self._ensure_ready() + def _init_backends(self): """Initialize chromadb collection and check Ollama.""" try: @@ -116,6 +138,7 @@ def build(self, code_index, force: bool = False) -> dict: Returns: Stats dict with files_processed, chunks_embedded, chunks_skipped, etc. """ + self._ensure_ready() if not self.ready: return {"error": "Embedding backends unavailable", "available": False} @@ -261,6 +284,7 @@ def search( Returns list of dicts with: file, lines, name, type, content, score, tokens. """ + self._ensure_ready() if not self.ready or not self._collection or self._collection.count() == 0: return [] diff --git a/services/vector_store.py b/services/vector_store.py index d8e0d1e..e4d30fd 100644 --- a/services/vector_store.py +++ b/services/vector_store.py @@ -38,14 +38,37 @@ def __init__(self, project_path: str, config: dict | None = None): self._collections: dict[str, object] = {} self._chroma_available = False self._ollama_available = False - self._init_backends() self._fallback_dir = self.project_path / ".c3" / "sltm" / "fallback" self._fallback_dir.mkdir(parents=True, exist_ok=True) self._fallback_data: dict[str, list[dict]] = {} self._records_by_id: dict[str, dict] = {} self._text_index = TextIndex() - self._load_fallback() + + # Heavy backend init (chromadb import/client + ollama probe) and the + # fallback load are deferred to first use so build_runtime stays fast + # and the MCP handshake doesn't time out. See _ensure_ready(). + self._initialized = False + self._init_lock = threading.Lock() + + def _ensure_ready(self): + """Lazily init chromadb/ollama backends + fallback data on first use. + + Deferred from __init__ so build_runtime (and the MCP handshake) stays + fast. Idempotent and thread-safe via double-checked locking. + """ + if self._initialized: + return + with self._init_lock: + if self._initialized: + return + self._init_backends() + self._load_fallback() + self._initialized = True + + def warm(self): + """Pre-initialize backends (used for background warm-up).""" + self._ensure_ready() def _init_backends(self): if self.config.get("disable_vector_backend"): @@ -85,6 +108,7 @@ def add( record_id: str | None = None, ) -> dict: """Store a record in the SLTM using a stable record id when provided.""" + self._ensure_ready() if category not in SLTM_CATEGORIES: category = "general" @@ -133,6 +157,7 @@ def add( } def search(self, query: str, category: str = "", top_k: int = 5) -> list[dict]: + self._ensure_ready() categories = [category] if category and category in SLTM_CATEGORIES else list(SLTM_CATEGORIES) allowed = set(categories) @@ -204,6 +229,7 @@ def search(self, query: str, category: str = "", top_k: int = 5) -> list[dict]: return results def delete(self, record_id: str) -> dict: + self._ensure_ready() deleted = False with self._lock: deleted = self._delete_locked(record_id) diff --git a/tests/test_lazy_store_init.py b/tests/test_lazy_store_init.py new file mode 100644 index 0000000..3e443a1 --- /dev/null +++ b/tests/test_lazy_store_init.py @@ -0,0 +1,77 @@ +"""Lazy backend init for VectorStore / EmbeddingIndex. + +Regression guard for the MCP startup-latency fix: constructing these stores must +NOT trigger the heavy chromadb/ollama init — that must happen on first *use*, off +the MCP handshake path. See services/runtime.py build_runtime + the _ensure_ready +methods on each store. +""" +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from services.embedding_index import EmbeddingIndex +from services.vector_store import VectorStore + + +class _StubOllama: + def is_available(self, timeout=None): + return False + + def has_model(self, model=None): + return False + + def embed(self, text, model=None): + return None + + +class TestLazyStoreInit(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.project = Path(self.tmp.name) + (self.project / ".c3").mkdir() + + def test_vector_store_defers_init_until_first_use(self): + vs = VectorStore(str(self.project), config={"disable_vector_backend": True}) + self.assertFalse(vs._initialized) # not initialized on construct + # status reporters must NOT trigger init + vs.get_stats() + self.assertFalse(vs.vector_enabled) + self.assertFalse(vs._initialized) + # a work method triggers init + vs.search("anything") + self.assertTrue(vs._initialized) + + def test_embedding_index_defers_init_until_first_use(self): + ei = EmbeddingIndex(str(self.project), _StubOllama()) + calls = [] + ei._init_backends = lambda: calls.append("init") # avoid real chromadb + ei._load_hashes = lambda: calls.append("hashes") + self.assertFalse(ei._initialized) + # status reporters must NOT trigger init + self.assertFalse(ei.ready) + ei.get_stats() + self.assertFalse(ei._initialized) + self.assertEqual(calls, []) + # a work method triggers exactly-once init + ei.search("anything") + self.assertTrue(ei._initialized) + self.assertEqual(calls, ["init", "hashes"]) + ei.search("again") # idempotent + self.assertEqual(calls, ["init", "hashes"]) + + def test_warm_initializes(self): + vs = VectorStore(str(self.project), config={"disable_vector_backend": True}) + self.assertFalse(vs._initialized) + vs.warm() + self.assertTrue(vs._initialized) + + +if __name__ == "__main__": + unittest.main()