Skip to content
Merged
9 changes: 9 additions & 0 deletions src/lemoncrow/core/settings_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,15 @@ class SettingSpec:
"code_context",
"Enable the on-disk tag cache for repo-map generation.",
),
SettingSpec(
"code_context.worktree_engine_idle_s",
"LEMONCROW_WORKTREE_ENGINE_IDLE_S",
"float",
1800.0,
"code_context",
"Seconds a git worktree's code engine may go without a request before the daemon unloads it "
"(its index stays on disk; the next request reopens it). The main checkout's engine never unloads.",
),
SettingSpec(
"code_context.zoekt_gate",
"LEMONCROW_ZOEKT_GATE",
Expand Down
51 changes: 18 additions & 33 deletions src/lemoncrow/gateway/adapters/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
tool_web_fetch,
)
from lemoncrow.gateway.adapters.mcp_branding import icon_metadata
from lemoncrow.infra.code_intel import worktree_seed
from lemoncrow.infra.code_intel.completeness import (
CODE_OP_MATCH_KINDS,
CODE_OP_OBJECTIVES,
Expand Down Expand Up @@ -3778,38 +3779,8 @@ def _record_session_cwd(name: str, args: Any) -> None:


def _linked_worktree_root(workspace_root: Path, candidate_dir: Path) -> Path | None:
"""The linked worktree of ``workspace_root`` that contains ``candidate_dir``, else None.

Detected without spawning git: a linked worktree's ``.git`` is a *file*
holding ``gitdir: <path>``, and for a worktree of THIS repo that path lives
under ``<workspace_root>/.git/worktrees/``. A normal checkout has ``.git``
as a directory, which ends the walk immediately.

Returns None for every uncertain case -- a plain directory, a worktree
belonging to a different repo, the workspace root itself.
"""
try:
candidate = candidate_dir.expanduser().resolve()
if not candidate.is_dir():
return None
root = workspace_root.resolve()
worktrees_dir = (root / ".git" / "worktrees").resolve()
for directory in (candidate, *candidate.parents):
marker = directory / ".git"
if marker.is_dir():
return None # a normal checkout, not a linked worktree
if not marker.is_file():
continue
gitdir = marker.read_text(encoding="utf-8").strip()
if not gitdir.startswith("gitdir:"):
return None
target = Path(gitdir.split(":", 1)[1].strip()).resolve()
if not target.is_relative_to(worktrees_dir):
return None # a worktree, but of some other repo
return None if directory == root else directory
except OSError:
return None
return None
"""The linked worktree of ``workspace_root`` that contains ``candidate_dir``, else None."""
return worktree_seed.linked_worktree_of(workspace_root, candidate_dir)


def _session_worktree_root(workspace_root: Path) -> Path | None:
Expand Down Expand Up @@ -8929,9 +8900,15 @@ def _memory_summary(session_id: str) -> dict[str, Any]:
# code_search answered "index is being rebuilt" for most calls.
def _retire_code_engine(engine: Any) -> None:
engine.stop_autosync()
# A scoped capability holds its engine; left cached it would pin a retired one.
with _scoped_context_cache_lock:
for key in [key for key, entry in _scoped_context_cache.items() if entry[1] is engine]:
del _scoped_context_cache[key]


_code_engine_cache = VersionedEngineCache("code_engine", on_evict=_retire_code_engine)
_code_engine_cache = VersionedEngineCache(
"code_engine", on_evict=_retire_code_engine, retire=worktree_seed.retire_worktree_engine
)

# ``cache_key -> (capability, engine_it_was_built_from)``.
#
Expand Down Expand Up @@ -9031,7 +9008,15 @@ def _code_context_engine(repo_root: str = ".") -> Any:
root = Path(repo_root)
resolved = (root if root.is_absolute() else Path(workspace) / root).resolve()
cache_key = str(resolved)
# A linked worktree's index starts as a clone of its main checkout's.
seed = worktree_seed.ensure_seeded(
resolved,
cached=cache_key in _code_engine_cache,
before_swap=lambda: _code_engine_cache.discard(cache_key),
)
engine, freshness = _code_engine_cache.get(cache_key, resolved, lambda: CodeContextEngine(resolved))
if seed is not None and seed.seeded:
worktree_seed.start_first_refresh(engine)
_code_index_freshness_for_current_call.value = freshness
return engine

Expand Down
42 changes: 34 additions & 8 deletions src/lemoncrow/gateway/cli/commands/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,11 @@ def code_host_remove_cmd(ctx: click.Context, engine: str, yes: bool) -> None:
)
@click.option("--include", "include_globs", multiple=True)
@click.option("--exclude", "exclude_globs", multiple=True)
@click.option("--reindex", is_flag=True, help="Full rebuild from scratch (default: incremental).")
@click.option(
"--reindex",
is_flag=True,
help="Full rebuild from scratch (default: incremental); in a linked git worktree, re-seed from the main checkout's index when it can.",
)
@click.option(
"--force",
"steal_lock",
Expand All @@ -725,7 +729,10 @@ def code_index_cmd(
"""Index a repository into the SQLite FTS5 symbol store.

Incremental by default (only re-indexes changed files). Use --reindex
for a full rebuild from scratch.
for a full rebuild from scratch. In a linked git worktree whose main
checkout has a current index, --reindex re-seeds from that index instead,
then re-indexes only the files that differ; a seeded worktree index is
never rebuilt.
"""
if repo_root is None:
# Resolve the same way the MCP code_search / read tools do
Expand All @@ -734,8 +741,23 @@ def code_index_cmd(
from lemoncrow.core.foundation.paths import resolve_workspace_root

repo_root = str(resolve_workspace_root())
engine = _code_context_engine(repo_root, db_path=Path(db_path) if db_path else None)
from lemoncrow.infra.code_intel import worktree_seed

force = reindex
seeded_worktree = False
if db_path is None:
# A linked worktree's index is seeded from its main checkout's, and --reindex
# re-seeds it: a full build would un-share every page of the clone.
from lemoncrow.infra.code_intel.freshness import IndexRebuilding

try:
seed = worktree_seed.ensure_seeded(Path(repo_root).resolve(), reseed=reindex)
except IndexRebuilding as exc:
raise click.ClickException(str(exc)) from exc
if seed is not None and seed.seeded:
force = False
seeded_worktree = worktree_seed.seeded_main_root(Path(repo_root).resolve()) is not None
engine = _code_context_engine(repo_root, db_path=Path(db_path) if db_path else None)
if as_json:
payload = engine.index_repo(
force=force,
Expand All @@ -744,14 +766,16 @@ def code_index_cmd(
include_globs=list(include_globs) or None,
exclude_globs=list(exclude_globs) or None,
).model_dump(mode="json")
worktree_seed.checkpoint_index(Path(engine.db_path).parent) # so a worktree seed finds a small WAL
try:
engine._deleted_history_adapter()._ensure_history_ready()
except Exception:
logging.exception("Failed to prepare background indexes")
try:
_trigger_zoekt_with_progress(Path(repo_root).resolve(), quiet=True)
except Exception:
logging.exception("Failed to prewarm Zoekt index")
if not seeded_worktree: # a seeded worktree searches its main checkout's Zoekt index
try:
_trigger_zoekt_with_progress(Path(repo_root).resolve(), quiet=True)
except Exception:
logging.exception("Failed to prewarm Zoekt index")
_emit(payload, as_json=True)
return

Expand All @@ -765,9 +789,11 @@ def code_index_cmd(
success_description="Indexed code",
frame_prefix=frame_prefix,
)
worktree_seed.checkpoint_index(Path(engine.db_path).parent) # so a worktree seed finds a small WAL

git_summary = _index_git_history_with_progress(engine, frame_prefix=frame_prefix)
_trigger_zoekt_with_progress(Path(repo_root).resolve(), frame_prefix=frame_prefix)
if not seeded_worktree:
_trigger_zoekt_with_progress(Path(repo_root).resolve(), frame_prefix=frame_prefix)

stats_line = (
f"{click.style('✓', fg='green')} Indexed {payload['files_indexed']} files, {payload['symbols_indexed']} "
Expand Down
61 changes: 61 additions & 0 deletions src/lemoncrow/infra/code_intel/freshness.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,12 @@ class VersionedEngineCache:
background autosync thread references the engine, so an evicted engine never
died. Every index bump left one more loop polling the tree and spawning its
own reindex, and those reindexes bumped the version again.

*retire* decides which entries to drop on its own: it is called with each
key and the seconds since that key was last requested, and True discards the
entry (through *on_evict*). The check runs on every :meth:`get` and on a
background timer every *sweep_seconds*, so an entry nobody requests again
still goes.
"""

def __init__(
Expand All @@ -326,15 +332,22 @@ def __init__(
recheck_seconds: float = DEFAULT_RECHECK_SECONDS,
clock: Callable[[], float] = time.monotonic,
on_evict: Callable[[Any], None] | None = None,
retire: Callable[[str, float], bool] | None = None,
sweep_seconds: float = 60.0,
) -> None:
self.name = name
self.recheck_seconds = float(recheck_seconds)
self.on_evict = on_evict
self.retire = retire
self.sweep_seconds = float(sweep_seconds)
self.evictions = 0
self._clock = clock
self._lock = threading.Lock()
self._entries: dict[str, _Entry] = {}
self._probes: dict[str, _Probe] = {}
self._last_access: dict[str, float] = {}
self._sweeper: threading.Thread | None = None
self._sweeper_stop = threading.Event()

# -- probing -----------------------------------------------------------

Expand Down Expand Up @@ -363,6 +376,10 @@ def get(self, key: str, repo_root: Path | str, build: Callable[[], Any]) -> tupl
results from a torn index is the failure mode this whole module exists
to remove.
"""
self._last_access[key] = self._clock()
if self.retire is not None:
self._ensure_sweeper()
self.sweep(skip=key)
state = self.state_for(repo_root)
if state.rebuilding:
raise IndexRebuilding(repo_root, state.detail)
Expand Down Expand Up @@ -412,14 +429,58 @@ def version_of(self, key: str) -> int | None:
def discard(self, key: str) -> None:
with self._lock:
entry = self._entries.pop(key, None)
self._probes.pop(key, None)
self._last_access.pop(key, None)
if entry is not None:
self._retire(entry.value)

def sweep(self, *, skip: str | None = None) -> list[str]:
"""Discard every entry *retire* says to; returns the keys discarded."""
retire = self.retire
if retire is None:
return []
now = self._clock()
with self._lock:
idle = {key: now - self._last_access.get(key, now) for key in self._entries if key != skip}
retired: list[str] = []
for key, idle_seconds in idle.items():
try:
if not retire(key, idle_seconds):
continue
except Exception:
logger.warning("%s: retire check for %s failed", self.name, key, exc_info=True)
continue
logger.info("%s: retiring %s after %.0fs idle", self.name, key, idle_seconds)
self.discard(key)
retired.append(key)
return retired

def _ensure_sweeper(self) -> None:
if self._sweeper is not None:
return
with self._lock:
if self._sweeper is not None:
return
self._sweeper = threading.Thread(target=self._sweep_loop, name=f"{self.name}-sweeper", daemon=True)
self._sweeper.start()

def _sweep_loop(self) -> None:
while not self._sweeper_stop.wait(self.sweep_seconds):
try:
self.sweep()
except Exception:
logger.warning("%s: sweep failed", self.name, exc_info=True)

def stop_sweeper(self) -> None:
"""End the background sweep; :meth:`get` still sweeps."""
self._sweeper_stop.set()

def clear(self) -> None:
with self._lock:
dropped = [entry.value for entry in self._entries.values()]
self._entries.clear()
self._probes.clear()
self._last_access.clear()
self.evictions = 0
for value in dropped:
self._retire(value)
Expand Down
8 changes: 8 additions & 0 deletions src/lemoncrow/infra/code_intel/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

__all__ = [
"CODE_CONTEXT_DB",
"CODE_INDEXER_SEMANTICS_VERSION",
"FTS_DB",
"INTEL_DB",
"REPO_MAP_TAGS_DB",
Expand All @@ -47,6 +48,13 @@
VECTORS_DB = "vectors.sqlite"
REPO_MAP_TAGS_DB = "repo_map_tags.sqlite"

#: The engine's indexer format, stamped into ``engine_state`` as
#: ``indexer_semantics_version``. Bump when source selection or symbol/text
#: extraction semantics change in a way an incremental mtime/hash check cannot
#: see for unchanged files.
#: 3: FTS5 rows carry explicit rowids derived from files.rowid / symbols.rowid.
CODE_INDEXER_SEMANTICS_VERSION = 3

# The engine holds WAL writers during a reindex; wait rather than fail fast.
_BUSY_TIMEOUT_MS = 5_000

Expand Down
Loading
Loading