Skip to content
80 changes: 78 additions & 2 deletions src/context_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3426,6 +3426,14 @@ async def _run_serve(config) -> None:
# gets the multiprocess path.
os.environ.setdefault("CCE_EMBED_PARALLEL", "0")

# Cap ONNX Runtime threads per process (#139). With N concurrent
# cce-serve processes, uncapped threads (default = cpu_count) create
# thousands of OS threads competing for cores and page-cache.
from context_engine.resource_governor import (
cap_ort_threads, IdleTracker, ProjectIndexLock, is_memory_pressured,
)
cap_ort_threads(max_threads=getattr(config, "serve_max_ort_threads", None))

from context_engine.storage.local_backend import LocalBackend
from context_engine.indexer.embedder import Embedder
from context_engine.retrieval.retriever import HybridRetriever
Expand All @@ -3452,6 +3460,15 @@ async def _run_serve(config) -> None:
embedder=embedder, config=config,
)

# Idle tracker — shuts down the server after prolonged inactivity (#139).
idle_tracker = IdleTracker(
timeout_minutes=getattr(config, "serve_idle_timeout_minutes", None),
)

# Per-project index lock — prevents N duplicate cce-serve processes from
# all indexing the same project simultaneously (#139).
index_lock = ProjectIndexLock(storage_base)

chunk_count = backend._vector_store.count()
import sys

Expand All @@ -3474,15 +3491,45 @@ async def _on_file_change(file_path: str):
await _reindex_queue.put(rel)

async def _reindex_worker():
"""Background task that processes re-index requests sequentially."""
"""Background task that processes re-index requests sequentially.

Acquires a per-project file lock before indexing so duplicate
cce-serve processes for the same project don't all index at
once. Backs off when the system is under memory pressure
(Linux PSI). See #139.
"""
while True:
rel = await _reindex_queue.get()
_reindex_pending.discard(rel)
# Back off under memory pressure (#139). Re-queue the
# file so it isn't lost — without this, the last change
# during sustained pressure would leave the index stale.
if is_memory_pressured():
_log.info(
"Memory pressure detected; deferring re-index of %s",
rel,
)
_reindex_queue.task_done()
await asyncio.sleep(30)
# Re-queue for retry after pressure subsides.
if rel not in _reindex_pending:
_reindex_pending.add(rel)
await _reindex_queue.put(rel)
continue
if not index_lock.try_acquire():
_log.debug(
"Another process is indexing this project; "
"skipping %s", rel,
)
_reindex_queue.task_done()
continue
try:
await run_indexing(config, project_dir, target_path=rel)
_log.debug("Re-indexed: %s", rel)
except Exception as exc:
_log.warning("Watch re-index failed for %s: %s", rel, exc)
finally:
index_lock.release()
_reindex_queue.task_done()

