Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 23 additions & 8 deletions cli/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 26 additions & 2 deletions services/embedding_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +45 to +66

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:
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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 []

Expand Down
30 changes: 28 additions & 2 deletions services/vector_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
77 changes: 77 additions & 0 deletions tests/test_lazy_store_init.py
Original file line number Diff line number Diff line change
@@ -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()
Loading