P0: refresh MCP index data before queries#142
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens MCP query handlers by validating (and, when safe, refreshing) session/index freshness before serving index-backed responses, and adds file-signature metadata to SQLite artifacts so staleness can be detected and handled safely.
Changes:
- Add agent session freshness policies (
manual/check/auto) with changed-file/byte thresholds and bounded stale reporting. - Attach
freshnessmetadata to MCP handler responses and auto-refresh the warm session for small workspace edits. - Persist and validate SQLite artifact file-signature baselines, enabling safe refresh/rebuild rules for
query_sqlite(and refusing stale reads when refresh is unsafe).
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/sqlite.test.ts | Adds coverage ensuring SQLite artifact freshness metadata is cleared on unsigned full rewrites. |
| tests/mcp-server.test.ts | Adds/updates MCP tests for auto-refresh behavior and SQLite freshness enforcement. |
| tests/helpers/agent.ts | Updates test session wrapper to preserve new discovery/freshness behavior. |
| tests/agent-session.test.ts | Adds coverage for check freshness policy behavior and cache invalidation expectations. |
| src/sqlite/write.ts | Writes/clears SQLite artifact file-signature metadata during full exports. |
| src/sqlite/types.ts | Extends SQLite write options to accept file-signature inputs. |
| src/sqlite.ts | Re-exports the SQLite artifact signature metadata key for callers/tests. |
| src/mcp/server.ts | Wraps MCP tools with freshness checks, refresh logic, and SQLite artifact staleness protection. |
| src/index.ts | Exports new session freshness types from the main library surface. |
| src/agent/session.ts | Implements freshness policies and signature baselines for session snapshots. |
| src/agent/artifact.ts | Threads session file signatures into SQLite artifact builds. |
| src/agent.ts | Re-exports new freshness types from the agent entrypoint. |
| docs/mcp.md | Documents freshness metadata behavior and stale/refresh expectations for MCP tools. |
| docs/library-api.md | Documents new session freshness options and shows freshness usage in examples. |
| docs/cli.md | Updates CLI docs to describe MCP freshness behavior and stale recovery guidance. |
| codegraph-skill/codegraph/SKILL.md | Updates agent skill guidance to account for MCP freshness metadata. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function collectAgentFileSignatures(files: readonly string[]): Promise<Map<string, AgentFileSignature>> { | ||
| const signatures = new Map<string, AgentFileSignature>(); | ||
| await Promise.all( | ||
| files.map(async (file) => { | ||
| const resolvedFile = path.resolve(file); | ||
| try { | ||
| const stat = await fsp.stat(resolvedFile); | ||
| if (!stat.isFile()) return; | ||
| signatures.set(resolvedFile, { | ||
| file: resolvedFile, | ||
| size: stat.size, | ||
| mtimeMs: stat.mtimeMs, | ||
| }); | ||
| } catch (error) { | ||
| if (isMissingStatRace(error)) return; | ||
| throw new Error(`Unable to verify freshness for ${resolvedFile}`, { cause: error }); | ||
| } | ||
| }), | ||
| ); | ||
| return signatures; | ||
| } |
There was a problem hiding this comment.
Fixed in 8762795 — replaced the unbounded Promise.all with mapLimit at a bounded concurrency of 64 (FILE_SIGNATURE_STAT_CONCURRENCY) so large workspaces no longer risk EMFILE / I/O saturation while still parallelizing.
| const checkMcpFreshness = async (): Promise<AgentFreshnessResult> => { | ||
| if (session.checkFreshness) return await session.checkFreshness(); | ||
| return staleFreshness([], "freshness check unavailable"); | ||
| }; |
There was a problem hiding this comment.
Fixed in 8762795 — checkMcpFreshness now returns { state: 'fresh' } (manual-mode semantics) when a prebuilt session has no checkFreshness, instead of the misleading stale label. Added regression test 'reports fresh when a prebuilt MCP session has no freshness check'.
| const signatures = new Map<string, SqliteArtifactFileSignature>(); | ||
| await Promise.all( | ||
| currentFiles.map(async (file) => { | ||
| try { | ||
| const stat = await fs.stat(file); | ||
| if (!stat.isFile()) return; | ||
| signatures.set(relative(file), { path: file, size: stat.size, mtimeMs: stat.mtimeMs }); | ||
| } catch (error) { | ||
| if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { | ||
| return; | ||
| } | ||
| throw error; | ||
| } | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Fixed in 8762795 — bounded with mapLimit at SQLITE_ARTIFACT_STAT_CONCURRENCY (64), so freshness checks no longer fan out unbounded stat calls on large workspaces.
| async function collectSqliteArtifactFileSignatures(files: Iterable<string>): Promise<SqliteArtifactFileSignature[]> { | ||
| const signatures: SqliteArtifactFileSignature[] = []; | ||
| await Promise.all( | ||
| [...files].map(async (file) => { | ||
| const stat = await fs.stat(file); | ||
| if (!stat.isFile()) return; | ||
| signatures.push({ path: file, size: stat.size, mtimeMs: stat.mtimeMs }); | ||
| }), | ||
| ); | ||
| signatures.sort((left, right) => left.path.localeCompare(right.path)); | ||
| return signatures; | ||
| } |
There was a problem hiding this comment.
Fixed in 8762795 — removed the unused collectSqliteArtifactFileSignatures helper and the now-unnecessary node:fs/promises import from src/sqlite/write.ts.
| async function collectAgentFileSignatures(files: readonly string[]): Promise<Map<string, AgentFileSignature>> { | ||
| const signatures = new Map<string, AgentFileSignature>(); | ||
| await mapLimit([...files], FILE_SIGNATURE_STAT_CONCURRENCY, async (file) => { | ||
| const resolvedFile = path.resolve(file); | ||
| try { | ||
| const stat = await fsp.stat(resolvedFile); | ||
| if (!stat.isFile()) return; | ||
| signatures.set(resolvedFile, { | ||
| file: resolvedFile, | ||
| size: stat.size, | ||
| mtimeMs: stat.mtimeMs, | ||
| }); | ||
| } catch (error) { | ||
| if (isMissingStatRace(error)) return; | ||
| throw new Error(`Unable to verify freshness for ${resolvedFile}`, { cause: error }); | ||
| } | ||
| }); | ||
| return signatures; | ||
| } |
There was a problem hiding this comment.
Fixed in ecea3d1 — signature keys are now normalizePath(path.resolve(file)), so they use forward slashes and match snapshot.fileGraph.nodes on Windows. This prevents the 'freshness signature is missing' failure during artifact_build.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Files Reviewed (2 files)
Previous Review Summaries (2 snapshots, latest commit ecea3d1)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit ecea3d1)Status: No Issues Found | Recommendation: Merge Files Reviewed (4 files)
Notes
Previous review (commit 8762795)Status: No Issues Found | Recommendation: Merge Files Reviewed (16 files)
Notes
Reviewed by kimi-k2.6-20260420 · Input: 79.1K · Output: 11.8K · Cached: 985.3K |
… export metadata key
| let freshness = await checkMcpFreshness(); | ||
| freshness = await refreshSqliteArtifactForQuery(freshness); | ||
| let realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); | ||
| const artifactFreshness = await checkSqliteArtifactFreshness(realSqlitePath); | ||
| if (artifactFreshness.state !== "fresh") { | ||
| freshness = await refreshSqliteArtifactForQuery(artifactFreshness, { allowStaleRebuild: true }); | ||
| realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); | ||
| } |
There was a problem hiding this comment.
Fixed in ab35d9c — query_sqlite now decides serve/refuse from the SQLite artifact's own baseline (checkSqliteArtifactFreshness). Session freshness only gates automatic rebuilds: a stale session blocks an auto-rebuild, but a fresh artifact is served even when the in-memory session snapshot is stale/refreshed. The returned freshness now reflects the artifact. Added regression test 'serves a fresh SQLite artifact even when the session snapshot is stale' (verified with teeth: the old ordering threw in read-only).
| get_file: async (request) => | ||
| await withFreshness(async () => { | ||
| const resolvedFile = await resolveReadableFile(await realRoot, root, request.file); |
There was a problem hiding this comment.
Fixed in ab35d9c — get_file no longer runs through withFreshness. It reads live bytes directly from disk and never consults the indexed session, so it no longer re-discovers/re-stats the whole workspace (or triggers an index rebuild) on every call. It now returns freshness: { state: 'fresh' } since the returned bytes are always current. Updated the get_file test accordingly and re-pointed the missing-checkFreshness regression to search (still withFreshness-wrapped) so that fallback stays covered.
…ap get_file reads
Summary
Verification