fix(mcp): re-resolve a path's index every call so worktree state isn't pinned (#926) (#939)

A long-lived MCP server (the shared daemon) cached both the project handle
(projectCache) and the worktree-mismatch verdict (worktreeMismatchCache) for
its whole lifetime — cleared only on shutdown — keyed off the input path and
never re-checked. So a worktree path first resolved BEFORE it had its own
.codegraph/ — when the walk-up reached the main checkout — stayed pinned to
the main checkout: every query kept hitting the wrong index and every result
carried a false "this index belongs to a different git working tree" warning,
until the server restarted. The CLI was correct (fresh process per run);
re-indexing didn't help.

- getCodeGraph: re-resolve findNearestCodeGraphRoot on every call (cheap stat
  walk, no git, no reopen) and cache the open DB by RESOLVED ROOT only. Drops
  the input-path short-circuit that pinned the first resolution, and the
  double-keying (which also double-closed each instance in closeAll()).
- worktreeMismatchFor: key the verdict on (startPath, indexRoot) so a changed
  index root recomputes instead of serving the stale "borrowed the parent's
  index" verdict.

Adds a regression test that fails pre-fix (the stale warning survives the
index-root flip); the existing "no further git spawn" caching test still
passes. The query-staleness half (a second project opened through the handler)
isn't unit-testable under vitest, so it was validated with a dist A/B.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-21 13:43:43 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b218f625f7
commit 62d5cdde2c
3 changed files with 112 additions and 26 deletions
+36 -26
View File
@@ -823,11 +823,6 @@ export class ToolHandler {
return this.cg;
}
// Check cache first (using original path as key)
if (this.projectCache.has(projectPath)) {
return this.projectCache.get(projectPath)!;
}
// Reject sensitive system directories before opening. Only validate a
// path that actually exists — a nested or not-yet-created sub-path of a
// real project must still be allowed to resolve UP to its .codegraph/
@@ -840,7 +835,16 @@ export class ToolHandler {
}
}
// Walk up parent directories to find nearest .codegraph/
// Always RE-RESOLVE the nearest .codegraph/ from the input path. The walk
// is cheap (a few existsSync up the tree) and is the only thing that
// notices a path whose index root CHANGED since it was first seen — most
// importantly a git worktree that gained its own .codegraph/ after the
// (long-lived) server first resolved it up to the parent checkout. We used
// to short-circuit on a `projectCache[projectPath]` entry before resolving,
// which pinned that first resolution for the server's whole lifetime, so a
// worktree kept being served the parent checkout's index until restart
// (#926). The DB connection itself is still cached (by resolved root,
// below), so re-resolving costs only the stat walk, never a reopen.
const resolvedRoot = findNearestCodeGraphRoot(projectPath);
if (!resolvedRoot) {
@@ -856,27 +860,20 @@ export class ToolHandler {
// default instance rather than opening a SECOND connection to the same DB.
// A duplicate connection serializes reads against the watcher's auto-sync
// writes; on the wasm backend (no WAL) that surfaces as intermittent
// "database is locked" on concurrent tool calls. See issue #238. Deliberately
// not cached under projectPath — the server owns and closes the default
// instance, so routing it through projectCache.closeAll() would double-close it.
// "database is locked" on concurrent tool calls. See issue #238. The
// default instance is owned/closed by the server, so it's never cached.
if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
return this.cg;
}
// Check if we already have this resolved root cached (different path, same project)
if (this.projectCache.has(resolvedRoot)) {
const cg = this.projectCache.get(resolvedRoot)!;
// Cache under original path too for faster future lookups
this.projectCache.set(projectPath, cg);
return cg;
}
// Cache the open DB connection by RESOLVED ROOT only — never by the input
// path. One key per instance means closeAll() closes each exactly once, and
// a changed resolution maps to a different entry instead of a stale hit.
const cached = this.projectCache.get(resolvedRoot);
if (cached) return cached;
// Open and cache under both paths
const cg = loadCodeGraph().openSync(resolvedRoot);
this.projectCache.set(resolvedRoot, cg);
if (projectPath !== resolvedRoot) {
this.projectCache.set(projectPath, cg);
}
return cg;
}
@@ -947,17 +944,30 @@ export class ToolHandler {
*/
private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null {
const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
const cached = this.worktreeMismatchCache.get(startPath);
if (cached !== undefined) return cached;
let mismatch: WorktreeIndexMismatch | null = null;
// The verdict depends on BOTH the start path AND the index root it resolves
// to, so the cache must be keyed on the pair. Resolve the index root first
// (cheap — getCodeGraph re-walks to the nearest .codegraph/, no git), then
// key on `(startPath, indexRoot)`. The moment that root changes — most
// importantly when a git worktree gains its own index and the walk-up stops
// there instead of at the parent checkout — the key changes and the verdict
// is recomputed, instead of serving the stale "borrowed the parent's index"
// warning for the server's whole lifetime. Keying on startPath alone pinned
// that first verdict until restart (#926).
let indexRoot: string;
try {
mismatch = detectWorktreeIndexMismatch(startPath, this.getCodeGraph(projectPath).getProjectRoot());
indexRoot = this.getCodeGraph(projectPath).getProjectRoot();
} catch {
// No resolvable project (or any other resolution error) → nothing to warn.
mismatch = null;
return null;
}
this.worktreeMismatchCache.set(startPath, mismatch);
const cacheKey = `${startPath}\u0000${indexRoot}`;
const cached = this.worktreeMismatchCache.get(cacheKey);
if (cached !== undefined) return cached;
const mismatch = detectWorktreeIndexMismatch(startPath, indexRoot);
this.worktreeMismatchCache.set(cacheKey, mismatch);
return mismatch;
}