mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-09-19 07:34:57 +08:00
b48170e69f
Two coupled changes addressing the issue's underlying ask — "how does the
agent know when the index lags" — without resorting to a static wait.
Per-file staleness banner
-------------------------
FileWatcher now tracks per-path `pendingFiles` (path, firstSeenMs,
lastSeenMs, indexing) — events since the last successful sync, cleared
only after a sync whose `syncStartedMs >= lastSeenMs` commits. Chokidar
initial-scan events are gated behind a `ready` flag (with `waitUntilReady()`
exposed so tests can deterministically wait through it) so a fresh startup
doesn't falsely flag every existing file as pending.
ToolHandler now wraps every code-returning response (search, context,
callers, callees, impact, trace, explore, node, files) with
`withStalenessNotice`: intersects "files referenced in the response" with
`getPendingFiles()` and emits a hybrid signal —
* banner at the top for files referenced AND pending (with edit age +
indexing/pending-sync state, telling the agent to Read those specific
files directly; the rest of the response stays fresh and codegraph
stays authoritative for it),
* compact footer for pending files elsewhere in the project not
referenced above (capped at 5).
Cost is one boolean check + N substring matches when pending; zero
allocation when idle. `codegraph_status` surfaces the same data as a
first-class `### Pending sync:` section so the agent can ask "is the index
caught up?" in one call.
Cross-project quirk: when an agent passes `projectPath` matching the
default session's project, the staleness wrapper switches from the cached
cross-project CodeGraph (no watcher) to the default one (with watcher) so
the signal still fires. Same fix applied to `handleStatus`.
CODEGRAPH_WATCH_DEBOUNCE_MS
---------------------------
MCP `serve --mcp` now reads `CODEGRAPH_WATCH_DEBOUNCE_MS` and forwards it
to `cg.watch({ debounceMs })`. Clamped to [100ms, 60s]; out-of-range or
non-numeric values fall back to the FileWatcher default (2000ms). Active
value is logged to stderr on watcher startup so it's discoverable. The
docs in `server-instructions.ts`, `installer/instructions-template.ts`,
and `.cursor/rules/codegraph.mdc` no longer claim "~500ms"; they now
describe the banner mechanism instead — since per-file staleness replaces
the "wait N ms" guidance entirely, the docs become accurate at any
debounce value.
Validation
----------
* 847 unit/integration tests pass (added 15 new ones — pending-file
tracking, banner/footer routing, status section, env-var parsing).
* Direct MCP probe through a real `codegraph serve --mcp` process: edit a
file, query within the debounce window, banner fires naming the
edited file with edit-age.
* Real Claude TUI session via `scripts/agent-eval/itrun.sh` with
`CODEGRAPH_WATCH_DEBOUNCE_MS=10000`: agent edits `math.ts`, calls
`codegraph_explore`, reads the banner, **and discloses it unprompted in
its final reply**: "note: symbol index is mid-sync for the new `divide`,
but the source it returned is verbatim from disk."
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
9.7 KiB
TypeScript
265 lines
9.7 KiB
TypeScript
/**
|
|
* MCP shared engine — the heavyweight, *shared* state for an MCP server:
|
|
* the project's {@link CodeGraph} instance, file watcher, and the
|
|
* {@link ToolHandler} cache for cross-project queries.
|
|
*
|
|
* One engine, many sessions:
|
|
* - direct mode (single stdio session) instantiates one engine + one session;
|
|
* - daemon mode instantiates one engine and a new session per socket
|
|
* connection. Every session reads from the same SQLite WAL and the same
|
|
* inotify watch set — that's the entire point of issue #411.
|
|
*/
|
|
|
|
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
|
|
import { watchDisabledReason } from '../sync';
|
|
import { ToolHandler } from './tools';
|
|
|
|
export interface MCPEngineOptions {
|
|
/**
|
|
* Whether to start the file watcher when initializing. Daemon and direct
|
|
* modes both want this true; tests may set it false to keep the engine
|
|
* cheap. Honors {@link watchDisabledReason} regardless.
|
|
*/
|
|
watch?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Shared MCP engine. Thread-safe in the sense that multiple sessions can
|
|
* call its methods concurrently — internally it serializes initialization
|
|
* through a single promise so multiple sessions racing each other on first
|
|
* connect never double-open the SQLite file.
|
|
*/
|
|
export class MCPEngine {
|
|
private cg: CodeGraph | null = null;
|
|
private toolHandler: ToolHandler;
|
|
// Project root we resolved to. Null until `ensureInitialized` succeeds
|
|
// (or null forever if no .codegraph/ ever turned up — that's a valid
|
|
// state for the engine, since cross-project queries still work).
|
|
private projectPath: string | null = null;
|
|
// Set on first `ensureInitialized` so subsequent sessions don't redo work.
|
|
private initPromise: Promise<void> | null = null;
|
|
private watcherStarted = false;
|
|
private opts: Required<MCPEngineOptions>;
|
|
private closed = false;
|
|
|
|
constructor(opts: MCPEngineOptions = {}) {
|
|
this.opts = { watch: opts.watch ?? true };
|
|
this.toolHandler = new ToolHandler(null);
|
|
}
|
|
|
|
/**
|
|
* Convenience for {@link MCPServer} compatibility: pre-seed an explicit
|
|
* project path (from the `--path` CLI flag) without yet opening it. This
|
|
* keeps the synchronous constructor cheap; the actual open happens on the
|
|
* first `ensureInitialized` call.
|
|
*/
|
|
setProjectPathHint(projectPath: string): void {
|
|
this.projectPath = projectPath;
|
|
this.toolHandler.setDefaultProjectHint(projectPath);
|
|
}
|
|
|
|
/** Project root that the engine resolved on first init (null if none). */
|
|
getProjectPath(): string | null {
|
|
return this.projectPath;
|
|
}
|
|
|
|
/** Shared ToolHandler — sessions delegate tool dispatch through this. */
|
|
getToolHandler(): ToolHandler {
|
|
return this.toolHandler;
|
|
}
|
|
|
|
/** Whether the default project's CodeGraph is open. */
|
|
hasDefaultCodeGraph(): boolean {
|
|
return this.toolHandler.hasDefaultCodeGraph();
|
|
}
|
|
|
|
/**
|
|
* Walk up from `searchFrom` to find the nearest `.codegraph/` and open it.
|
|
* Idempotent: concurrent callers share one in-flight init; subsequent
|
|
* callers after success are no-ops.
|
|
*
|
|
* The original `MCPServer.tryInitializeDefault` carried the same retry-on-
|
|
* subsequent-tool-call semantics; we preserve them by NOT throwing when the
|
|
* search misses (just leaves `cg` null so the next call can retry).
|
|
*/
|
|
async ensureInitialized(searchFrom: string): Promise<void> {
|
|
if (this.closed) return;
|
|
if (this.toolHandler.hasDefaultCodeGraph()) return;
|
|
if (this.initPromise) {
|
|
try { await this.initPromise; } catch { /* let caller retry */ }
|
|
return;
|
|
}
|
|
|
|
this.initPromise = this.doInitialize(searchFrom).finally(() => {
|
|
this.initPromise = null;
|
|
});
|
|
try {
|
|
await this.initPromise;
|
|
} catch {
|
|
// Init errors are logged inside `doInitialize`; falling through here
|
|
// matches MCPServer's previous "retry on next tool call" behavior.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronous last-resort init used by the per-session retry loop when the
|
|
* background `ensureInitialized` already finished (or failed) and we need
|
|
* to pick up a project that appeared *after* the engine started.
|
|
*/
|
|
retryInitializeSync(searchFrom: string): void {
|
|
if (this.closed) return;
|
|
if (this.toolHandler.hasDefaultCodeGraph()) return;
|
|
this.toolHandler.setDefaultProjectHint(searchFrom);
|
|
const resolvedRoot = findNearestCodeGraphRoot(searchFrom);
|
|
if (!resolvedRoot) return;
|
|
try {
|
|
// Close any previously failed instance to avoid leaking resources.
|
|
if (this.cg) {
|
|
try { this.cg.close(); } catch { /* ignore */ }
|
|
this.cg = null;
|
|
}
|
|
this.cg = CodeGraph.openSync(resolvedRoot);
|
|
this.projectPath = resolvedRoot;
|
|
this.toolHandler.setDefaultCodeGraph(this.cg);
|
|
this.startWatching();
|
|
this.catchUpSync();
|
|
} catch {
|
|
// Still failing — caller will try again on the next tool call.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close everything. Used on graceful daemon shutdown (SIGTERM/idle timeout)
|
|
* and on direct-mode stop. Idempotent.
|
|
*/
|
|
stop(): void {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
this.toolHandler.closeAll();
|
|
if (this.cg) {
|
|
try { this.cg.close(); } catch { /* ignore */ }
|
|
this.cg = null;
|
|
}
|
|
}
|
|
|
|
private async doInitialize(searchFrom: string): Promise<void> {
|
|
this.toolHandler.setDefaultProjectHint(searchFrom);
|
|
|
|
const resolvedRoot = findNearestCodeGraphRoot(searchFrom);
|
|
if (!resolvedRoot) {
|
|
// No .codegraph/ above searchFrom — that's not an error, sessions may
|
|
// still discover one later via roots/list.
|
|
this.projectPath = searchFrom;
|
|
return;
|
|
}
|
|
|
|
this.projectPath = resolvedRoot;
|
|
try {
|
|
this.cg = await CodeGraph.open(resolvedRoot);
|
|
this.toolHandler.setDefaultCodeGraph(this.cg);
|
|
this.startWatching();
|
|
this.catchUpSync();
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
process.stderr.write(`[CodeGraph MCP] Failed to open project at ${resolvedRoot}: ${msg}\n`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start file watching on the active CodeGraph instance. Idempotent — the
|
|
* watcher is per-engine, not per-session, which is why the daemon path
|
|
* collapses N inotify sets to one. The wording of the disabled-reason log
|
|
* exactly matches the prior in-tree implementation so log-driven dashboards
|
|
* keep working.
|
|
*/
|
|
private startWatching(): void {
|
|
if (!this.cg || this.watcherStarted || !this.opts.watch) return;
|
|
|
|
const disabledReason = watchDisabledReason(this.projectPath ?? process.cwd());
|
|
if (disabledReason) {
|
|
process.stderr.write(
|
|
`[CodeGraph MCP] File watcher disabled — ${disabledReason}. ` +
|
|
`The graph will not auto-update; run \`codegraph sync\` (or install the git sync hooks via \`codegraph init\`) to refresh.\n`
|
|
);
|
|
this.watcherStarted = true;
|
|
return;
|
|
}
|
|
|
|
// Optional override for the debounce window via env var (issue #403).
|
|
// Useful for workspaces with bursty writes (formatter-on-save chains,
|
|
// large generated outputs) where the 2s default fires too often. Clamped
|
|
// to [100ms, 60s]; out-of-range / non-numeric values fall back to the
|
|
// FileWatcher default. We log the active value so it's discoverable.
|
|
const debounceMs = parseDebounceEnv(process.env.CODEGRAPH_WATCH_DEBOUNCE_MS);
|
|
if (debounceMs !== undefined) {
|
|
process.stderr.write(`[CodeGraph MCP] File watcher debounce: ${debounceMs}ms (CODEGRAPH_WATCH_DEBOUNCE_MS)\n`);
|
|
}
|
|
|
|
const started = this.cg.watch({
|
|
debounceMs,
|
|
onSyncComplete: (result) => {
|
|
if (result.filesChanged > 0) {
|
|
process.stderr.write(
|
|
`[CodeGraph MCP] Auto-synced ${result.filesChanged} file(s) in ${result.durationMs}ms\n`
|
|
);
|
|
}
|
|
},
|
|
onSyncError: (err) => {
|
|
process.stderr.write(`[CodeGraph MCP] Auto-sync error: ${err.message}\n`);
|
|
},
|
|
});
|
|
|
|
this.watcherStarted = true;
|
|
if (started) {
|
|
process.stderr.write('[CodeGraph MCP] File watcher active — graph will auto-sync on changes\n');
|
|
} else {
|
|
process.stderr.write(
|
|
'[CodeGraph MCP] File watcher unavailable on this platform — run `codegraph sync` to refresh the graph after changes.\n'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconcile the index with the current filesystem once, right after open —
|
|
* catches edits, adds, deletes, and `git pull`/`checkout` changes made while
|
|
* no watcher was running. Background, never awaited.
|
|
*/
|
|
private catchUpSync(): void {
|
|
const cg = this.cg;
|
|
if (!cg) return;
|
|
void cg
|
|
.sync()
|
|
.then((result) => {
|
|
const changed = result.filesAdded + result.filesModified + result.filesRemoved;
|
|
if (changed > 0) {
|
|
process.stderr.write(`[CodeGraph MCP] Caught up ${changed} file(s) changed since last run\n`);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
process.stderr.write(`[CodeGraph MCP] Catch-up sync failed: ${msg}\n`);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse and clamp the CODEGRAPH_WATCH_DEBOUNCE_MS env override.
|
|
*
|
|
* Issue #403: workspaces with bursty writes (formatter-on-save, multi-file
|
|
* refactors) sometimes want a longer quiet window before sync. Returns
|
|
* `undefined` for unset / empty / non-numeric / out-of-range values so the
|
|
* FileWatcher default (2000ms) takes over — never throws.
|
|
*
|
|
* Clamp range: 100ms (faster would mean a sync per keystroke) to 60s (longer
|
|
* and the watcher feels broken). Out-of-range values are treated as "ignore
|
|
* this misconfiguration" rather than capped, since silently capping a 0 or
|
|
* a typoed value would mask a real config bug.
|
|
*/
|
|
export function parseDebounceEnv(raw: string | undefined): number | undefined {
|
|
if (!raw || !raw.trim()) return undefined;
|
|
const n = Number(raw);
|
|
if (!Number.isFinite(n) || !Number.isInteger(n)) return undefined;
|
|
if (n < 100 || n > 60000) return undefined;
|
|
return n;
|
|
}
|