watcher = FileWatcher(
Expand Down Expand Up @@ -3535,11 +3582,18 @@ async def _reindex_worker():
except Exception as exc:
_log.warning("Auto-prune worker failed to start: %s", exc)

# Inject idle tracker so every MCP tool call resets the idle timer (#139).
mcp._idle_tracker = idle_tracker

watcher_label = " · live watcher active" if watcher else ""
hook_label = f" · memory hooks :{hook_port}" if hook_port else ""
idle_label = (
f" · idle shutdown {idle_tracker.timeout_seconds // 60}m"
if idle_tracker.timeout_seconds > 0 else ""
)
print(
f"CCE ready · {project_name} · {chunk_count} chunks indexed"
f"{watcher_label}{hook_label}",
f"{watcher_label}{hook_label}{idle_label}",
file=sys.stderr,
)

Expand All @@ -3551,6 +3605,26 @@ async def _reindex_worker():
serve_loop = asyncio.get_running_loop()
mcp_task = asyncio.create_task(mcp.run_stdio())

# Idle-shutdown loop: periodically check if the server has been unused
# for longer than the configured timeout and initiate shutdown (#139).
async def _idle_watchdog():
while True:
await asyncio.sleep(60)
if idle_tracker.is_idle():
idle_min = int(idle_tracker.idle_seconds // 60)
_log.info(
"No MCP activity for %d minutes; shutting down", idle_min,
)
print(
f"CCE idle for {idle_min}m — shutting down "
f"(set CCE_IDLE_TIMEOUT_MINUTES=0 to disable)",
file=sys.stderr,
)
mcp_task.cancel()
return

idle_task = asyncio.create_task(_idle_watchdog())

def _request_shutdown(signame: str) -> None:
if not mcp_task.done():
_log.info("Received %s, shutting down...", signame)
Expand Down Expand Up @@ -3588,6 +3662,7 @@ def _request_shutdown(signame: str) -> None:
except asyncio.CancelledError:
pass
finally:
idle_task.cancel()
for _sig in installed_signals:
try:
serve_loop.remove_signal_handler(_sig)
Expand Down Expand Up @@ -3618,3 +3693,4 @@ def _request_shutdown(signame: str) -> None:
await hook_runner.cleanup()
except Exception:
_log.warning("hook_runner cleanup failed", exc_info=True)
index_lock.release()
10 changes: 10 additions & 0 deletions src/context_engine/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ class Config:
indexer_watch: bool = True
indexer_debounce_ms: int = 500
indexer_ignore: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORE))
# Resource governor (#139) — caps per-process ONNX Runtime threads and
# auto-shuts down idle servers so zombie processes don't accumulate.
# 0 = disabled (no auto-shutdown / use ORT default threads).
serve_idle_timeout_minutes: int = 30
serve_max_ort_threads: int = 2
Comment on lines +82 to +86

# When True, the indexer skips well-known credential filenames
# (.env*, *.pem, secrets.yml, credentials.json, …) and redacts
# AWS/GitHub/JWT/etc. patterns from the content of files it does
Expand Down Expand Up @@ -140,6 +146,8 @@ def _deep_merge(base: dict, override: dict) -> dict:
"indexer_watch": bool,
"indexer_debounce_ms": int,
"indexer_ignore": list,
"serve_idle_timeout_minutes": int,
"serve_max_ort_threads": int,
"indexer_redact_secrets": bool,
"memory_redact_pii": bool,
"audit_log_enabled": bool,
Expand All @@ -162,6 +170,8 @@ def _apply_dict_to_config(config: Config, data: dict) -> None:
("retrieval", "top_k"): "retrieval_top_k",
("retrieval", "marginal_ratio"): "retrieval_marginal_ratio",
("retrieval", "bootstrap_max_tokens"): "bootstrap_max_tokens",
("serve", "idle_timeout_minutes"): "serve_idle_timeout_minutes",
("serve", "max_ort_threads"): "serve_max_ort_threads",
("indexer", "watch"): "indexer_watch",
("indexer", "debounce_ms"): "indexer_debounce_ms",
("indexer", "ignore"): "indexer_ignore",
Expand Down
5 changes: 5 additions & 0 deletions src/context_engine/integration/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,9 @@ def __init__(self, retriever, backend, compressor, embedder, config) -> None:
self._compressor = compressor
self._embedder = embedder
self._config = config
# Set by _run_serve after construction; reset on every tool call
# so the idle-shutdown watchdog knows the server is in use (#139).
self._idle_tracker = None
# Propagate the PII-redaction toggle to the memory module's
# process-global state. Done at MCPServer boot — the compressor
# and migrate paths read from the same module-level flag.
Expand Down Expand Up @@ -863,6 +866,8 @@ async def list_tools():
@self._server.call_tool()
async def call_tool(name: str, arguments: dict):
arguments = arguments or {}
if self._idle_tracker is not None:
self._idle_tracker.touch()
try:
if name == "context_search":
return await self._handle_context_search(arguments)
Expand Down
Loading
Loading