From 11fd4aa9daefb43edf481375a89bee5174a20981 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 13:57:42 -0500 Subject: [PATCH 1/9] ISS-10812: route each session's calls to its own worktree PLN-2070 PR 2. The plugin's PreToolUse hook records each session's cwd; the daemon picks a request's root by D2's precedence (explicit root, worktree paths, recorded cwd, per-session bash cwd, workspace) for code tools, relative read/edit paths and cwd-less bash. Code tools say which worktree answered; edit's resolved-against note needs a relative path. Co-Authored-By: Claude --- .../claude/plugin/hooks/mcp_read_allow.py | 61 ++- .../claude/plugin/hooks/session_start.py | 27 ++ src/lemoncrow/gateway/adapters/mcp/bash.py | 6 +- .../gateway/adapters/mcp/session_root.py | 444 ++++++++++++++++++ src/lemoncrow/gateway/adapters/mcp_server.py | 133 +++--- tests/gateway/test_edit_mcp_handler.py | 45 +- tests/gateway/test_session_root_routing.py | 396 ++++++++++++++++ 7 files changed, 1033 insertions(+), 79 deletions(-) create mode 100644 src/lemoncrow/gateway/adapters/mcp/session_root.py create mode 100644 tests/gateway/test_session_root_routing.py diff --git a/integrations/claude/plugin/hooks/mcp_read_allow.py b/integrations/claude/plugin/hooks/mcp_read_allow.py index 4484c053c..2cca4d2de 100644 --- a/integrations/claude/plugin/hooks/mcp_read_allow.py +++ b/integrations/claude/plugin/hooks/mcp_read_allow.py @@ -30,13 +30,24 @@ user's own deny rule for something outside this list. Fail-open; opt-out via LEMONCROW_MCP_READ_ALLOW=0. + +It also records the session's working directory for every lc call, read-only or +not and whatever the opt-out says: ``/session_cwd/`` +holds the payload's ``cwd``, which follows ``EnterWorktree``. The shared MCP +daemon reads it to route the session's calls to the worktree it works in +(``lemoncrow.gateway.adapters.mcp.session_root``). The record never changes +this hook's output and never raises; SessionStart prunes records older than +7 days. """ from __future__ import annotations import json import os +import re import sys +import tempfile +from pathlib import Path from typing import Any # MCP server key as registered by install_claude.sh (user scope: "lc", @@ -122,15 +133,61 @@ def _allow(reason: str) -> None: ) +# A session id names a file, so it must never carry a path separator or a dot. +_SESSION_ID_RE = re.compile(r"[A-Za-z0-9_-]{1,128}") + + +def _session_cwd_dir() -> Path: + """``/session_cwd``, the store root resolved as ``lemoncrow`` resolves it.""" + configured = os.environ.get("LEMONCROW_ROOT", "").strip() + root = Path(configured).expanduser() if configured else Path.home() / ".lemoncrow" + return root / "session_cwd" + + +def _record_session_cwd(payload: dict[str, Any]) -> None: + """Write the payload's cwd to its session's file when it changed; never raise.""" + session_id = payload.get("session_id") + cwd = payload.get("cwd") + if not isinstance(session_id, str) or not _SESSION_ID_RE.fullmatch(session_id): + return + if not isinstance(cwd, str) or not cwd.strip(): + return + try: + directory = _session_cwd_dir() + if not directory.is_dir(): + directory.mkdir(parents=True, exist_ok=True) + os.chmod(directory, 0o700) + target = directory / session_id + try: + if target.read_text(encoding="utf-8") == cwd: + return + except (OSError, UnicodeDecodeError): + pass + fd, tmp = tempfile.mkstemp(dir=directory, prefix=f".{session_id}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(cwd) + os.replace(tmp, target) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + except Exception as exc: # the permission decision must survive any failure here + print(f"lc: session cwd not recorded: {exc}", file=sys.stderr) + + def main() -> int: - if os.environ.get("LEMONCROW_MCP_READ_ALLOW", "1") == "0": - return 0 try: payload: dict[str, Any] = json.loads(sys.stdin.read() or "{}") except (json.JSONDecodeError, TypeError, OSError): return 0 if not isinstance(payload, dict): return 0 + _record_session_cwd(payload) + if os.environ.get("LEMONCROW_MCP_READ_ALLOW", "1") == "0": + return 0 tool = _read_only_tool(str(payload.get("tool_name") or ""), payload.get("tool_input")) if tool is None: return 0 diff --git a/integrations/claude/plugin/hooks/session_start.py b/integrations/claude/plugin/hooks/session_start.py index b37e86c77..aff33d219 100644 --- a/integrations/claude/plugin/hooks/session_start.py +++ b/integrations/claude/plugin/hooks/session_start.py @@ -27,6 +27,7 @@ import re import sys import tempfile +import time from contextlib import suppress from pathlib import Path from typing import Any @@ -85,6 +86,29 @@ def _lemoncrow_root() -> Path: return Path.home() / ".lemoncrow" +_SESSION_CWD_MAX_AGE_S = 7 * 24 * 3600 + + +def _prune_session_cwds(now: float | None = None) -> None: + """Delete session cwd records untouched for 7 days. + + ``mcp_read_allow.py`` writes one per session, under the same store root, and + rewrites it only when the cwd changes; a live session whose record is pruned + gets it back on its next lc call. + """ + configured = os.environ.get("LEMONCROW_ROOT", "").strip() + root = Path(configured).expanduser() if configured else Path.home() / ".lemoncrow" + cutoff = (time.time() if now is None else now) - _SESSION_CWD_MAX_AGE_S + try: + entries = list(os.scandir(root / "session_cwd")) + except OSError: + return + for entry in entries: + with suppress(OSError): + if entry.is_file(follow_symlinks=False) and entry.stat(follow_symlinks=False).st_mtime < cutoff: + os.unlink(entry.path) + + def _active_session_id() -> str | None: state = _read_session_state() return state.get("session_id") or state.get("active_session_id") @@ -263,6 +287,9 @@ def main() -> int: with suppress(Exception): _emit_env_context(payload["cwd"]) + with suppress(Exception): + _prune_session_cwds() + session_id_raw: str = payload.get("session_id", "") or "" source: str = payload.get("source", "startup") or "startup" model: str = payload.get("model", "") or "" diff --git a/src/lemoncrow/gateway/adapters/mcp/bash.py b/src/lemoncrow/gateway/adapters/mcp/bash.py index 6a1a51f65..0fc57897a 100644 --- a/src/lemoncrow/gateway/adapters/mcp/bash.py +++ b/src/lemoncrow/gateway/adapters/mcp/bash.py @@ -21,6 +21,7 @@ from pathlib import Path from typing import Any, Literal, cast +from lemoncrow.gateway.adapters.mcp import session_root from lemoncrow.gateway.adapters.mcp.deferral import ( _defer_bash_enabled, _deferral_supported, @@ -450,7 +451,10 @@ def _render_grep_stdout(payload: dict[str, Any]) -> str: # directory) so the command still runs instead of hard-failing. if not Path(workspace).is_dir(): workspace = os.getcwd() - effective_cwd = cwd or workspace + # A command with no cwd runs in the linked worktree the session's recorded + # cwd puts it in (session_root step 3), else the workspace as before. + _session_worktree = None if cwd else session_root.session_worktree(Path(workspace)) + effective_cwd = cwd or (str(_session_worktree) if _session_worktree is not None else workspace) if action in {"poll", "kill", "status", "update", "send"}: if not session_id: diff --git a/src/lemoncrow/gateway/adapters/mcp/session_root.py b/src/lemoncrow/gateway/adapters/mcp/session_root.py new file mode 100644 index 000000000..bc68b88d9 --- /dev/null +++ b/src/lemoncrow/gateway/adapters/mcp/session_root.py @@ -0,0 +1,444 @@ +"""Which checkout a daemon request works in: its session root (ISS-10812). + +Every session on a repo shares the main checkout's MCP daemon, whose workspace +root is fixed at launch. A session that enters a linked git worktree keeps +calling that daemon, so unless a request is routed its searches answer from the +main checkout's index and its relative paths resolve into the main checkout. + +A request's root is decided by this precedence: + +1. an explicit ``repo_root`` / ``root`` argument, which the tool honors before + asking this module; +2. absolute code-tool ``paths`` that all fall inside one linked worktree + (:func:`code_root`); +3. the session's recorded ``cwd``. The plugin's PreToolUse hook writes it to + ``/session_cwd/`` before every lc call. It counts only + when it is the workspace root or inside one of its linked worktrees; +4. for relative edit and read paths only, the session's last explicit ``bash`` + ``cwd`` (:func:`path_root`); +5. the workspace root. + +Workspace-keyed state (ledger, handover, compression, the workflow executor, +auto-init, warmers, savings) never reads the session root. + +Known limitations: + +* A session and its subagents share one session id. A subagent working in its + own worktree (Agent ``isolation: "worktree"``) and a parent in the main + checkout overwrite each other's recorded ``cwd``, so a call that names no + checkout follows whichever of them called last. A call that names the worktree + through absolute ``paths`` or ``repo_root`` still routes correctly, and an edit + whose relative path names an existing file in both checkouts is refused as + ambiguous instead of guessed. +* A worktree added with ``git worktree add --relative-paths`` records a relative + ``gitdir``, which :func:`linked_worktree_of` does not resolve. Such a worktree + is never accepted, and its session's calls fall back to the workspace root. +* Hosts without the hook (Codex, Cursor) get steps 1, 2, 4 and 5. +""" + +from __future__ import annotations + +import re +import threading +import time +from collections import OrderedDict +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from lemoncrow.infra.code_intel.worktree_seed import linked_worktree_of + +__all__ = [ + "BASH_CWD", + "BASH_CWD_IDLE_S", + "MAX_SESSIONS", + "PATHS", + "PATH_ROUTED_TOOLS", + "SESSION_CWD", + "SESSION_CWD_DIRNAME", + "WORKSPACE", + "BashCwds", + "PathsSpanWorktrees", + "SessionRoot", + "accept_session_cwd", + "begin_request", + "code_root", + "disclose", + "end_request", + "path_root", + "record_bash_cwd", + "routed_paths", + "session_cwd_file", + "session_worktree", + "worktree_of_paths", +] + +#: Directory under the store root holding one file per session, named by its id. +#: The plugin's ``mcp_read_allow.py`` writes it and ``session_start.py`` prunes it. +SESSION_CWD_DIRNAME = "session_cwd" +#: Sessions whose bash cwd (and recorded-cwd read) is remembered at once. +MAX_SESSIONS = 256 +#: A session's bash cwd is forgotten after this long without use. +BASH_CWD_IDLE_S = 24 * 3600.0 + +#: :attr:`SessionRoot.source` values: which precedence step answered. +PATHS = "paths" +SESSION_CWD = "session_cwd" +BASH_CWD = "bash_cwd" +WORKSPACE = "workspace" + +#: Tools whose ``paths`` / ``path`` arguments can route the call (step 2). +PATH_ROUTED_TOOLS: frozenset[str] = frozenset({"code_search", "code_coverage_check", "code_changes", "graph"}) + +# A session id names a file, so it must never carry a path separator or a dot. +_SESSION_ID_RE = re.compile(r"[A-Za-z0-9_-]{1,128}") + + +@dataclass(frozen=True) +class SessionRoot: + """The checkout a request answers from, and the precedence step that chose it. + + ``source`` is :data:`WORKSPACE` exactly when ``root`` is the workspace root. + """ + + root: Path + source: str + + +class PathsSpanWorktrees(ValueError): + """A code tool's absolute ``paths`` name files in more than one linked worktree.""" + + +class BashCwds: + """Each session's last explicit ``bash`` ``cwd``, bounded in count and idle time.""" + + def __init__( + self, + *, + max_sessions: int = MAX_SESSIONS, + idle_s: float = BASH_CWD_IDLE_S, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._max_sessions = max_sessions + self._idle_s = idle_s + self._clock = clock + self._lock = threading.Lock() + # Least recently used first; a value is (cwd, monotonic time of last use). + self._entries: OrderedDict[str, tuple[str, float]] = OrderedDict() + + def record(self, session_key: str, cwd: str) -> None: + now = self._clock() + with self._lock: + self._entries[session_key] = (cwd, now) + self._entries.move_to_end(session_key) + self._expire(now) + while len(self._entries) > self._max_sessions: + self._entries.popitem(last=False) + + def get(self, session_key: str) -> str | None: + now = self._clock() + with self._lock: + self._expire(now) + entry = self._entries.get(session_key) + if entry is None: + return None + self._entries[session_key] = (entry[0], now) + self._entries.move_to_end(session_key) + return entry[0] + + def __len__(self) -> int: + with self._lock: + return len(self._entries) + + def _expire(self, now: float) -> None: + # Entries are ordered by last use, so the idle ones are all at the front. + while self._entries: + key, (_cwd, last_used) = next(iter(self._entries.items())) + if now - last_used < self._idle_s: + return + del self._entries[key] + + +class _SessionCwdFiles: + """Recorded session cwds, re-read only when the file's inode or mtime moves. + + The hook replaces the file by rename on every change, so a new value always + arrives under a new inode. + """ + + def __init__(self, max_entries: int = MAX_SESSIONS) -> None: + self._max_entries = max_entries + self._lock = threading.Lock() + self._cache: OrderedDict[str, tuple[int, int, str | None]] = OrderedDict() + + def read(self, path: Path) -> str | None: + key = str(path) + try: + stat = path.stat() + except OSError: + with self._lock: + self._cache.pop(key, None) + return None + with self._lock: + hit = self._cache.get(key) + if hit is not None and hit[0] == stat.st_ino and hit[1] == stat.st_mtime_ns: + self._cache.move_to_end(key) + return hit[2] + try: + value: str | None = path.read_text(encoding="utf-8").strip() or None + except (OSError, UnicodeDecodeError): + value = None + with self._lock: + self._cache[key] = (stat.st_ino, stat.st_mtime_ns, value) + self._cache.move_to_end(key) + while len(self._cache) > self._max_entries: + self._cache.popitem(last=False) + return value + + +class _Request: + """What one request carries for routing, plus the answers already worked out.""" + + def __init__( + self, + *, + session_id: Callable[[], str], + bash_key: str, + paths: tuple[str, ...], + store_root: Callable[[], Path], + ) -> None: + self.session_id = session_id + self.bash_key = bash_key + self.paths = paths + self.store_root = store_root + self.resolved_session_id: str | None = None + self.checkouts: dict[str, Path | None] = {} + self.code_roots: dict[str, SessionRoot] = {} + #: The code root this request answered from, for :func:`disclose`. + self.answered: SessionRoot | None = None + + +_request = threading.local() +_bash_cwds = BashCwds() +_cwd_files = _SessionCwdFiles() + + +def begin_request( + *, + session_id: Callable[[], str], + bash_key: str, + paths: tuple[str, ...], + store_root: Callable[[], Path], +) -> _Request | None: + """Scope routing to the request running on this thread; return the prior scope. + + *session_id* is called at most once, and only when a tool needs the recorded + cwd. It returns "" when the request names no session. *bash_key* keys the + per-session bash cwd map. *paths* are the call's routing paths + (:func:`routed_paths`). + """ + prior = _current() + _request.value = _Request(session_id=session_id, bash_key=bash_key, paths=paths, store_root=store_root) + return prior + + +def end_request(prior: _Request | None) -> None: + _request.value = prior + + +def _current() -> _Request | None: + value = getattr(_request, "value", None) + return value if isinstance(value, _Request) else None + + +def record_bash_cwd(session_key: str, cwd: str) -> None: + """Remember a session's explicit ``bash`` ``cwd`` (precedence step 4).""" + _bash_cwds.record(session_key, cwd) + + +def routed_paths(tool: str, args: Mapping[str, Any]) -> tuple[str, ...]: + """The ``paths`` / ``path`` arguments of a tool that routes by them, else (). + + A ``tool`` broker call is judged by the tool it calls. + """ + if tool == "tool": + inner_name = args.get("name") + inner_args = args.get("arguments") + if isinstance(inner_name, str) and inner_name != "tool" and isinstance(inner_args, dict): + return routed_paths(inner_name, inner_args) + return () + if tool not in PATH_ROUTED_TOOLS: + return () + found: list[str] = [] + for key in ("paths", "path"): + value = args.get(key) + if isinstance(value, str): + found.extend(part.strip() for part in value.split(",") if part.strip()) + elif isinstance(value, list): + found.extend(item.strip() for item in value if isinstance(item, str) and item.strip()) + return tuple(found) + + +def session_cwd_file(store_root: Path, session_id: str) -> Path | None: + """Where the hook records *session_id*'s cwd, or None for an id that cannot name a file.""" + if not _SESSION_ID_RE.fullmatch(session_id): + return None + return store_root / SESSION_CWD_DIRNAME / session_id + + +def accept_session_cwd(workspace: Path, cwd: str) -> Path | None: + """The checkout a recorded *cwd* puts the session in, else None. + + That is the linked worktree of *workspace* containing *cwd*, or *workspace* + itself when *cwd* is exactly the workspace root. Anything else -- another + repo, a plain directory, a missing or relative path -- is never used. + """ + candidate = Path(cwd).expanduser() + if not candidate.is_absolute(): + return None + worktree = linked_worktree_of(workspace, candidate) + if worktree is not None: + return worktree + try: + return workspace if candidate.resolve() == workspace.resolve() else None + except OSError: + return None + + +def _nearest_dir(path: Path) -> Path | None: + for directory in (path, *path.parents): + if directory.is_dir(): + return directory + return None + + +def worktree_of_paths(workspace: Path, paths: Iterable[str]) -> Path | None: + """The one linked worktree every absolute path in *paths* falls inside, else None (step 2). + + Relative paths are ignored: they resolve against whichever root is chosen. + Raises :class:`PathsSpanWorktrees` when the absolute paths name two or more + worktrees, since one call answers from one index. + """ + worktrees: dict[str, Path] = {} + elsewhere = False + for raw in paths: + candidate = Path(raw).expanduser() + if not candidate.is_absolute(): + continue + directory = _nearest_dir(candidate) + worktree = linked_worktree_of(workspace, directory) if directory is not None else None + if worktree is None: + elsewhere = True + else: + worktrees[str(worktree)] = worktree + if len(worktrees) > 1: + raise PathsSpanWorktrees( + "paths span more than one worktree (" + + ", ".join(sorted(worktrees)) + + "); one call searches one worktree's index -- split the call per worktree" + ) + if len(worktrees) == 1 and not elsewhere: + return next(iter(worktrees.values())) + return None + + +def _recorded_checkout(ctx: _Request, workspace: Path) -> Path | None: + """Step 3: the checkout the session's recorded cwd puts it in, else None.""" + key = str(workspace) + if key in ctx.checkouts: + return ctx.checkouts[key] + if ctx.resolved_session_id is None: + ctx.resolved_session_id = ctx.session_id() + found: Path | None = None + path = session_cwd_file(ctx.store_root(), ctx.resolved_session_id) if ctx.resolved_session_id else None + cwd = _cwd_files.read(path) if path is not None else None + if cwd: + found = accept_session_cwd(workspace, cwd) + ctx.checkouts[key] = found + return found + + +def _as_root(workspace: Path, checkout: Path, source: str) -> SessionRoot: + try: + same = checkout.resolve() == workspace.resolve() + except OSError: + same = False + return SessionRoot(workspace, WORKSPACE) if same else SessionRoot(checkout, source) + + +def code_root(workspace: Path) -> SessionRoot: + """Where this request's code tools answer from: steps 2, 3 and 5. + + The workspace root outside a request. Raises :class:`PathsSpanWorktrees` + when the call's absolute paths name two worktrees. + """ + ctx = _current() + if ctx is None: + return SessionRoot(workspace, WORKSPACE) + key = str(workspace) + found = ctx.code_roots.get(key) + if found is None: + worktree = worktree_of_paths(workspace, ctx.paths) if ctx.paths else None + if worktree is not None: + found = _as_root(workspace, worktree, PATHS) + else: + checkout = _recorded_checkout(ctx, workspace) + found = ( + _as_root(workspace, checkout, SESSION_CWD) + if checkout is not None + else SessionRoot(workspace, WORKSPACE) + ) + ctx.code_roots[key] = found + ctx.answered = found + return found + + +def path_root(workspace: Path) -> SessionRoot: + """Where this request's relative edit and read paths resolve: steps 3, 4 and 5. + + Outside a request only step 4 applies, under the session key "" that a + single-session stdio server records under. + """ + ctx = _current() + bash_key = "" + if ctx is not None: + checkout = _recorded_checkout(ctx, workspace) + if checkout is not None: + return _as_root(workspace, checkout, SESSION_CWD) + bash_key = ctx.bash_key + recorded = _bash_cwds.get(bash_key) + worktree = linked_worktree_of(workspace, Path(recorded)) if recorded else None + return SessionRoot(worktree, BASH_CWD) if worktree is not None else SessionRoot(workspace, WORKSPACE) + + +def session_worktree(workspace: Path) -> Path | None: + """The linked worktree the session's recorded cwd puts it in (step 3 only), else None. + + A ``bash`` call with no ``cwd`` runs there. + """ + ctx = _current() + checkout = _recorded_checkout(ctx, workspace) if ctx is not None else None + if checkout is None: + return None + found = _as_root(workspace, checkout, SESSION_CWD) + return None if found.source == WORKSPACE else found.root + + +def disclose(result: object, rendered: str | None) -> str | None: + """Name the checkout a code tool answered from when it is not the workspace root. + + Sets ``result["repo_root"]`` and returns *rendered* with one + ``| repo_root: `` suffix on its first line. Unchanged otherwise. + """ + ctx = _current() + answered = ctx.answered if ctx is not None else None + if answered is None or answered.source == WORKSPACE: + return rendered + root = str(answered.root) + if isinstance(result, dict): + result["repo_root"] = root + if not rendered: + return rendered + first, newline, rest = rendered.partition("\n") + return f"{first} | repo_root: {root}{newline}{rest}" diff --git a/src/lemoncrow/gateway/adapters/mcp_server.py b/src/lemoncrow/gateway/adapters/mcp_server.py index d8530973b..2a5670697 100644 --- a/src/lemoncrow/gateway/adapters/mcp_server.py +++ b/src/lemoncrow/gateway/adapters/mcp_server.py @@ -66,6 +66,7 @@ from lemoncrow.core.foundation.redaction import redact from lemoncrow.core.foundation.rubric_gate import run_rubric from lemoncrow.gateway.adapters.mcp import ledger as _ledger +from lemoncrow.gateway.adapters.mcp import session_root from lemoncrow.gateway.adapters.mcp.bash import ( # noqa: F401 (registers bash tool + re-exports) _BASH_STATS_MAX_KEYS, _BASH_STATS_PRUNE_TO, @@ -3748,34 +3749,28 @@ def tool_review_feedback_addressed( } -_last_session_cwd: str | None = None -"""Most recent explicit ``cwd`` seen on a ``bash`` call, or None. - -This server is a long-lived process whose own ``os.getcwd()`` is fixed at -launch, and MCP carries no per-call session cwd (we implement no ``roots`` -capability). So when a session enters a git worktree, :func:`_workspace_root` -keeps naming the *main checkout*. A bash call's explicit ``cwd`` is the only -place the session's real working directory reaches this process, which is why -bash honored the worktree and edit did not. Recorded here and used to resolve -relative edit paths -- see :func:`_session_worktree_root`. - -lc-debt: process-global, so a shared HTTP-mode server hosting two sessions on -the SAME repo could let one session's worktree cwd redirect the other's -relative edits (a foreign repo is already rejected by the -``/.git/worktrees`` check, and the redirect is always disclosed). -Upgrade path: key this by MCP session id once the dispatcher threads one -through, or drop it entirely if the protocol ever carries a per-call cwd. -""" - - def _record_session_cwd(name: str, args: Any) -> None: - """Remember a bash call's cwd, the only session-cwd signal this server gets.""" - global _last_session_cwd + """Remember a bash call's explicit cwd for the calling session (session_root step 4).""" if name != "bash" or not isinstance(args, dict): return cwd = args.get("cwd") if isinstance(cwd, str) and cwd.strip(): - _last_session_cwd = cwd.strip() + session_root.record_bash_cwd(_request_session_identity()[0], cwd.strip()) + + +def _session_root_identity() -> str: + """The calling session's id, for its recorded cwd; "" when the request names none. + + A daemon request without a session id borrows nobody's: the daemon's own + resolution names whichever session started it. A stdio server serves one + window, so its own resolution is that window's session. + """ + session_id, _host = _request_session_identity() + if session_id: + return session_id + if _ledger._request_bridge_id(): + return "" + return _resolved_host_session_id() def _linked_worktree_root(workspace_root: Path, candidate_dir: Path) -> Path | None: @@ -3784,16 +3779,21 @@ def _linked_worktree_root(workspace_root: Path, candidate_dir: Path) -> Path | N def _session_worktree_root(workspace_root: Path) -> Path | None: - """The linked git worktree the session is working in, else None. + """The linked git worktree the session is working in, else None (see session_root.path_root).""" + found = session_root.path_root(workspace_root) + return None if found.source == session_root.WORKSPACE else found.root - Returns None for every uncertain case -- no recorded cwd, a plain - directory, a worktree belonging to a different repo -- so resolution falls - back to the workspace root exactly as before. - """ - recorded = _last_session_cwd - if not recorded: - return None - return _linked_worktree_root(workspace_root, Path(recorded)) + +def _session_code_root() -> Path: + """The checkout this request's code tools answer from (see session_root.code_root).""" + return session_root.code_root(_workspace_root()).root + + +def _session_read_path(file_path: str) -> Path: + """A read path; a relative one resolves in the worktree the session is working in.""" + candidate = Path(file_path) + worktree = None if candidate.is_absolute() else _session_worktree_root(_workspace_root()) + return worktree / candidate if worktree is not None else _workspace_path(file_path) # Thread-local slot for passing real tokens_saved from tool handlers to the @@ -5529,6 +5529,8 @@ def render_tool_result_text(name: str, result: Any) -> str | None: if isinstance(resolved_against, str) and resolved_against: if payload.get("resolved_against_source") == "explicit": text = f"{text} | resolved against root {resolved_against} (explicit root argument)" + elif payload.get("resolved_against_source") == "session_cwd": + text = f"{text} | resolved against worktree {resolved_against} (from session cwd)" else: text = f"{text} | resolved against worktree {resolved_against} (from last bash cwd)" vcs_raw = payload.get("vcs_status") @@ -6123,7 +6125,7 @@ def _smart_read_single( # handles it uniformly (range read is already bounded and efficient). if tail_lines is not None and range is None and not expand: try: - total = sum(1 for _ in open(_workspace_path(target_path), encoding="utf-8", errors="replace")) + total = sum(1 for _ in open(_session_read_path(target_path), encoding="utf-8", errors="replace")) start = max(1, total - tail_lines + 1) range = f"{start}-" except OSError: @@ -6133,7 +6135,7 @@ def _smart_read_single( # host's own permission layer gates the tool call. Writes/edits, by contrast, # are confined to the workspace (see tool_smart_edit). Relative paths still # resolve against the workspace root. - resolved = _workspace_path(target_path) + resolved = _session_read_path(target_path) # Freshness ledger: recorded below, once the actual view served (exact vs # a lossy projection) is known -- see _record_read_sig's exact= kwarg. # A ranged read is served EXACTLY as requested -- never silently widened. @@ -6947,6 +6949,13 @@ def _ambiguous_relative_edit_paths( return ambiguous +def _edits_name_relative_paths(edits: list[dict[str, Any]]) -> bool: + """Whether any edit resolves against a root: a relative path, or none at all (a symbol edit).""" + return any( + not Path(_snapshot_path(str(edit.get("file_path") or edit.get("path") or ""))).is_absolute() for edit in edits + ) + + def _snapshot_paths(paths: dict[str, Path]) -> dict[str, tuple[Path, bool, str | None]]: """Snapshot each file's pre-edit state for rollback. @@ -7827,13 +7836,16 @@ def tool_smart_edit( # (/.claude/worktrees/...) sit *under* that root. Resolve relative # paths against the worktree the session is demonstrably working in. # - # This is an INFERENCE (from the last bash cwd), so it is never silent: - # `resolved_against` rides on the result, survives the clean-success - # squelch, and renders as a suffix on the one-liner. Absolute paths are - # unaffected -- _resolve_snapshot_path only applies a root to relative ones. - # A caller who knows the checkout passes `root` and skips the guessing - # entirely; see _resolve_explicit_edit_root. - _session_worktree = _session_worktree_root(repo_root) + # This is an INFERENCE (from the session's recorded cwd, else its last bash + # cwd -- see session_root.path_root), so it is never silent when it moved a + # relative path: `resolved_against` rides on the result, survives the + # clean-success squelch, and renders as a suffix on the one-liner. Absolute + # paths are unaffected -- _resolve_snapshot_path only applies a root to + # relative ones. A caller who knows the checkout passes `root` and skips the + # guessing entirely; see _resolve_explicit_edit_root. + _session = session_root.path_root(repo_root) + _session_worktree = None if _session.source == session_root.WORKSPACE else _session.root + _inferred_from = "the session's cwd" if _session.source == session_root.SESSION_CWD else "the last bash cwd" edits = [_normalize_edit_aliases(e) for e in edits] _require_edits(edits) @@ -7900,7 +7912,7 @@ def tool_smart_edit( "paths": [raw for raw, _hits in _ambiguous], "error": ( f"ambiguous relative edit path: {_collisions}. The worktree was inferred " - "from the last bash cwd, not named by you -- pass an absolute path, or " + f"from {_inferred_from}, not named by you -- pass an absolute path, or " "root=, to say which checkout you mean" ), } @@ -8387,9 +8399,9 @@ def _fmt_diag(d: dict[str, Any], root: Path) -> str: if _explicit_root is not None: result["resolved_against"] = str(_explicit_root) result["resolved_against_source"] = "explicit" - elif _session_worktree is not None: + elif _session_worktree is not None and _edits_name_relative_paths(edits): result["resolved_against"] = str(_session_worktree) - result["resolved_against_source"] = "inferred" + result["resolved_against_source"] = "session_cwd" if _session.source == session_root.SESSION_CWD else "inferred" return _silence_clean_edit_result(result) @@ -8926,11 +8938,12 @@ def _retire_code_engine(engine: Any) -> None: def _code_repo_root(repo_root: str | None) -> Path: """Resolve a caller-supplied repo root the way the code engine does. - A relative root is taken against the active workspace, not the process cwd, - so open code-intel modules address the same repo the engine indexed. + A relative root is taken against the request's session root (see + session_root.code_root), not the process cwd, so open code-intel modules + address the same repo the engine indexed. """ root = Path(repo_root or ".") - return (root if root.is_absolute() else _workspace_root() / root).resolve() + return (root if root.is_absolute() else _session_code_root() / root).resolve() # Every lazy `from lemoncrow.pro...` on the code path lands in one mypyc group @@ -9004,9 +9017,8 @@ def _code_context_engine(repo_root: str = ".") -> Any: _warm_pro_code_modules() from lemoncrow.pro.capabilities.code_context import CodeContextEngine - workspace = str(_workspace_root()) root = Path(repo_root) - resolved = (root if root.is_absolute() else Path(workspace) / root).resolve() + resolved = (root if root.is_absolute() else _session_code_root() / 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( @@ -9037,9 +9049,8 @@ def _scoped_context_capability(repo_root: str = ".") -> Any: _warm_pro_code_modules() from lemoncrow.pro.capabilities.scoped_context import ScopedContextCapability - workspace = str(_workspace_root()) root = Path(repo_root) - resolved = (root if root.is_absolute() else Path(workspace) / root).resolve() + resolved = (root if root.is_absolute() else _session_code_root() / root).resolve() cache_key = str(resolved) engine = _code_context_engine(str(resolved)) entry = _scoped_context_cache.get(cache_key) @@ -9058,9 +9069,8 @@ def _workspace_code_router(repo_root: str = ".") -> Any: _warm_pro_code_modules() from lemoncrow.pro.capabilities.code_context.workspace_router import WorkspaceCodeRouter - workspace = str(_workspace_root()) root = Path(repo_root) - resolved = root if root.is_absolute() else Path(workspace) / root + resolved = root if root.is_absolute() else _session_code_root() / root return WorkspaceCodeRouter( repo_root=resolved, engine_factory=lambda target_root: _code_context_engine(str(target_root)), @@ -11131,7 +11141,7 @@ def tool_code_search( as already read -- do not re-open those files with `read`. """ _ = force # consumed by the dedup dispatcher wrapper (mcp_server._handle), not here - workspace_root = _workspace_root() + workspace_root = _session_code_root() # Check BEFORE doing any search work: a flagged repeat query skips the # engine entirely and returns blank -- no explanatory text (measured: the # caller skims past prose hints and searches again anyway; blank results @@ -11503,7 +11513,7 @@ def tool_smart_search( raise ValueError("query is required for semantic search; use grep for regex/glob/symbol search") from lemoncrow.pro.capabilities.grounded_loop.search_first import search_first - workspace_root = _workspace_root() + workspace_root = _session_code_root() def indexed_search( *, @@ -12596,9 +12606,14 @@ def _handle(request: dict[str, Any]) -> dict[str, Any] | _Deferred | None: # N10 — request-scoped project isolation. Honor an Mcp-Project-Path-style # override for the lifetime of this request only; absent -> unchanged. _prior_project = _set_request_project(_extract_request_project(params, args if isinstance(args, dict) else {})) - # A bash cwd is the only place the session's real working directory - # reaches this long-lived process; record it so edit can resolve - # relative paths against a worktree the session has entered. + _prior_session_root = session_root.begin_request( + session_id=_session_root_identity, + bash_key=_request_session_identity()[0], + paths=session_root.routed_paths(name, args), + store_root=_lemoncrow_root, + ) + # On hosts without the plugin's hook a bash cwd is the only place the + # session's working directory reaches this process; record it per session. _record_session_cwd(name, args) _call_duration_ms: int = 0 _call_started = time.perf_counter() @@ -12704,6 +12719,7 @@ def _finalize_response(result: dict[str, Any] | Any) -> dict[str, Any]: # Compute MD text for read-heavy tools rendered_text = render_tool_result_text(name, result) + rendered_text = session_root.disclose(result, rendered_text) # Pull the internal `calls_saved` credit out of `result` NOW, # before the JSON-dump fallback below can serialize it verbatim. @@ -13079,6 +13095,7 @@ def _finalize_response(result: dict[str, Any] | Any) -> dict[str, Any]: finally: # Always drop the request-scoped project override (N10). _clear_request_project(_prior_project) + session_root.end_request(_prior_session_root) return _err(rid, -32601, f"unknown method: {method}") diff --git a/tests/gateway/test_edit_mcp_handler.py b/tests/gateway/test_edit_mcp_handler.py index 896414bb9..d6d9dc8e1 100644 --- a/tests/gateway/test_edit_mcp_handler.py +++ b/tests/gateway/test_edit_mcp_handler.py @@ -17,6 +17,7 @@ import pytest from lemoncrow.gateway.adapters import mcp_server +from lemoncrow.gateway.adapters.mcp import session_root from lemoncrow.gateway.adapters.mcp_server import tool_smart_edit from lemoncrow.pro.capabilities.code_context import CodeContextEngine @@ -1353,6 +1354,13 @@ def _git(*args: str, cwd: Path) -> None: subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) +def _set_bash_cwd(monkeypatch: pytest.MonkeyPatch, cwd: str | None) -> None: + """Start from an empty per-session bash cwd map, then record *cwd* for the stdio session.""" + monkeypatch.setattr(session_root, "_bash_cwds", session_root.BashCwds()) + if cwd is not None: + session_root.record_bash_cwd("", cwd) + + def _repo_with_worktree(root: Path) -> Path: """Init a git repo at `root` and add a linked worktree; return the worktree.""" _git("init", "-q", cwd=root) @@ -1376,7 +1384,7 @@ def test_relative_edit_follows_the_session_into_a_worktree(workspace: Path, monk """ wt = _repo_with_worktree(workspace) (wt / "target.txt").write_text("WORKTREE\n", encoding="utf-8") - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) payload = _edit( { @@ -1403,7 +1411,7 @@ def test_relative_path_in_both_checkouts_is_refused_not_guessed( wt = _repo_with_worktree(workspace) (workspace / "target.txt").write_text("SHARED\n", encoding="utf-8") (wt / "target.txt").write_text("SHARED\n", encoding="utf-8") - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) payload = _edit( { @@ -1427,7 +1435,7 @@ def test_relative_create_under_an_inferred_worktree_is_not_refused( ) -> None: """A path that exists in no candidate root has nothing to collide with.""" wt = _repo_with_worktree(workspace) - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) text = _edit_text( { @@ -1448,7 +1456,7 @@ def test_explicit_root_beats_the_inferred_worktree(workspace: Path, monkeypatch: wt = _repo_with_worktree(workspace) (workspace / "target.txt").write_text("SHARED\n", encoding="utf-8") (wt / "target.txt").write_text("SHARED\n", encoding="utf-8") - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) text = _edit_text( { @@ -1481,7 +1489,7 @@ def test_explicit_root_outside_the_workspace_is_refused( outside = tmp_path.parent / "outside-root" outside.mkdir(parents=True, exist_ok=True) (outside / "target.txt").write_text("OUTSIDE\n", encoding="utf-8") - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) payload = _edit( { @@ -1512,7 +1520,7 @@ def test_explicit_root_under_a_scratch_root_is_allowed( scratch.mkdir(parents=True, exist_ok=True) (scratch / "target.txt").write_text("SCRATCH\n", encoding="utf-8") monkeypatch.setattr(mcp_server, "_SCRATCH_EDIT_ROOTS", (scratch,)) - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) payload = _edit( { @@ -1534,7 +1542,7 @@ def test_worktree_redirect_is_disclosed_to_the_model(workspace: Path, monkeypatc """ wt = _repo_with_worktree(workspace) (wt / "target.txt").write_text("WORKTREE\n", encoding="utf-8") - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) text = _edit_text( { @@ -1551,7 +1559,7 @@ def test_no_worktree_evidence_keeps_the_main_checkout(workspace: Path, monkeypat """Without a worktree cwd, resolution is unchanged -- and says nothing.""" _repo_with_worktree(workspace) (workspace / "target.txt").write_text("MAIN\n", encoding="utf-8") - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(workspace)) + _set_bash_cwd(monkeypatch, str(workspace)) text = _edit_text( { @@ -1572,7 +1580,7 @@ def test_a_worktree_of_another_repo_does_not_redirect( other = tmp_path.parent / "other-repo" other.mkdir(parents=True, exist_ok=True) foreign_wt = _repo_with_worktree(other) - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(foreign_wt)) + _set_bash_cwd(monkeypatch, str(foreign_wt)) assert mcp_server._session_worktree_root(workspace) is None @@ -1590,7 +1598,7 @@ def test_absolute_main_checkout_path_survives_a_worktree_inference( wt = _repo_with_worktree(workspace) target = workspace / "main_only.txt" target.write_text("MAIN\n", encoding="utf-8") - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) payload = _edit( { @@ -1619,7 +1627,7 @@ def test_absolute_edit_under_a_symlinked_workspace_root_is_not_an_escape( link.symlink_to(real, target_is_directory=True) monkeypatch.setenv("CLAUDE_WORKSPACE_ROOT", str(link)) wt = _repo_with_worktree(real) - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) (link / "main_only.txt").write_text("MAIN\n", encoding="utf-8") payload = _edit( @@ -1634,18 +1642,19 @@ def test_absolute_edit_under_a_symlinked_workspace_root_is_not_an_escape( def test_bash_cwd_is_what_teaches_edit_where_the_session_is(monkeypatch: pytest.MonkeyPatch) -> None: - """Only a bash call's cwd is recorded -- it is the sole session-cwd signal.""" - monkeypatch.setattr(mcp_server, "_last_session_cwd", None) + """Only a bash call's cwd is recorded, per session -- the session-cwd signal on hosts without the hook.""" + _set_bash_cwd(monkeypatch, None) + bash_cwds = session_root._bash_cwds mcp_server._record_session_cwd("read", {"cwd": "/not/recorded"}) - assert mcp_server._last_session_cwd is None + assert bash_cwds.get("") is None mcp_server._record_session_cwd("bash", {"cwd": "/recorded"}) - assert mcp_server._last_session_cwd == "/recorded" + assert bash_cwds.get("") == "/recorded" # A bash call without an explicit cwd must not clear what we know. mcp_server._record_session_cwd("bash", {}) - assert mcp_server._last_session_cwd == "/recorded" + assert bash_cwds.get("") == "/recorded" # --------------------------------------------------------------------------- @@ -1740,7 +1749,7 @@ def test_edit_rooted_outside_the_workspace_root_reindexes_before_responding( wt = _repo_with_worktree(workspace) target = wt / "wt_only.py" target.write_text("VALUE = 1\n", encoding="utf-8") - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt)) + _set_bash_cwd(monkeypatch, str(wt)) responded = threading.Event() calls = _record_reindexes(monkeypatch, responded) @@ -1807,7 +1816,7 @@ def test_edit_reindex_off_switch_disables_both_reindexes(workspace: Path, monkey calls = _record_reindexes(monkeypatch, responded) for path in (str(workspace / "main_only.py"), "wt_only.py"): - monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt) if path == "wt_only.py" else None) + _set_bash_cwd(monkeypatch, str(wt) if path == "wt_only.py" else None) payload = _edit( { "post_edit_hooks": False, diff --git a/tests/gateway/test_session_root_routing.py b/tests/gateway/test_session_root_routing.py new file mode 100644 index 000000000..161b5d0cf --- /dev/null +++ b/tests/gateway/test_session_root_routing.py @@ -0,0 +1,396 @@ +"""Each session's calls route to the worktree it works in (ISS-10812, PLN-2070 PR 2). + +Every test builds a real git repository with real linked worktrees, indexes the +main checkout with the real engine, and drives the tools through the daemon's +HTTP dispatcher, so each request carries its own ``Mcp-Session-Id`` exactly as a +bridge sends it. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +import threading +import time +import types +import uuid +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from lemoncrow.gateway.adapters import mcp_http, mcp_server +from lemoncrow.gateway.adapters.mcp import session_root +from lemoncrow.infra.code_intel import worktree_seed +from lemoncrow.infra.code_intel.freshness import VersionedEngineCache +from lemoncrow.infra.code_intel.zoekt import adapter as zoekt_adapter +from lemoncrow.pro.capabilities.code_context import CodeContextEngine + +_GIT = ["git", "-c", "user.email=route@test", "-c", "user.name=route", "-c", "commit.gpgsign=false"] +_HOOKS = Path(__file__).resolve().parents[2] / "integrations" / "claude" / "plugin" / "hooks" +# wt_a's copy of pkg/shared.py carries this many extra lines above shared_target. +_SHIFT = 5 + + +@pytest.fixture(autouse=True) +def _isolated(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setenv("LEMONCROW_CODE_AUTOSYNC", "0") + monkeypatch.setenv("LEMONCROW_CODE_FILE_WATCHER", "0") + monkeypatch.setattr(worktree_seed, "_worktrees", {}) + monkeypatch.setattr(worktree_seed, "_checked_at", {}) + monkeypatch.setattr(zoekt_adapter, "_ROOT_OVERRIDES", {}) + monkeypatch.setattr(zoekt_adapter, "_SUPERVISORS", {}) + monkeypatch.setattr(session_root, "_bash_cwds", session_root.BashCwds()) + monkeypatch.setattr(session_root, "_cwd_files", session_root._SessionCwdFiles()) + cache = VersionedEngineCache( + "test", + recheck_seconds=0.0, + on_evict=mcp_server._retire_code_engine, + retire=worktree_seed.retire_worktree_engine, + ) + monkeypatch.setattr(mcp_server, "_code_engine_cache", cache) + monkeypatch.setattr(mcp_server, "_scoped_context_cache", {}) + yield + cache.stop_sweeper() + + +def _git(cwd: Path, *args: str) -> None: + subprocess.run([*_GIT, *args], cwd=cwd, check=True, capture_output=True, text=True) + + +def _refresh(root: Path) -> None: + """Open *root*'s engine through the daemon's factory (seeding a worktree) and bring it current.""" + mcp_server._code_context_engine(str(root)).index_repo(force=False) + + +@pytest.fixture +def repos(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path, Path]: + """(main, wt_a, wt_b): main is the workspace; each worktree holds a symbol only it has.""" + main = (tmp_path / "main").resolve() + (main / "pkg").mkdir(parents=True) + (main / "pkg" / "shared.py").write_text("def shared_target():\n return 1\n", encoding="utf-8") + (main / "pkg" / "base.py").write_text("def alpha_main():\n return 0\n", encoding="utf-8") + (main / ".gitignore").write_text(".lemoncrow/\n", encoding="utf-8") + _git(main, "init", "-q", "-b", "main") + _git(main, "add", "-A") + _git(main, "commit", "-q", "-m", "init") + wt_a = (tmp_path / "wt_a").resolve() + wt_b = (tmp_path / "wt_b").resolve() + _git(main, "worktree", "add", "-q", "-b", "a", str(wt_a)) + _git(main, "worktree", "add", "-q", "-b", "b", str(wt_b)) + CodeContextEngine(main, autosync_enabled=False).index_repo(force=True) + shifted = "".join(f"# a{i}\n" for i in range(_SHIFT)) + "def shared_target():\n return 1\n" + (wt_a / "pkg" / "shared.py").write_text(shifted, encoding="utf-8") + (wt_a / "pkg" / "only_a.py").write_text("def omega_only_a():\n return 'a'\n", encoding="utf-8") + (wt_b / "pkg" / "only_b.py").write_text("def omega_only_b():\n return 'b'\n", encoding="utf-8") + for worktree in (wt_a, wt_b): + _refresh(worktree) + monkeypatch.setenv("CLAUDE_WORKSPACE_ROOT", str(main)) + return main, wt_a, wt_b + + +def _session() -> str: + return f"s-{uuid.uuid4().hex[:12]}" + + +def _record_cwd(session_id: str, cwd: Path | str) -> None: + """What the plugin's PreToolUse hook leaves behind for *session_id*.""" + directory = Path(os.environ["LEMONCROW_ROOT"]) / session_root.SESSION_CWD_DIRNAME + directory.mkdir(parents=True, exist_ok=True) + (directory / session_id).write_text(str(cwd), encoding="utf-8") + + +def _call(session_id: str, name: str, args: dict[str, Any]) -> dict[str, Any]: + request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}} + response = mcp_http._dispatch(request, session_id=session_id, host="claude", bridge_id="bridge-test") + assert isinstance(response, dict) and "result" in response, response + result = response["result"] + assert isinstance(result, dict) + return result + + +def _text(session_id: str, name: str, args: dict[str, Any]) -> str: + result = _call(session_id, name, args) + assert not result.get("isError"), result + return str(result["content"][0]["text"]) + + +def _search(session_id: str, query: str, **extra: Any) -> str: + return _text(session_id, "code_search", {"query": query, **extra}) + + +def test_two_concurrent_sessions_each_search_their_own_worktree(repos: tuple[Path, Path, Path]) -> None: + """AC-2.1 / AC-2.2: no repo_root, each session answers from its worktree, with its line numbers.""" + main, wt_a, wt_b = repos + session_a, session_b, session_main = _session(), _session(), _session() + _record_cwd(session_a, wt_a / "pkg") # a subdirectory still names its worktree + _record_cwd(session_b, wt_b) + _record_cwd(session_main, main) + barrier = threading.Barrier(2) + texts: dict[str, str] = {} + + def run(session_id: str, query: str) -> None: + barrier.wait(timeout=30) + texts[session_id] = _search(session_id, query) + + threads = [ + threading.Thread(target=run, args=(session_a, "omega_only_a")), + threading.Thread(target=run, args=(session_b, "omega_only_b")), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=60) + + assert "omega_only_a" in texts[session_a], texts[session_a] + assert f"repo_root: {wt_a}" in texts[session_a].splitlines()[0], texts[session_a] + assert "omega_only_b" in texts[session_b], texts[session_b] + assert f"repo_root: {wt_b}" in texts[session_b].splitlines()[0], texts[session_b] + # Line numbers are the worktree's own: wt_a's shared_target sits _SHIFT lines lower. + in_a = _search(session_a, "shared_target") + in_b = _search(session_b, "shared_target") + in_main = _search(session_main, "shared_target") + assert f"shared_target L{1 + _SHIFT}-L{2 + _SHIFT}" in in_a, in_a + assert "shared_target L1-L2" in in_b, in_b + assert "shared_target L1-L2" in in_main and "repo_root:" not in in_main, in_main + assert "omega_only_a" not in _search(session_b, "omega_only_a function") + + +def test_absolute_paths_inside_a_worktree_route_to_it_even_from_the_main_checkout( + repos: tuple[Path, Path, Path], +) -> None: + """AC-2.3: paths name the worktree, so its index answers whatever the session's cwd says.""" + main, wt_a, wt_b = repos + session_id = _session() + _record_cwd(session_id, main) + + text = _search(session_id, "omega_only_a", paths=[str(wt_a / "pkg")]) + + assert "omega_only_a" in text, text + assert f"repo_root: {wt_a}" in text.splitlines()[0], text + refused = _call(session_id, "code_search", {"query": "shared_target", "paths": [str(wt_a), str(wt_b / "pkg")]}) + assert refused.get("isError") is True, refused + message = refused["content"][0]["text"] + assert str(wt_a) in message and str(wt_b) in message, message + + +def _other_repos_worktree(tmp_path: Path) -> Path: + other = tmp_path / "other" + other.mkdir() + (other / "f.txt").write_text("x\n", encoding="utf-8") + _git(other, "init", "-q", "-b", "main") + _git(other, "add", "-A") + _git(other, "commit", "-q", "-m", "init") + worktree = tmp_path / "other_wt" + _git(other, "worktree", "add", "-q", "-b", "o", str(worktree)) + return worktree + + +@pytest.mark.parametrize("kind", ["another-repo", "plain-dir", "missing", "relative"]) +def test_a_recorded_cwd_outside_the_workspace_is_never_used( + repos: tuple[Path, Path, Path], tmp_path: Path, kind: str +) -> None: + """AC-2.6: only the workspace root or one of its linked worktrees can route a call.""" + _main, wt_a, _wt_b = repos + plain = tmp_path / "plain" + plain.mkdir() + cwd = { + "another-repo": str(_other_repos_worktree(tmp_path)), + "plain-dir": str(plain), + "missing": str(tmp_path / "gone"), + "relative": os.path.relpath(wt_a), + }[kind] + session_id = _session() + _record_cwd(session_id, cwd) + + text = _search(session_id, "shared_target") + + assert "shared_target L1-L2" in text and "repo_root:" not in text, text + assert session_root.accept_session_cwd(repos[0], cwd) is None + + +def test_relative_creates_land_in_each_calling_sessions_worktree(repos: tuple[Path, Path, Path]) -> None: + """AC-2.2: a relative create follows the recorded cwd, or else the session's own last bash cwd.""" + main, wt_a, wt_b = repos + hooked_a, hooked_b, bash_a, bash_b = _session(), _session(), _session(), _session() + _record_cwd(hooked_a, wt_a) + _record_cwd(hooked_b, wt_b) + # Hosts without the hook: each session's bash cwd is its own, never the last one seen. + _text(bash_a, "bash", {"command": "true", "cwd": str(wt_a)}) + _text(bash_b, "bash", {"command": "true", "cwd": str(wt_b)}) + + for session_id, name in ( + (hooked_a, "hooked.txt"), + (hooked_b, "hooked.txt"), + (bash_a, "bash.txt"), + (bash_b, "bash.txt"), + ): + _text(session_id, "edit", {"edits": [{"path": name, "new": f"{session_id}\n", "replace": True}]}) + + assert (wt_a / "hooked.txt").read_text(encoding="utf-8") == f"{hooked_a}\n" + assert (wt_b / "hooked.txt").read_text(encoding="utf-8") == f"{hooked_b}\n" + assert (wt_a / "bash.txt").read_text(encoding="utf-8") == f"{bash_a}\n" + assert (wt_b / "bash.txt").read_text(encoding="utf-8") == f"{bash_b}\n" + assert not (main / "hooked.txt").exists() and not (main / "bash.txt").exists() + + +def test_reads_and_cwd_less_bash_follow_the_session_into_its_worktree(repos: tuple[Path, Path, Path]) -> None: + main, wt_a, _wt_b = repos + in_wt, in_main = _session(), _session() + _record_cwd(in_wt, wt_a) + _record_cwd(in_main, main) + + assert f"{1 + _SHIFT}\tdef shared_target" in _text(in_wt, "read", {"files": ["pkg/shared.py"]}) + assert _text(in_main, "read", {"files": ["pkg/shared.py"]}) == "def shared_target():\n return 1\n" + assert str(wt_a) in _text(in_wt, "bash", {"command": "pwd -P"}) + assert str(wt_a) not in _text(in_main, "bash", {"command": "pwd -P"}) + + +def test_the_resolved_against_note_appears_only_for_a_relative_path(repos: tuple[Path, Path, Path]) -> None: + """AC-2.5: an absolute path owes nothing to the inferred worktree, so it says nothing about it.""" + _main, wt_a, wt_b = repos + by_bash, by_hook = _session(), _session() + _text(by_bash, "bash", {"command": "true", "cwd": str(wt_a)}) + _record_cwd(by_hook, wt_b) + + absolute = _text( + by_bash, + "edit", + {"edits": [{"path": str(wt_a / "pkg" / "only_a.py"), "old": "'a'", "new": "'A'"}]}, + ) + relative = _text(by_bash, "edit", {"edits": [{"path": "pkg/only_a.py", "old": "'A'", "new": "'AA'"}]}) + hooked = _text(by_hook, "edit", {"edits": [{"path": "pkg/only_b.py", "old": "'b'", "new": "'B'"}]}) + + assert "resolved against" not in absolute, absolute + assert f"resolved against worktree {wt_a} (from last bash cwd)" in relative, relative + assert f"resolved against worktree {wt_b} (from session cwd)" in hooked, hooked + assert (wt_a / "pkg" / "only_a.py").read_text(encoding="utf-8").endswith("'AA'\n") + + +_GOLDEN_CALLS: tuple[tuple[str, dict[str, Any]], ...] = ( + ("code_search", {"query": "shared_target"}), + ("code_query", {"select": "symbols", "where": {"name_regex": "^(alpha|shared)"}}), + ("relations", {"symbol": "shared_target", "kind": "self"}), + ("read", {"files": ["pkg/shared.py"]}), +) + + +def test_a_main_checkout_session_answers_as_an_unrouted_call( + repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """AC-2.4: a session in the main checkout gets byte-identical answers to a call routing never touched.""" + main, _wt_a, _wt_b = repos + routed = _session() + _record_cwd(routed, main) + routed_texts = [_text(routed, name, args) for name, args in _GOLDEN_CALLS] + + monkeypatch.setattr(session_root, "_current", lambda: None) + unrouted = _session() + unrouted_texts = [_text(unrouted, name, args) for name, args in _GOLDEN_CALLS] + + assert routed_texts == unrouted_texts + assert all("repo_root:" not in text for text in routed_texts) + + +def test_the_bash_cwd_map_is_bounded_and_forgets_idle_sessions() -> None: + """AC-2.7: at most 256 sessions, least recently used out first; 24 idle hours forgets one.""" + now = [0.0] + cwds = session_root.BashCwds(clock=lambda: now[0]) + for i in range(session_root.MAX_SESSIONS + 10): + cwds.record(f"s{i}", f"/cwd/{i}") + assert len(cwds) == 256 + assert cwds.get("s0") is None and cwds.get("s9") is None + assert cwds.get("s10") == "/cwd/10" + + now[0] = 23 * 3600.0 + assert cwds.get("s11") == "/cwd/11" # used, so its idle clock restarts + now[0] = 24 * 3600.0 + assert cwds.get("s12") is None + assert cwds.get("s11") == "/cwd/11" + assert len(cwds) == 1 + + +def _run_hook(script: str, payload: dict[str, Any], store: Path, **env: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(_HOOKS / script)], + input=json.dumps(payload), + capture_output=True, + text=True, + env={**os.environ, "LEMONCROW_ROOT": str(store), **env}, + timeout=60, + ) + + +def test_the_hook_writes_the_cwd_on_a_change_and_skips_an_unchanged_one(tmp_path: Path) -> None: + store = tmp_path / "store" + payload = {"session_id": "sess-1", "cwd": "/work/one", "tool_name": "mcp__lc__edit", "tool_input": {}} + record = store / "session_cwd" / "sess-1" + + assert _run_hook("mcp_read_allow.py", payload, store).returncode == 0 + assert record.read_text(encoding="utf-8") == "/work/one" + assert (record.parent.stat().st_mode & 0o777) == 0o700 + first = record.stat() + os.utime(record, ns=(first.st_atime_ns, first.st_mtime_ns - 10**9)) + written = record.stat().st_mtime_ns + + _run_hook("mcp_read_allow.py", payload, store) + assert record.stat().st_mtime_ns == written, "an unchanged cwd was rewritten" + + _run_hook("mcp_read_allow.py", {**payload, "cwd": "/work/two"}, store) + assert record.read_text(encoding="utf-8") == "/work/two" + assert sorted(p.name for p in record.parent.iterdir()) == ["sess-1"], "a temp file was left behind" + + +def test_the_hook_records_before_every_early_return_and_never_changes_its_decision(tmp_path: Path) -> None: + store = tmp_path / "store" + edit = {"session_id": "sess-2", "cwd": "/work/edit", "tool_name": "mcp__lc__edit", "tool_input": {}} + read = {**edit, "cwd": "/work/read", "tool_name": "mcp__lc__read"} + + killed = _run_hook("mcp_read_allow.py", edit, store, LEMONCROW_MCP_READ_ALLOW="0") + assert killed.stdout == "" and (store / "session_cwd" / "sess-2").read_text(encoding="utf-8") == "/work/edit" + + allowed = _run_hook("mcp_read_allow.py", read, store) + assert json.loads(allowed.stdout)["hookSpecificOutput"]["permissionDecision"] == "allow" + assert (store / "session_cwd" / "sess-2").read_text(encoding="utf-8") == "/work/read" + + blocked = tmp_path / "not-a-dir" + blocked.write_text("", encoding="utf-8") + failed = _run_hook("mcp_read_allow.py", read, blocked) + assert failed.returncode == 0 + assert failed.stdout == allowed.stdout + assert "session cwd not recorded" in failed.stderr + + +def _session_start_module() -> types.ModuleType: + spec = importlib.util.spec_from_file_location("lemoncrow_session_start_under_test", _HOOKS / "session_start.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_session_start_prunes_cwd_records_older_than_seven_days(tmp_path: Path) -> None: + """AC-2.7: SessionStart drops what no session has refreshed in 7 days.""" + store = tmp_path / "store" + records = store / "session_cwd" + records.mkdir(parents=True) + week = 7 * 24 * 3600 + now = time.time() + for name, age in (("stale", week + 60), ("fresh", week - 60)): + (records / name).write_text("/work", encoding="utf-8") + os.utime(records / name, (now - age, now - age)) + + started = _run_hook( + "session_start.py", + {"session_id": "sess-3", "cwd": str(tmp_path), "source": "resume"}, + store, + CLAUDE_WORKSPACE_ROOT=str(tmp_path), + ) + + assert started.returncode == 0, started.stderr + assert sorted(p.name for p in records.iterdir()) == ["fresh"] + assert _session_start_module()._SESSION_CWD_MAX_AGE_S == week From a775115bfc3653673e71e6194ea013398ee0740e Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 14:07:03 -0500 Subject: [PATCH 2/9] ISS-10812: prune noise comments and a restated-constant assertion from session routing --- src/lemoncrow/gateway/adapters/mcp/bash.py | 2 -- src/lemoncrow/gateway/adapters/mcp/session_root.py | 1 - tests/gateway/test_session_root_routing.py | 11 ----------- 3 files changed, 14 deletions(-) diff --git a/src/lemoncrow/gateway/adapters/mcp/bash.py b/src/lemoncrow/gateway/adapters/mcp/bash.py index 0fc57897a..3d7f36d81 100644 --- a/src/lemoncrow/gateway/adapters/mcp/bash.py +++ b/src/lemoncrow/gateway/adapters/mcp/bash.py @@ -451,8 +451,6 @@ def _render_grep_stdout(payload: dict[str, Any]) -> str: # directory) so the command still runs instead of hard-failing. if not Path(workspace).is_dir(): workspace = os.getcwd() - # A command with no cwd runs in the linked worktree the session's recorded - # cwd puts it in (session_root step 3), else the workspace as before. _session_worktree = None if cwd else session_root.session_worktree(Path(workspace)) effective_cwd = cwd or (str(_session_worktree) if _session_worktree is not None else workspace) diff --git a/src/lemoncrow/gateway/adapters/mcp/session_root.py b/src/lemoncrow/gateway/adapters/mcp/session_root.py index bc68b88d9..bef0c9ff7 100644 --- a/src/lemoncrow/gateway/adapters/mcp/session_root.py +++ b/src/lemoncrow/gateway/adapters/mcp/session_root.py @@ -79,7 +79,6 @@ SESSION_CWD_DIRNAME = "session_cwd" #: Sessions whose bash cwd (and recorded-cwd read) is remembered at once. MAX_SESSIONS = 256 -#: A session's bash cwd is forgotten after this long without use. BASH_CWD_IDLE_S = 24 * 3600.0 #: :attr:`SessionRoot.source` values: which precedence step answered. diff --git a/tests/gateway/test_session_root_routing.py b/tests/gateway/test_session_root_routing.py index 161b5d0cf..e80147cbd 100644 --- a/tests/gateway/test_session_root_routing.py +++ b/tests/gateway/test_session_root_routing.py @@ -8,14 +8,12 @@ from __future__ import annotations -import importlib.util import json import os import subprocess import sys import threading import time -import types import uuid from collections.abc import Iterator from pathlib import Path @@ -365,14 +363,6 @@ def test_the_hook_records_before_every_early_return_and_never_changes_its_decisi assert "session cwd not recorded" in failed.stderr -def _session_start_module() -> types.ModuleType: - spec = importlib.util.spec_from_file_location("lemoncrow_session_start_under_test", _HOOKS / "session_start.py") - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - def test_session_start_prunes_cwd_records_older_than_seven_days(tmp_path: Path) -> None: """AC-2.7: SessionStart drops what no session has refreshed in 7 days.""" store = tmp_path / "store" @@ -393,4 +383,3 @@ def test_session_start_prunes_cwd_records_older_than_seven_days(tmp_path: Path) assert started.returncode == 0, started.stderr assert sorted(p.name for p in records.iterdir()) == ["fresh"] - assert _session_start_module()._SESSION_CWD_MAX_AGE_S == week From eec5e595222cbc5eb4b44048bdd7b6263de25a43 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 14:27:46 -0500 Subject: [PATCH 3/9] ISS-10812: key near-repeat code_search suppression by checkout too A session that searched in the main checkout and repeated the query after entering a worktree (or naming one through paths) got a blank result stamped with the worktree's repo_root. The recent-query bucket is now keyed by (session, answering checkout). Co-Authored-By: Claude <> --- src/lemoncrow/gateway/adapters/mcp_server.py | 33 +++++++++++--------- tests/gateway/test_mcp_tool_handlers.py | 18 +++++------ tests/gateway/test_session_root_routing.py | 19 +++++++++++ 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/lemoncrow/gateway/adapters/mcp_server.py b/src/lemoncrow/gateway/adapters/mcp_server.py index 2a5670697..ff9d740eb 100644 --- a/src/lemoncrow/gateway/adapters/mcp_server.py +++ b/src/lemoncrow/gateway/adapters/mcp_server.py @@ -5844,9 +5844,12 @@ def _read_summary_response(resolved: Path) -> dict[str, Any]: _range_read_sigs_lock = threading.Lock() _MAX_RANGE_READ_SIG_SESSIONS = _MAX_HTTP_SESSION_LEDGERS -# Per-session recent code_search queries, same session-bucketing rationale as -# _RANGE_READ_SIGS above (HTTP: per client session; stdio: one process-wide -# "_global" bucket per host window). Feeds _check_repeat_query's near- +# Per-session, per-checkout recent code_search queries, same session-bucketing +# rationale as _RANGE_READ_SIGS above (HTTP: per client session; stdio: one +# process-wide "_global" bucket per host window). Keyed by the checkout the +# search answered from as well: a session that moves into a linked worktree +# (or names one through absolute paths) asks a different index, so the same +# words there are a new question, not a repeat. Feeds _check_repeat_query's near- # duplicate nudge -- measured 11 near-duplicate code_search calls in a single # debt-benchmark rep (2026-07-25): "fail-on-stale stale-days debt_cmd", # "stale_days fail_on_stale debt stale age git blame", "_line_age_days git @@ -5860,7 +5863,7 @@ def _read_summary_response(resolved: Path) -> dict[str, Any]: # previous, unrelated chat, possibly hours earlier) would be wrongly blanked. # Bounding to _RECENT_QUERY_WINDOW_SECONDS keeps the check scoped to "this # same burst of searching", not "this process's entire lifetime". -_RECENT_CODE_SEARCH_QUERIES: OrderedDict[str, deque[tuple[float, str, frozenset[str]]]] = OrderedDict() +_RECENT_CODE_SEARCH_QUERIES: OrderedDict[tuple[str, str], deque[tuple[float, str, frozenset[str]]]] = OrderedDict() _recent_code_search_queries_lock = threading.Lock() _MAX_RECENT_QUERY_SESSIONS = _MAX_HTTP_SESSION_LEDGERS _RECENT_QUERY_HISTORY = 3 @@ -5896,26 +5899,28 @@ def _query_words(query: str) -> frozenset[str]: return frozenset(w for w in _QUERY_WORD_RE.findall(query.lower()) if len(w) >= 3) -def _recent_code_search_queries() -> deque[tuple[float, str, frozenset[str]]]: - """The current session's recent-query bucket (see _RECENT_CODE_SEARCH_QUERIES).""" +def _recent_code_search_queries(root: str) -> deque[tuple[float, str, frozenset[str]]]: + """The current session's recent-query bucket for checkout *root* (see _RECENT_CODE_SEARCH_QUERIES).""" led = getattr(_request_ledger, "value", None) sid = led.session_id if isinstance(led, RunLedger) and led.session_id else "_global" + key = (sid, root) with _recent_code_search_queries_lock: - bucket = _RECENT_CODE_SEARCH_QUERIES.get(sid) + bucket = _RECENT_CODE_SEARCH_QUERIES.get(key) if bucket is None: if len(_RECENT_CODE_SEARCH_QUERIES) >= _MAX_RECENT_QUERY_SESSIONS: _RECENT_CODE_SEARCH_QUERIES.popitem(last=False) bucket = deque(maxlen=_RECENT_QUERY_HISTORY) - _RECENT_CODE_SEARCH_QUERIES[sid] = bucket + _RECENT_CODE_SEARCH_QUERIES[key] = bucket else: - _RECENT_CODE_SEARCH_QUERIES.move_to_end(sid) + _RECENT_CODE_SEARCH_QUERIES.move_to_end(key) return bucket -def _check_repeat_query(query: str) -> bool: +def _check_repeat_query(query: str, root: str) -> bool: """True when *query* overlaps heavily (Jaccard >= floor) with a query this - session ran within the last _RECENT_QUERY_WINDOW_SECONDS -- re-phrasing the - same investigation rarely surfaces anything new. A hard signal, not a + session ran against checkout *root* within the last + _RECENT_QUERY_WINDOW_SECONDS -- re-phrasing the same investigation rarely + surfaces anything new. A hard signal, not a hint: an explanatory sentence here just adds tokens to a response the caller has already shown it skims past and searches again anyway (measured across debt-benchmark reps). The caller returns blank results instead -- @@ -5925,7 +5930,7 @@ def _check_repeat_query(query: str) -> bool: coincidental word overlap with a stale query from a previous chat. """ words = _query_words(query) - bucket = _recent_code_search_queries() + bucket = _recent_code_search_queries(root) now = time.monotonic() is_repeat = False if words: @@ -11146,7 +11151,7 @@ def tool_code_search( # engine entirely and returns blank -- no explanatory text (measured: the # caller skims past prose hints and searches again anyway; blank results # cost nothing and say the same thing). - if _check_repeat_query(query): + if _check_repeat_query(query, str(workspace_root)): return _attach_code_search_savings({"exact_match": False, "files": []}, workspace_root) # Normalise: paths param accepts list, comma-sep string, or single path # (the legacy `path` kwarg is folded into `paths` by param_aliases). diff --git a/tests/gateway/test_mcp_tool_handlers.py b/tests/gateway/test_mcp_tool_handlers.py index 8582cda62..79c3a49c2 100644 --- a/tests/gateway/test_mcp_tool_handlers.py +++ b/tests/gateway/test_mcp_tool_handlers.py @@ -3615,7 +3615,7 @@ def test_check_repeat_query_flags_the_debt_benchmark_regression() -> None: "_line_age_days git blame annotate stale", "line age days git blame last modified", ] - flags = [mcp_server._check_repeat_query(q) for q in queries] + flags = [mcp_server._check_repeat_query(q, "/repo") for q in queries] assert all(isinstance(f, bool) for f in flags) assert flags[0] is False # nothing recorded yet assert any(flags[1:]), "expected at least one repeat-query flag" @@ -3623,15 +3623,15 @@ def test_check_repeat_query_flags_the_debt_benchmark_regression() -> None: def test_check_repeat_query_false_for_distinct_topics() -> None: mcp_server._RECENT_CODE_SEARCH_QUERIES.clear() - assert mcp_server._check_repeat_query("parse yaml config loader") is False - assert mcp_server._check_repeat_query("websocket reconnect backoff timer") is False - assert mcp_server._check_repeat_query("database connection pool sizing") is False + assert mcp_server._check_repeat_query("parse yaml config loader", "/repo") is False + assert mcp_server._check_repeat_query("websocket reconnect backoff timer", "/repo") is False + assert mcp_server._check_repeat_query("database connection pool sizing", "/repo") is False def test_check_repeat_query_records_into_session_bucket() -> None: mcp_server._RECENT_CODE_SEARCH_QUERIES.clear() - mcp_server._check_repeat_query("parse yaml config loader") - bucket = mcp_server._recent_code_search_queries() + mcp_server._check_repeat_query("parse yaml config loader", "/repo") + bucket = mcp_server._recent_code_search_queries("/repo") assert len(bucket) == 1 assert bucket[0][1] == "parse yaml config loader" assert bucket[0][2] == mcp_server._query_words("parse yaml config loader") @@ -3642,11 +3642,11 @@ def test_check_repeat_query_ignores_entries_outside_time_window() -> None: # session) -- a query from a previous, unrelated chat must not blank a # brand-new conversation's first call just because the words overlap. mcp_server._RECENT_CODE_SEARCH_QUERIES.clear() - mcp_server._check_repeat_query("fail-on-stale stale-days debt_cmd") - bucket = mcp_server._recent_code_search_queries() + mcp_server._check_repeat_query("fail-on-stale stale-days debt_cmd", "/repo") + bucket = mcp_server._recent_code_search_queries("/repo") ts, text, words = bucket[0] bucket[0] = (ts - mcp_server._RECENT_QUERY_WINDOW_SECONDS - 1, text, words) - assert mcp_server._check_repeat_query("stale_days fail_on_stale debt marker age") is False + assert mcp_server._check_repeat_query("stale_days fail_on_stale debt marker age", "/repo") is False def test_render_code_search_md_renders_blank_result_with_no_hint_text() -> None: diff --git a/tests/gateway/test_session_root_routing.py b/tests/gateway/test_session_root_routing.py index e80147cbd..c6210e6e2 100644 --- a/tests/gateway/test_session_root_routing.py +++ b/tests/gateway/test_session_root_routing.py @@ -176,6 +176,25 @@ def test_absolute_paths_inside_a_worktree_route_to_it_even_from_the_main_checkou assert str(wt_a) in message and str(wt_b) in message, message +def test_a_query_repeated_in_another_checkout_is_answered_and_blanked_only_in_the_same_one( + repos: tuple[Path, Path, Path], +) -> None: + """The near-repeat suppression counts a query as a repeat only against the checkout it was asked in.""" + main, wt_a, wt_b = repos + session_id = _session() + _record_cwd(session_id, main) + assert "shared_target L1-L2" in _search(session_id, "shared_target") + + _record_cwd(session_id, wt_a) # what EnterWorktree leaves behind + in_a = _search(session_id, "shared_target") + in_b = _search(session_id, "shared_target", paths=[str(wt_b / "pkg")]) + repeated_in_a = _search(session_id, "shared_target") + + assert f"shared_target L{1 + _SHIFT}-L{2 + _SHIFT}" in in_a, in_a + assert "shared_target L1-L2" in in_b and f"repo_root: {wt_b}" in in_b, in_b + assert repeated_in_a.startswith("no exact match") and "shared_target" not in repeated_in_a, repeated_in_a + + def _other_repos_worktree(tmp_path: Path) -> Path: other = tmp_path / "other" other.mkdir() From 12415c8b817dfc2140ad720085044b52db0d37bc Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 14:27:58 -0500 Subject: [PATCH 4/9] ISS-10812: resolve bash's redundant-dump check where a cwd-less command runs A cwd-less bash now runs in the session's worktree, but the "already read in full" check still resolved its relative path against the main checkout, so `cat` of a worktree file was refused after a full read of the main checkout's copy. Co-Authored-By: Claude <> --- src/lemoncrow/gateway/adapters/mcp/bash.py | 8 +++++++- tests/gateway/test_session_root_routing.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/lemoncrow/gateway/adapters/mcp/bash.py b/src/lemoncrow/gateway/adapters/mcp/bash.py index 3d7f36d81..82c24f127 100644 --- a/src/lemoncrow/gateway/adapters/mcp/bash.py +++ b/src/lemoncrow/gateway/adapters/mcp/bash.py @@ -1066,6 +1066,12 @@ def _render_bash_text(result: dict[str, Any]) -> str: return "" +def _session_worktree_cwd() -> str | None: + """The session's linked worktree, where a command with no ``cwd`` runs (see _run_bash_tool), else None.""" + worktree = session_root.session_worktree(Path(os.environ.get("CLAUDE_WORKSPACE_ROOT") or os.getcwd())) + return None if worktree is None else str(worktree) + + def _lift_bash_command_list(args: dict[str, Any], known_params: frozenset[str]) -> dict[str, Any]: """Recover list-for-string args: command=[...] -> commands, id=[...] -> ids.""" cmd = args.get("command") @@ -1257,7 +1263,7 @@ def tool_bash( # bash(id=x) with no explicit action = wait for the run to finish. action = "poll" if action == "run" and command and not bg and not interactive: - _dump_notice = _check_redundant_file_dump(command, cwd) + _dump_notice = _check_redundant_file_dump(command, cwd or _session_worktree_cwd()) if _dump_notice: return _dump_notice result = _run_bash_tool( diff --git a/tests/gateway/test_session_root_routing.py b/tests/gateway/test_session_root_routing.py index c6210e6e2..c48241b6d 100644 --- a/tests/gateway/test_session_root_routing.py +++ b/tests/gateway/test_session_root_routing.py @@ -267,6 +267,24 @@ def test_reads_and_cwd_less_bash_follow_the_session_into_its_worktree(repos: tup assert str(wt_a) not in _text(in_main, "bash", {"command": "pwd -P"}) +def test_a_file_read_in_full_in_the_main_checkout_does_not_block_its_worktree_copys_cat( + repos: tuple[Path, Path, Path], +) -> None: + """The redundant-dump check resolves a relative path where the cwd-less command runs.""" + main, wt_a, _wt_b = repos + session_id = _session() + _record_cwd(session_id, main) + _text(session_id, "read", {"files": ["pkg/shared.py:full"]}) + + _record_cwd(session_id, wt_a) + worktree_copy = _text(session_id, "bash", {"command": "cat pkg/shared.py"}) + _text(session_id, "read", {"files": ["pkg/shared.py:full"]}) + reread = _text(session_id, "bash", {"command": "cat pkg/shared.py"}) + + assert "# a0" in worktree_copy, worktree_copy + assert reread.startswith("[lc: already read in full"), reread + + def test_the_resolved_against_note_appears_only_for_a_relative_path(repos: tuple[Path, Path, Path]) -> None: """AC-2.5: an absolute path owes nothing to the inferred worktree, so it says nothing about it.""" _main, wt_a, wt_b = repos From a2935c2d1ca1c11073c797e27c08e021b3b6222f Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 14:28:11 -0500 Subject: [PATCH 5/9] ISS-10812: name edit's session-cwd disclosure through session_root.SESSION_CWD Co-Authored-By: Claude <> --- src/lemoncrow/gateway/adapters/mcp_server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lemoncrow/gateway/adapters/mcp_server.py b/src/lemoncrow/gateway/adapters/mcp_server.py index ff9d740eb..5457598d6 100644 --- a/src/lemoncrow/gateway/adapters/mcp_server.py +++ b/src/lemoncrow/gateway/adapters/mcp_server.py @@ -5529,7 +5529,7 @@ def render_tool_result_text(name: str, result: Any) -> str | None: if isinstance(resolved_against, str) and resolved_against: if payload.get("resolved_against_source") == "explicit": text = f"{text} | resolved against root {resolved_against} (explicit root argument)" - elif payload.get("resolved_against_source") == "session_cwd": + elif payload.get("resolved_against_source") == session_root.SESSION_CWD: text = f"{text} | resolved against worktree {resolved_against} (from session cwd)" else: text = f"{text} | resolved against worktree {resolved_against} (from last bash cwd)" @@ -8406,7 +8406,9 @@ def _fmt_diag(d: dict[str, Any], root: Path) -> str: result["resolved_against_source"] = "explicit" elif _session_worktree is not None and _edits_name_relative_paths(edits): result["resolved_against"] = str(_session_worktree) - result["resolved_against_source"] = "session_cwd" if _session.source == session_root.SESSION_CWD else "inferred" + result["resolved_against_source"] = ( + session_root.SESSION_CWD if _session.source == session_root.SESSION_CWD else "inferred" + ) return _silence_clean_edit_result(result) From 4d1310b668d48464dde9b880692779e2490d1d15 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 15:03:03 -0500 Subject: [PATCH 6/9] ISS-10812: make a fresh worktree's first code calls wait for its first refresh --- src/lemoncrow/gateway/adapters/mcp_server.py | 13 ++++- src/lemoncrow/infra/code_intel/freshness.py | 10 ++++ .../infra/code_intel/worktree_seed.py | 53 ++++++++++++++++--- tests/gateway/test_session_root_routing.py | 34 ++++++++++++ 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/lemoncrow/gateway/adapters/mcp_server.py b/src/lemoncrow/gateway/adapters/mcp_server.py index 5457598d6..5a7bac826 100644 --- a/src/lemoncrow/gateway/adapters/mcp_server.py +++ b/src/lemoncrow/gateway/adapters/mcp_server.py @@ -179,6 +179,7 @@ FRESHNESS_REFRESHING, IndexRebuilding, VersionedEngineCache, + note_refreshing, reset_readiness_probes, take_refreshing, ) @@ -9033,9 +9034,17 @@ def _code_context_engine(repo_root: str = ".") -> Any: 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) + seeded_engine, _ = _code_engine_cache.get(cache_key, resolved, lambda: CodeContextEngine(resolved)) + worktree_seed.start_first_refresh(seeded_engine, cache_key) + # Waited out before the engine is fetched, so the index probe sees the + # refreshed index instead of the refresh's write lock. + refreshed = worktree_seed.await_first_refresh(cache_key) + if refreshed: + _code_engine_cache.recheck(cache_key) + engine, freshness = _code_engine_cache.get(cache_key, resolved, lambda: CodeContextEngine(resolved)) + if refreshed is False: + note_refreshing() _code_index_freshness_for_current_call.value = freshness return engine diff --git a/src/lemoncrow/infra/code_intel/freshness.py b/src/lemoncrow/infra/code_intel/freshness.py index a009170a0..1339ed79b 100644 --- a/src/lemoncrow/infra/code_intel/freshness.py +++ b/src/lemoncrow/infra/code_intel/freshness.py @@ -70,6 +70,7 @@ "IndexState", "VersionedEngineCache", "index_state", + "note_refreshing", "require_ready", "reset_readiness_probes", "take_refreshing", @@ -177,6 +178,11 @@ def take_refreshing() -> bool: return seen +def note_refreshing() -> None: + """Flag this thread's call as answered from an index that is still being refreshed.""" + _refreshing_seen.value = True + + def index_lock_path(repo_root: Path | str = ".") -> Path: """Path of the engine's index-write lock for *repo_root*.""" return Path(str(workspace_dir(repo_root) / CODE_CONTEXT_DB) + INDEX_LOCK_SUFFIX) @@ -426,6 +432,10 @@ def version_of(self, key: str) -> int | None: entry = self._entries.get(key) return None if entry is None else entry.index_version + def recheck(self, key: str) -> None: + """Make the next :meth:`get` for *key* re-read the index state, whatever *recheck_seconds* says.""" + self._probes.pop(key, None) + def discard(self, key: str) -> None: with self._lock: entry = self._entries.pop(key, None) diff --git a/src/lemoncrow/infra/code_intel/worktree_seed.py b/src/lemoncrow/infra/code_intel/worktree_seed.py index 6e01d7dde..0e41dc105 100644 --- a/src/lemoncrow/infra/code_intel/worktree_seed.py +++ b/src/lemoncrow/infra/code_intel/worktree_seed.py @@ -60,12 +60,14 @@ __all__ = [ "ALIAS_KEY_PREFIX", "BUSY", + "FIRST_REFRESH_WAIT_S", "INDEX_DBS", "SEEDED", "SEEDED_FROM_KEY", "UNAVAILABLE", "WORKTREE_ENGINE_IDLE_ENV", "SeedResult", + "await_first_refresh", "checkpoint_index", "ensure_seeded", "forget_worktree", @@ -103,6 +105,14 @@ #: How often the factory re-checks a worktree it already has an engine for, so a #: worktree whose index went stale (main rebuilt to a new format) gets re-seeded. SEED_RECHECK_S = 60.0 +#: Seconds after a just-seeded worktree's first refresh starts during which code +#: tool calls on that worktree wait for it, so a session's first answers come from +#: the worktree's own files instead of the main checkout's seed. The window is +#: counted from the refresh's start, not per call: a refresh slower than this +#: costs the worktree's callers one wait in total, and every call after the window +#: answers from the seed, marked refreshing. A small worktree's first refresh +#: lands in about a second. +FIRST_REFRESH_WAIT_S = 10.0 WORKTREE_ENGINE_IDLE_ENV = "LEMONCROW_WORKTREE_ENGINE_IDLE_S" #: Seconds a worktree engine may go without a request before the daemon unloads @@ -121,6 +131,9 @@ _worktrees: dict[str, Path] = {} #: Monotonic time a worktree's seed need was last checked. _checked_at: dict[str, float] = {} +#: Worktree root -> (monotonic end of its wait window, set when done) for each +#: post-seed first refresh still running. The refresh removes its own entry. +_first_refreshes: dict[str, tuple[float, threading.Event]] = {} #: Serialises ensure_seeded's check-and-seed within the process. Two threads #: opening one unseeded worktree would otherwise both seed it: the loser's #: non-blocking flock on the worktree's own index fails and surfaces as @@ -680,23 +693,51 @@ def seeded_main_root(root: Path) -> Path | None: return main if facts is not None and facts.seeded_from is not None else None -def _first_refresh(engine: Any) -> None: +def _first_refresh(engine: Any, key: str, done: threading.Event) -> None: try: with engine._autosync_lock: engine._maybe_autosync_reindex_locked(known_change="seeded") except Exception: logger.exception("worktree_seed: first refresh after the seed failed") + finally: + with _lock: + pending = _first_refreshes.get(key) + if pending is not None and pending[1] is done: + del _first_refreshes[key] + done.set() -def start_first_refresh(engine: Any) -> None: - """Bring a just-seeded engine's index to the worktree's files now, off the request path. +def start_first_refresh(engine: Any, root: Path | str) -> None: + """Bring a just-seeded engine's index to the worktree *root*'s files now, off the request path. - Queries answer from the seed meanwhile, marked refreshing. Without this the - worktree's own edits would wait for autosync's next full-tree poll. + Code tool calls on *root* wait for it through :func:`await_first_refresh`. + Without it the worktree's own edits would wait for autosync's next full-tree + poll. """ if not getattr(engine, "_autosync_enabled", False): return - threading.Thread(target=_first_refresh, args=(engine,), name="lemoncrow-worktree-seed-refresh", daemon=True).start() + key = str(root) + done = threading.Event() + with _lock: + _first_refreshes[key] = (time.monotonic() + FIRST_REFRESH_WAIT_S, done) + threading.Thread( + target=_first_refresh, args=(engine, key, done), name="lemoncrow-worktree-seed-refresh", daemon=True + ).start() + + +def await_first_refresh(root: Path | str) -> bool | None: + """Wait for *root*'s post-seed first refresh until :data:`FIRST_REFRESH_WAIT_S` after it started. + + None when no first refresh is running for *root*, True when it finished in + time, False when it is still running: the caller then answers from the seed + and says the index is refreshing. Holds no lock while it waits. + """ + with _lock: + pending = _first_refreshes.get(str(root)) + if pending is None: + return None + deadline, done = pending + return done.wait(max(0.0, deadline - time.monotonic())) def worktree_engine_idle_s() -> float: diff --git a/tests/gateway/test_session_root_routing.py b/tests/gateway/test_session_root_routing.py index c48241b6d..776b9a2b5 100644 --- a/tests/gateway/test_session_root_routing.py +++ b/tests/gateway/test_session_root_routing.py @@ -53,6 +53,7 @@ def _isolated(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.setattr(mcp_server, "_code_engine_cache", cache) monkeypatch.setattr(mcp_server, "_scoped_context_cache", {}) yield + cache.clear() cache.stop_sweeper() @@ -195,6 +196,39 @@ def test_a_query_repeated_in_another_checkout_is_answered_and_blanked_only_in_th assert repeated_in_a.startswith("no exact match") and "shared_target" not in repeated_in_a, repeated_in_a +def _fresh_worktree(main: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A worktree the daemon has never opened, with pkg/only_c.py (zeta_only_c) that only it has. + + Engines opened from here have autosync on (tests/gateway/conftest.py forces it + off), so opening the worktree seeds it and runs the real post-seed first refresh. + """ + forced_off = CodeContextEngine.__init__ + + def with_autosync(self: CodeContextEngine, *args: Any, **kwargs: Any) -> None: + forced_off(self, *args, **kwargs) + self._autosync_enabled = True + + monkeypatch.setattr(CodeContextEngine, "__init__", with_autosync) + worktree = (main.parent / "wt_c").resolve() + _git(main, "worktree", "add", "-q", "-b", "c", str(worktree)) + (worktree / "pkg" / "only_c.py").write_text("def zeta_only_c():\n return 'c'\n", encoding="utf-8") + return worktree + + +def test_the_first_search_in_a_fresh_worktree_waits_for_its_first_refresh( + repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """AC-1.3: the first answer comes from the worktree's own files, not the main checkout's seed.""" + wt_c = _fresh_worktree(repos[0], monkeypatch) + session_id = _session() + _record_cwd(session_id, wt_c) + + first = _search(session_id, "zeta_only_c") + + assert "pkg/only_c.py" in first and f"repo_root: {wt_c}" in first.splitlines()[0], first + assert mcp_server._INDEX_REFRESHING_NOTE not in first, first + + def _other_repos_worktree(tmp_path: Path) -> Path: other = tmp_path / "other" other.mkdir() From 8474c560c978cd516d8888a0f9619aaa6ea0f0f2 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 15:03:45 -0500 Subject: [PATCH 7/9] ISS-10812: keep answers read while the index refreshes out of near-repeat suppression --- src/lemoncrow/gateway/adapters/mcp_server.py | 26 +++++++++--- src/lemoncrow/infra/code_intel/freshness.py | 6 +++ tests/gateway/test_session_root_routing.py | 42 ++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/lemoncrow/gateway/adapters/mcp_server.py b/src/lemoncrow/gateway/adapters/mcp_server.py index 5a7bac826..0b9403140 100644 --- a/src/lemoncrow/gateway/adapters/mcp_server.py +++ b/src/lemoncrow/gateway/adapters/mcp_server.py @@ -180,6 +180,7 @@ IndexRebuilding, VersionedEngineCache, note_refreshing, + refreshing_noted, reset_readiness_probes, take_refreshing, ) @@ -5917,15 +5918,17 @@ def _recent_code_search_queries(root: str) -> deque[tuple[float, str, frozenset[ return bucket -def _check_repeat_query(query: str, root: str) -> bool: +def _check_repeat_query(query: str, root: str, *, record: bool = True) -> bool: """True when *query* overlaps heavily (Jaccard >= floor) with a query this session ran against checkout *root* within the last _RECENT_QUERY_WINDOW_SECONDS -- re-phrasing the same investigation rarely surfaces anything new. A hard signal, not a hint: an explanatory sentence here just adds tokens to a response the caller has already shown it skims past and searches again anyway (measured - across debt-benchmark reps). The caller returns blank results instead -- - always records *query* into the bucket either way. Time-bounded (not just + across debt-benchmark reps). The caller returns blank results instead. + *query* goes into the bucket either way, except a new one when *record* is + False: the caller records that with _record_code_search_query once it is + answered. Time-bounded (not just the last _RECENT_QUERY_HISTORY calls) so a brand-new, unrelated conversation on the same long-lived daemon never gets blanked because of a coincidental word overlap with a stale query from a previous chat. @@ -5944,10 +5947,22 @@ def _check_repeat_query(query: str, root: str) -> bool: if len(words & prior_words) / len(union) >= _REPEAT_QUERY_SIMILARITY_FLOOR: is_repeat = True break - bucket.append((now, query, words)) + if record or is_repeat: + bucket.append((now, query, words)) return is_repeat +def _record_code_search_query(query: str, root: str) -> None: + """Count *query*'s answer from checkout *root* toward _check_repeat_query. + + Skipped for an answer read while the index was refreshing: it may predate the + files it was asked about, so a retry is a fair question, not a repeat. + """ + if refreshing_noted(): + return + _recent_code_search_queries(root).append((time.monotonic(), query, _query_words(query))) + + # Per-line digest: crc32 + byte length, 8 bytes a line. Both halves must match # for a line to count as unchanged, so a crc collision alone can never pass a # changed line off as identical. @@ -11162,7 +11177,7 @@ def tool_code_search( # engine entirely and returns blank -- no explanatory text (measured: the # caller skims past prose hints and searches again anyway; blank results # cost nothing and say the same thing). - if _check_repeat_query(query, str(workspace_root)): + if _check_repeat_query(query, str(workspace_root), record=False): return _attach_code_search_savings({"exact_match": False, "files": []}, workspace_root) # Normalise: paths param accepts list, comma-sep string, or single path # (the legacy `path` kwarg is folded into `paths` by param_aliases). @@ -11222,6 +11237,7 @@ def tool_code_search( # the agent reads only what it picks. Content-only shaping -- the ranked # file/candidate surface (and retrieval MRR) is untouched either way. shaped = _outline_lean_view(lean, keep_top2=include_source or bool(lean.get("exact_match"))) + _record_code_search_query(query, str(workspace_root)) return _attach_code_search_savings(shaped, workspace_root) diff --git a/src/lemoncrow/infra/code_intel/freshness.py b/src/lemoncrow/infra/code_intel/freshness.py index 1339ed79b..87319a237 100644 --- a/src/lemoncrow/infra/code_intel/freshness.py +++ b/src/lemoncrow/infra/code_intel/freshness.py @@ -71,6 +71,7 @@ "VersionedEngineCache", "index_state", "note_refreshing", + "refreshing_noted", "require_ready", "reset_readiness_probes", "take_refreshing", @@ -178,6 +179,11 @@ def take_refreshing() -> bool: return seen +def refreshing_noted() -> bool: + """What :func:`take_refreshing` would return now, without clearing the mark.""" + return bool(getattr(_refreshing_seen, "value", False)) + + def note_refreshing() -> None: """Flag this thread's call as answered from an index that is still being refreshed.""" _refreshing_seen.value = True diff --git a/tests/gateway/test_session_root_routing.py b/tests/gateway/test_session_root_routing.py index 776b9a2b5..382ca10dc 100644 --- a/tests/gateway/test_session_root_routing.py +++ b/tests/gateway/test_session_root_routing.py @@ -229,6 +229,48 @@ def test_the_first_search_in_a_fresh_worktree_waits_for_its_first_refresh( assert mcp_server._INDEX_REFRESHING_NOTE not in first, first +def test_a_first_refresh_slower_than_the_wait_is_announced_and_a_retry_is_answered( + repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + release = threading.Event() + real_refresh = worktree_seed._first_refresh + + def slow_refresh(engine: Any, *args: Any) -> None: + with engine._autosync_lock: # keeps autosync's own poll off the index meanwhile + release.wait(timeout=60) + real_refresh(engine, *args) + + monkeypatch.setattr(worktree_seed, "_first_refresh", slow_refresh) + monkeypatch.setattr(worktree_seed, "FIRST_REFRESH_WAIT_S", 0.2) + wt_c = _fresh_worktree(repos[0], monkeypatch) + session_id = _session() + _record_cwd(session_id, wt_c) + + first = _search(session_id, "zeta_only_c") + release.set() + for thread in threading.enumerate(): + if thread.name == "lemoncrow-worktree-seed-refresh": + thread.join(timeout=60) + retry = _search(session_id, "where is zeta_only_c defined") + + assert mcp_server._INDEX_REFRESHING_NOTE in first, first + assert "pkg/only_c.py" in retry, retry + + +def test_a_reworded_repeat_of_an_answer_from_the_refreshed_index_is_still_blanked( + repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + wt_c = _fresh_worktree(repos[0], monkeypatch) + session_id = _session() + _record_cwd(session_id, wt_c) + + first = _search(session_id, "zeta_only_c") + reworded = _search(session_id, "where is zeta_only_c defined") + + assert mcp_server._INDEX_REFRESHING_NOTE not in first, first + assert reworded.startswith("no exact match") and "only_c" not in reworded, reworded + + def _other_repos_worktree(tmp_path: Path) -> Path: other = tmp_path / "other" other.mkdir() From 37c1cefde4db26c6b10501accc580adae95fa491 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 15:04:26 -0500 Subject: [PATCH 8/9] ISS-10812: resolve bash's cwd-less run dir once for the command and its dump check --- src/lemoncrow/gateway/adapters/mcp/bash.py | 35 ++++++++++++---------- tests/gateway/test_session_root_routing.py | 19 ++++++++++++ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/lemoncrow/gateway/adapters/mcp/bash.py b/src/lemoncrow/gateway/adapters/mcp/bash.py index 82c24f127..dff8d73b6 100644 --- a/src/lemoncrow/gateway/adapters/mcp/bash.py +++ b/src/lemoncrow/gateway/adapters/mcp/bash.py @@ -443,16 +443,8 @@ def _render_grep_stdout(payload: dict[str, Any]) -> str: return json.dumps(matches, ensure_ascii=False) return json.dumps(payload, ensure_ascii=False) - workspace = os.environ.get("CLAUDE_WORKSPACE_ROOT", os.getcwd()) - # A misconfigured CLAUDE_WORKSPACE_ROOT -- e.g. a host path that leaked into - # a container via the environment and does not exist here -- would make every - # cwd-less command fail with a raw FileNotFoundError from Popen, surfaced as - # an opaque "MCP error -32000". Fall back to the process cwd (always a real - # directory) so the command still runs instead of hard-failing. - if not Path(workspace).is_dir(): - workspace = os.getcwd() - _session_worktree = None if cwd else session_root.session_worktree(Path(workspace)) - effective_cwd = cwd or (str(_session_worktree) if _session_worktree is not None else workspace) + workspace = _bash_workspace() + effective_cwd = cwd or _cwd_less_run_dir(workspace) if action in {"poll", "kill", "status", "update", "send"}: if not session_id: @@ -1066,10 +1058,23 @@ def _render_bash_text(result: dict[str, Any]) -> str: return "" -def _session_worktree_cwd() -> str | None: - """The session's linked worktree, where a command with no ``cwd`` runs (see _run_bash_tool), else None.""" - worktree = session_root.session_worktree(Path(os.environ.get("CLAUDE_WORKSPACE_ROOT") or os.getcwd())) - return None if worktree is None else str(worktree) +def _bash_workspace() -> str: + """The workspace root ``bash`` works under: CLAUDE_WORKSPACE_ROOT, else the process cwd. + + A misconfigured CLAUDE_WORKSPACE_ROOT -- e.g. a host path that leaked into a + container via the environment and does not exist here -- would make every + cwd-less command fail with a raw FileNotFoundError from Popen, surfaced as an + opaque "MCP error -32000". It falls back to the process cwd (always a real + directory) so the command still runs instead of hard-failing. + """ + workspace = os.environ.get("CLAUDE_WORKSPACE_ROOT", os.getcwd()) + return workspace if Path(workspace).is_dir() else os.getcwd() + + +def _cwd_less_run_dir(workspace: str) -> str: + """Where a command with no ``cwd`` runs: the session's linked worktree, else *workspace*.""" + worktree = session_root.session_worktree(Path(workspace)) + return workspace if worktree is None else str(worktree) def _lift_bash_command_list(args: dict[str, Any], known_params: frozenset[str]) -> dict[str, Any]: @@ -1263,7 +1268,7 @@ def tool_bash( # bash(id=x) with no explicit action = wait for the run to finish. action = "poll" if action == "run" and command and not bg and not interactive: - _dump_notice = _check_redundant_file_dump(command, cwd or _session_worktree_cwd()) + _dump_notice = _check_redundant_file_dump(command, cwd or _cwd_less_run_dir(_bash_workspace())) if _dump_notice: return _dump_notice result = _run_bash_tool( diff --git a/tests/gateway/test_session_root_routing.py b/tests/gateway/test_session_root_routing.py index 382ca10dc..b057c12ab 100644 --- a/tests/gateway/test_session_root_routing.py +++ b/tests/gateway/test_session_root_routing.py @@ -361,6 +361,25 @@ def test_a_file_read_in_full_in_the_main_checkout_does_not_block_its_worktree_co assert reread.startswith("[lc: already read in full"), reread +def test_a_missing_workspace_root_sends_the_dump_check_where_the_command_runs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLAUDE_WORKSPACE_ROOT that is not a directory falls back to the process cwd for both.""" + here = (tmp_path / "here").resolve() + here.mkdir() + (here / "notes.txt").write_text("already seen\n", encoding="utf-8") + (tmp_path / "a-file").write_text("", encoding="utf-8") + # Under a file, so nothing the daemon does can create it. + monkeypatch.setenv("CLAUDE_WORKSPACE_ROOT", str(tmp_path / "a-file" / "gone")) + session_id = _session() + _text(session_id, "read", {"files": [f"{here / 'notes.txt'}:full"]}) + monkeypatch.chdir(here) + + dumped = _text(session_id, "bash", {"command": "head notes.txt"}) + + assert dumped.startswith("[lc: already read in full"), dumped + + def test_the_resolved_against_note_appears_only_for_a_relative_path(repos: tuple[Path, Path, Path]) -> None: """AC-2.5: an absolute path owes nothing to the inferred worktree, so it says nothing about it.""" _main, wt_a, wt_b = repos From 3dfa09537ca931741ef9f2ba2b1b39a077e91743 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Mon, 21 Sep 2026 15:32:13 -0500 Subject: [PATCH 9/9] ISS-10812: opt the real-autosync routing tests out of the gateway conftest instead of layering over its patch --- pyproject.toml | 1 + tests/gateway/conftest.py | 6 +++++- tests/gateway/test_session_root_routing.py | 17 ++++++++--------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4d18bbdf1..7253f6fb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -164,6 +164,7 @@ pythonpath = ["src", "."] markers = [ "slow: marks tests as slow", "ab: real A/B benchmark for MCP tool savings", + "real_code_autosync: keep CodeContextEngine autosync on (opts out of tests/gateway/conftest.py's forced-off patch)", ] [tool.mypy] diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index 79b29099e..1f9c9e331 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -8,7 +8,7 @@ @pytest.fixture(autouse=True) -def _disable_code_autosync() -> None: +def _disable_code_autosync(request: pytest.FixtureRequest) -> None: """Disable autosync for gateway tests to make them deterministic. MCP read tools never block on a cold index: a read returns immediately and @@ -16,7 +16,11 @@ def _disable_code_autosync() -> None: the assertions and makes MCP-surface tests non-deterministic. With autosync disabled there is no worker, so tests that need a populated index build it explicitly (via index_repo) and observe a fully built index deterministically. + A test marked ``real_code_autosync`` opts out and gets the engine unpatched. """ + if request.node.get_closest_marker("real_code_autosync"): + yield + return from lemoncrow.pro.capabilities.code_context import CodeContextEngine original_init = CodeContextEngine.__init__ diff --git a/tests/gateway/test_session_root_routing.py b/tests/gateway/test_session_root_routing.py index b057c12ab..11d6d6244 100644 --- a/tests/gateway/test_session_root_routing.py +++ b/tests/gateway/test_session_root_routing.py @@ -199,22 +199,19 @@ def test_a_query_repeated_in_another_checkout_is_answered_and_blanked_only_in_th def _fresh_worktree(main: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """A worktree the daemon has never opened, with pkg/only_c.py (zeta_only_c) that only it has. - Engines opened from here have autosync on (tests/gateway/conftest.py forces it - off), so opening the worktree seeds it and runs the real post-seed first refresh. + Callers are marked ``real_code_autosync`` (no conftest forced-off patch), and + this turns LEMONCROW_CODE_AUTOSYNC back on, so engines opened from here without + an explicit ``autosync_enabled`` have autosync on: opening the worktree seeds it + and runs the real post-seed first refresh. """ - forced_off = CodeContextEngine.__init__ - - def with_autosync(self: CodeContextEngine, *args: Any, **kwargs: Any) -> None: - forced_off(self, *args, **kwargs) - self._autosync_enabled = True - - monkeypatch.setattr(CodeContextEngine, "__init__", with_autosync) + monkeypatch.setenv("LEMONCROW_CODE_AUTOSYNC", "1") worktree = (main.parent / "wt_c").resolve() _git(main, "worktree", "add", "-q", "-b", "c", str(worktree)) (worktree / "pkg" / "only_c.py").write_text("def zeta_only_c():\n return 'c'\n", encoding="utf-8") return worktree +@pytest.mark.real_code_autosync def test_the_first_search_in_a_fresh_worktree_waits_for_its_first_refresh( repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -229,6 +226,7 @@ def test_the_first_search_in_a_fresh_worktree_waits_for_its_first_refresh( assert mcp_server._INDEX_REFRESHING_NOTE not in first, first +@pytest.mark.real_code_autosync def test_a_first_refresh_slower_than_the_wait_is_announced_and_a_retry_is_answered( repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -257,6 +255,7 @@ def slow_refresh(engine: Any, *args: Any) -> None: assert "pkg/only_c.py" in retry, retry +@pytest.mark.real_code_autosync def test_a_reworded_repeat_of_an_answer_from_the_refreshed_index_is_still_blanked( repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch ) -> None: