Sfoglia il codice sorgente

feat(mcp): per-file staleness banner + tunable watcher debounce (#403) (#428)

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>
Colby Mchenry 4 settimane fa
parent
commit
b48170e69f

+ 1 - 1
.cursor/rules/codegraph.mdc

@@ -31,7 +31,7 @@ Use codegraph for **structural** questions — what calls what, what would break
 - **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call.
 - **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call.
 - **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call.
 - **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call.
 - **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more.
 - **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more.
-- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn.
+- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them. `codegraph_status` also lists pending files under "Pending sync".
 
 
 ### If `.codegraph/` doesn't exist
 ### If `.codegraph/` doesn't exist
 
 

+ 22 - 0
CHANGELOG.md

@@ -34,6 +34,28 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   already attached to the old daemon keep using it while new sessions run
   already attached to the old daemon keep using it while new sessions run
   standalone until it idles out — they never mix versions over the socket.
   standalone until it idles out — they never mix versions over the socket.
 
 
+- **Per-file staleness banner — codegraph responses now tell the agent which
+  files are pending re-index (#403).** When the file watcher has seen edits
+  since the last successful sync, MCP tool responses (`codegraph_search`,
+  `context`, `callers`, `callees`, `impact`, `trace`, `explore`, `node`,
+  `files`) prepend a `⚠️` banner naming the stale files referenced in that
+  response, with their edit age and indexing state; pending files elsewhere in
+  the project appear as a small footer. The agent is told which specific files
+  to Read directly; the rest of the response is fresh and codegraph stays
+  authoritative for it. No artificial wait, no static "wait ~500ms" guess —
+  the cost is zero when nothing's pending. `codegraph_status` also surfaces a
+  `### Pending sync:` section so an agent can ask "is the index caught up?" in
+  one call.
+- **`CODEGRAPH_WATCH_DEBOUNCE_MS` env var lets you tune the file-watcher quiet
+  window (#403).** Default stays at 2000ms; workspaces with bursty writes
+  (formatter-on-save chains, multi-file refactors, large generated outputs)
+  can raise it (e.g. `5000` or `10000`) without touching their agent's command
+  line. Clamped to `[100ms, 60s]`; out-of-range or non-numeric values fall
+  back to the default and the active value is logged to stderr on watcher
+  startup so it's discoverable. Pairs with the staleness banner above: the
+  banner stays accurate at any debounce value because it's per-file, not a
+  static "wait N ms" instruction.
+
 ### Fixed
 ### Fixed
 - **Git worktrees no longer silently borrow another tree's index (#155).**
 - **Git worktrees no longer silently borrow another tree's index (#155).**
   When a worktree is nested inside the main checkout — exactly what agent
   When a worktree is nested inside the main checkout — exactly what agent

+ 47 - 0
__tests__/mcp-debounce-env.test.ts

@@ -0,0 +1,47 @@
+/**
+ * CODEGRAPH_WATCH_DEBOUNCE_MS env override (issue #403).
+ *
+ * Lets users tune the watcher quiet window from MCP-launched configs without
+ * editing the agent's command line — formatter-on-save chains and large
+ * generated outputs benefit from a longer window. Clamped to [100ms, 60s];
+ * out-of-range / non-numeric values fall back to the FileWatcher default
+ * (2000ms) rather than throwing or silently capping a likely typo.
+ */
+import { describe, it, expect } from 'vitest';
+import { parseDebounceEnv } from '../src/mcp/engine';
+
+describe('parseDebounceEnv', () => {
+  it('returns undefined for unset / empty values', () => {
+    expect(parseDebounceEnv(undefined)).toBeUndefined();
+    expect(parseDebounceEnv('')).toBeUndefined();
+    expect(parseDebounceEnv('   ')).toBeUndefined();
+  });
+
+  it('accepts integer values inside [100, 60000]', () => {
+    expect(parseDebounceEnv('100')).toBe(100);
+    expect(parseDebounceEnv('2000')).toBe(2000);
+    expect(parseDebounceEnv('5000')).toBe(5000);
+    expect(parseDebounceEnv('60000')).toBe(60000);
+  });
+
+  it('rejects out-of-range values (returns undefined, lets default win)', () => {
+    expect(parseDebounceEnv('0')).toBeUndefined();
+    expect(parseDebounceEnv('50')).toBeUndefined();   // below 100
+    expect(parseDebounceEnv('99')).toBeUndefined();
+    expect(parseDebounceEnv('60001')).toBeUndefined(); // above 60s
+    expect(parseDebounceEnv('-500')).toBeUndefined();
+  });
+
+  it('rejects non-integer / non-numeric values', () => {
+    expect(parseDebounceEnv('abc')).toBeUndefined();
+    expect(parseDebounceEnv('500.5')).toBeUndefined();
+    expect(parseDebounceEnv('NaN')).toBeUndefined();
+    expect(parseDebounceEnv('Infinity')).toBeUndefined();
+  });
+
+  it('accepts scientific notation that resolves to an in-range integer', () => {
+    // Number('1e3') === 1000, Number.isInteger(1000) === true. Power users
+    // who write debounce as 1e3 should not be surprised; the clamp still applies.
+    expect(parseDebounceEnv('1e3')).toBe(1000);
+  });
+});

+ 153 - 0
__tests__/mcp-staleness-banner.test.ts

@@ -0,0 +1,153 @@
+/**
+ * Per-file staleness banner on MCP tool responses (issue #403).
+ *
+ * The watcher tracks every file event since the last successful sync; the
+ * tool dispatcher intersects "files referenced in this response" with that
+ * pending set and prepends a banner ("⚠️ Some files referenced below were
+ * edited since the last index sync…") plus an optional footer ("(Note: N
+ * file(s) elsewhere in this project are pending index sync…)").
+ *
+ * No auto-flush, no static wait — the response is instant and the agent
+ * decides whether to Read the specific stale file. These tests exercise
+ * the full real path: real watcher + real CodeGraph index + real
+ * ToolHandler.execute().
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+
+function waitFor(condition: () => boolean, timeoutMs = 5000, intervalMs = 50): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const start = Date.now();
+    const tick = () => {
+      if (condition()) return resolve();
+      if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out'));
+      setTimeout(tick, intervalMs);
+    };
+    tick();
+  });
+}
+
+describe('MCP staleness banner', () => {
+  let testDir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  beforeEach(async () => {
+    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-banner-'));
+    fs.mkdirSync(path.join(testDir, 'src'));
+    // Three isolated files with no cross-references — keeps each test's
+    // "which path does the response mention?" assertion unambiguous. If the
+    // files shared imports/calls, codegraph_search responses would surface
+    // multiple file paths and the banner-vs-footer split would be racy.
+    fs.writeFileSync(
+      path.join(testDir, 'src', 'alpha-only.ts'),
+      'export function alphaOnly() { return 1; }\n',
+    );
+    fs.writeFileSync(
+      path.join(testDir, 'src', 'bravo-only.ts'),
+      'export function bravoOnly() { return 2; }\n',
+    );
+    fs.writeFileSync(
+      path.join(testDir, 'src', 'charlie-only.ts'),
+      'export function charlieOnly() { return 3; }\n',
+    );
+
+    cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
+    await cg.indexAll();
+    handler = new ToolHandler(cg);
+  });
+
+  afterEach(() => {
+    try { cg.unwatch(); } catch { /* ignore */ }
+    try { cg.close(); } catch { /* ignore */ }
+    if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+  });
+
+  it('prepends a stale banner when the response references a pending file', async () => {
+    // Long debounce so the edit lingers in pendingFiles while we query.
+    cg.watch({ debounceMs: 4000 });
+    await cg.waitUntilWatcherReady();
+
+    fs.writeFileSync(
+      path.join(testDir, 'src', 'alpha-only.ts'),
+      'export function alphaOnly() { return 99; }\n',
+    );
+    await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/alpha-only.ts'), 8000);
+
+    const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
+    expect(res.isError).toBeFalsy();
+    const text = res.content[0].text;
+
+    // Banner shape: warning glyph + filename + actionable instruction.
+    expect(text.startsWith('⚠️')).toBe(true);
+    expect(text).toContain('src/alpha-only.ts');
+    expect(text).toMatch(/edited \d+ms ago/);
+    expect(text).toMatch(/Read them directly/);
+    // The actual result must still follow the banner.
+    expect(text).toMatch(/alphaOnly/);
+  });
+
+  it('uses the footer (not the banner) when pending files are not referenced', async () => {
+    cg.watch({ debounceMs: 4000 });
+    await cg.waitUntilWatcherReady();
+
+    // Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is
+    // only in alpha-only.ts. The two files share no imports/calls so the
+    // response text won't mention bravo-only.ts.
+    fs.writeFileSync(
+      path.join(testDir, 'src', 'bravo-only.ts'),
+      'export function bravoOnly() { return 22; }\n',
+    );
+    await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts'), 8000);
+
+    const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
+    const text = res.content[0].text;
+
+    expect(text.startsWith('⚠️')).toBe(false);
+    expect(text).toMatch(/elsewhere in this project are pending index sync/);
+    expect(text).toContain('src/bravo-only.ts');
+  });
+
+  it('drops the banner once the sync completes and clears the pending entry', async () => {
+    cg.watch({ debounceMs: 200 });
+    await cg.waitUntilWatcherReady();
+
+    fs.writeFileSync(
+      path.join(testDir, 'src', 'alpha-only.ts'),
+      'export function alphaOnly() { return 7; }\n',
+    );
+    await waitFor(() => cg.getPendingFiles().length === 0, 5000);
+
+    const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
+    const text = res.content[0].text;
+    expect(text.startsWith('⚠️')).toBe(false);
+    expect(text).not.toMatch(/elsewhere in this project are pending index sync/);
+  });
+
+  it('lists pending files under "Pending sync" in codegraph_status', async () => {
+    cg.watch({ debounceMs: 4000 });
+    await cg.waitUntilWatcherReady();
+
+    fs.writeFileSync(
+      path.join(testDir, 'src', 'charlie-only.ts'),
+      'export function charlieOnly() { return 33; }\n',
+    );
+    await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts'), 8000);
+
+    const res = await handler.execute('codegraph_status', {});
+    const text = res.content[0].text;
+    expect(text).toContain('### Pending sync:');
+    expect(text).toContain('src/charlie-only.ts');
+    // Status embeds the info first-class, so the auto-banner is suppressed.
+    expect(text.startsWith('⚠️')).toBe(false);
+  });
+
+  it('returns zero pending files when no watcher is active', () => {
+    expect(cg.getPendingFiles()).toEqual([]);
+  });
+});

+ 88 - 0
__tests__/watcher.test.ts

@@ -198,6 +198,94 @@ describe('FileWatcher', () => {
     });
     });
   });
   });
 
 
+  describe('pending file tracking (#403)', () => {
+    it('should expose edited paths via getPendingFiles before sync fires', async () => {
+      // Slow debounce — events arrive but sync hasn't run yet.
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 });
+      const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 2000 });
+      watcher.start();
+      // Deterministic boundary: wait for chokidar's initial scan to complete
+      // so any late initial-scan events have fired before we assert. A bare
+      // sleep is flaky under test-parallelism load.
+      await watcher.waitUntilReady();
+
+      expect(watcher.getPendingFiles()).toEqual([]);
+
+      fs.writeFileSync(path.join(testDir, 'src', 'pending.ts'), 'export const p = 1;');
+
+      // Allow chokidar to emit, but DON'T let the 2s debounce fire.
+      await waitFor(() => watcher.getPendingFiles().length > 0, 3000);
+
+      const pending = watcher.getPendingFiles();
+      const paths = pending.map((p) => p.path);
+      expect(paths).toContain('src/pending.ts');
+      const entry = pending.find((p) => p.path === 'src/pending.ts')!;
+      expect(entry.firstSeenMs).toBeGreaterThan(0);
+      expect(entry.lastSeenMs).toBeGreaterThanOrEqual(entry.firstSeenMs);
+      // No sync running yet → indexing flag is false.
+      expect(entry.indexing).toBe(false);
+
+      watcher.stop();
+    });
+
+    it('should clear an entry only after a successful sync absorbing that edit', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 });
+      const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 });
+      watcher.start();
+      await watcher.waitUntilReady();
+
+      fs.writeFileSync(path.join(testDir, 'src', 'fresh.ts'), 'export const f = 1;');
+
+      // Watcher saw the change → pendingFiles has the entry. Longer windows
+      // here because chokidar event delivery on macOS slows under heavy
+      // parallel test-suite load (4× slower than isolation).
+      await waitFor(() => watcher.getPendingFiles().some((p) => p.path === 'src/fresh.ts'), 8000);
+
+      // Wait through debounce + sync; the entry should drop out.
+      await waitFor(() => syncFn.mock.calls.length > 0, 8000);
+      await waitFor(() => !watcher.getPendingFiles().some((p) => p.path === 'src/fresh.ts'), 8000);
+
+      expect(watcher.getPendingFiles()).toEqual([]);
+      watcher.stop();
+    });
+
+    it('should keep entries unchanged when sync fails (rescheduled work sees the same set)', async () => {
+      // First post-settle sync rejects, second resolves. The initial-scan
+      // sync (triggered by chokidar's pre-existing add events) is allowed to
+      // resolve cleanly so it doesn't consume one of our scripted outcomes.
+      const syncFn = vi
+        .fn()
+        .mockResolvedValueOnce({ filesChanged: 0, durationMs: 1 }) // initial scan
+        .mockRejectedValueOnce(new Error('boom'))                  // first real edit fails
+        .mockResolvedValueOnce({ filesChanged: 1, durationMs: 10 }); // retry succeeds
+      const onSyncError = vi.fn();
+      const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200, onSyncError });
+      watcher.start();
+      // Wait through chokidar `ready` AND the initial-scan-triggered sync, so
+      // the next sync corresponds to the explicit edit below.
+      await watcher.waitUntilReady();
+      await waitFor(() => syncFn.mock.calls.length >= 1, 5000);
+      await new Promise((r) => setTimeout(r, 100));
+
+      fs.writeFileSync(path.join(testDir, 'src', 'will-fail.ts'), 'export const wf = 1;');
+
+      // Wait for the sync that handles the explicit edit to reject.
+      await waitFor(() => onSyncError.mock.calls.length > 0, 5000);
+
+      // The file is STILL in pendingFiles — failure didn't drop it.
+      const after = watcher.getPendingFiles();
+      expect(after.some((p) => p.path === 'src/will-fail.ts')).toBe(true);
+
+      // Retry resolves; entry clears.
+      await waitFor(
+        () => !watcher.getPendingFiles().some((p) => p.path === 'src/will-fail.ts'),
+        5000,
+      );
+
+      watcher.stop();
+    });
+  });
+
   describe('callbacks', () => {
   describe('callbacks', () => {
     it('should call onSyncComplete after successful sync', async () => {
     it('should call onSyncComplete after successful sync', async () => {
       const syncFn = vi.fn().mockResolvedValue({ filesChanged: 2, durationMs: 50 });
       const syncFn = vi.fn().mockResolvedValue({ filesChanged: 2, durationMs: 50 });

+ 27 - 2
src/index.ts

@@ -46,7 +46,7 @@ import {
 import { GraphTraverser, GraphQueryManager } from './graph';
 import { GraphTraverser, GraphQueryManager } from './graph';
 import { ContextBuilder, createContextBuilder } from './context';
 import { ContextBuilder, createContextBuilder } from './context';
 import { Mutex, FileLock } from './utils';
 import { Mutex, FileLock } from './utils';
-import { FileWatcher, WatchOptions } from './sync';
+import { FileWatcher, WatchOptions, PendingFile } from './sync';
 
 
 // Re-export types for consumers
 // Re-export types for consumers
 export * from './types';
 export * from './types';
@@ -75,7 +75,7 @@ export {
   defaultLogger,
   defaultLogger,
 } from './errors';
 } from './errors';
 export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
 export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
-export { FileWatcher, WatchOptions } from './sync';
+export { FileWatcher, WatchOptions, PendingFile } from './sync';
 export { MCPServer } from './mcp';
 export { MCPServer } from './mcp';
 
 
 /**
 /**
@@ -499,6 +499,31 @@ export class CodeGraph {
     return this.watcher?.isActive() ?? false;
     return this.watcher?.isActive() ?? false;
   }
   }
 
 
+  /**
+   * Files seen by the file watcher since the last successful sync —
+   * the per-file "stale" signal MCP tools attach to responses so an agent
+   * can fall back to {@link Read} for just the affected file without
+   * waiting for a debounced sync to complete (issue #403).
+   *
+   * Returns an empty list when the watcher isn't active, or no events have
+   * arrived. Each entry includes `firstSeenMs` and `lastSeenMs` (wall-clock
+   * `Date.now()` values) so callers can render "edited Nms ago", plus an
+   * `indexing` flag indicating whether the in-flight sync (if any) will
+   * absorb that file.
+   */
+  getPendingFiles(): PendingFile[] {
+    return this.watcher?.getPendingFiles() ?? [];
+  }
+
+  /**
+   * Resolves once the file watcher has finished its initial chokidar scan.
+   * Useful for tests that need a deterministic boundary before asserting on
+   * `getPendingFiles()`. Resolves immediately when no watcher is active.
+   */
+  waitUntilWatcherReady(timeoutMs?: number): Promise<void> {
+    return this.watcher ? this.watcher.waitUntilReady(timeoutMs) : Promise.resolve();
+  }
+
   /**
   /**
    * Get files that have changed since last index
    * Get files that have changed since last index
    */
    */

+ 1 - 1
src/installer/instructions-template.ts

@@ -49,7 +49,7 @@ Use codegraph for **structural** questions — what calls what, what would break
 - **Don't grep first** when looking up a symbol by name. \`codegraph_search\` is faster and returns kind + location + signature in one call.
 - **Don't grep first** when looking up a symbol by name. \`codegraph_search\` is faster and returns kind + location + signature in one call.
 - **Don't chain \`codegraph_search\` + \`codegraph_node\`** when you just want context — \`codegraph_context\` is one call.
 - **Don't chain \`codegraph_search\` + \`codegraph_node\`** when you just want context — \`codegraph_context\` is one call.
 - **Don't loop \`codegraph_node\` over many symbols** — one \`codegraph_explore\` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more.
 - **Don't loop \`codegraph_node\` over many symbols** — one \`codegraph_explore\` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more.
-- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn.
+- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them. \`codegraph_status\` also lists pending files under "Pending sync".
 
 
 ### If \`.codegraph/\` doesn't exist
 ### If \`.codegraph/\` doesn't exist
 
 

+ 32 - 0
src/mcp/engine.ts

@@ -185,7 +185,18 @@ export class MCPEngine {
       return;
       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({
     const started = this.cg.watch({
+      debounceMs,
       onSyncComplete: (result) => {
       onSyncComplete: (result) => {
         if (result.filesChanged > 0) {
         if (result.filesChanged > 0) {
           process.stderr.write(
           process.stderr.write(
@@ -230,3 +241,24 @@ export class MCPEngine {
       });
       });
   }
   }
 }
 }
+
+/**
+ * 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;
+}

+ 1 - 1
src/mcp/server-instructions.ts

@@ -59,7 +59,7 @@ of calls; a grep/read exploration is dozens.
 - **Don't grep first** when looking up a symbol by name — \`codegraph_search\` is faster and returns kind + location + signature.
 - **Don't grep first** when looking up a symbol by name — \`codegraph_search\` is faster and returns kind + location + signature.
 - **Don't chain \`codegraph_search\` + \`codegraph_node\`** when you just want context — \`codegraph_context\` is one round-trip.
 - **Don't chain \`codegraph_search\` + \`codegraph_node\`** when you just want context — \`codegraph_context\` is one round-trip.
 - **Don't loop \`codegraph_node\` over many symbols** — one \`codegraph_explore\` call returns them all grouped by file, while each separate call re-reads the whole context and costs far more. Use \`codegraph_node\` for a single symbol.
 - **Don't loop \`codegraph_node\` over many symbols** — one \`codegraph_explore\` call returns them all grouped by file, while each separate call re-reads the whole context and costs far more. Use \`codegraph_node\` for a single symbol.
-- **Don't query the index immediately after editing a file** — the watcher needs ~500ms to debounce + sync. Wait for the next turn.
+- **After editing, check the staleness banner.** When a tool response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Every file NOT in that banner is fresh, so still trust codegraph. \`codegraph_status\` also lists pending files under "Pending sync".
 
 
 ## Limitations
 ## Limitations
 
 

+ 160 - 6
src/mcp/tools.ts

@@ -11,6 +11,7 @@ import {
   worktreeMismatchNotice,
   worktreeMismatchNotice,
   type WorktreeIndexMismatch,
   type WorktreeIndexMismatch,
 } from '../sync/worktree';
 } from '../sync/worktree';
+import type { PendingFile } from '../sync';
 import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
 import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
 import { createHash } from 'crypto';
 import { createHash } from 'crypto';
 import {
 import {
@@ -24,7 +25,7 @@ import {
 } from 'fs';
 } from 'fs';
 import { clamp, validatePathWithinRoot, validateProjectPath } from '../utils';
 import { clamp, validatePathWithinRoot, validateProjectPath } from '../utils';
 import { tmpdir } from 'os';
 import { tmpdir } from 'os';
-import { join } from 'path';
+import { join, resolve as resolvePath } from 'path';
 
 
 /** Maximum output length to prevent context bloat (characters) */
 /** Maximum output length to prevent context bloat (characters) */
 const MAX_OUTPUT_LENGTH = 15000;
 const MAX_OUTPUT_LENGTH = 15000;
@@ -264,6 +265,48 @@ function markSessionConsulted(sessionId: string): void {
   }
   }
 }
 }
 
 
+/**
+ * Per-file staleness banner emitted at the top of a tool response when the
+ * file watcher has pending events for files referenced by the response.
+ * The agent uses this to fall back to Read for those specific files
+ * without waiting for the debounced sync (issue #403).
+ */
+export function formatStaleBanner(stale: PendingFile[]): string {
+  const now = Date.now();
+  const lines = stale.map((p) => {
+    const ageMs = Math.max(0, now - p.lastSeenMs);
+    const label = p.indexing ? 'indexing in progress' : 'pending sync';
+    return `  - ${p.path} (edited ${ageMs}ms ago, ${label})`;
+  });
+  return (
+    '⚠️ Some files referenced below were edited since the last index sync — ' +
+    'their codegraph entries may be stale:\n' +
+    lines.join('\n') +
+    '\nFor accurate content of those specific files, Read them directly. ' +
+    'The rest of this response is fresh.'
+  );
+}
+
+/**
+ * Compact footer listing pending files that are NOT referenced in this
+ * response. Gives the agent a complete project-wide freshness picture
+ * without bloating the main banner.
+ */
+export function formatStaleFooter(stale: PendingFile[]): string {
+  const MAX = 5;
+  const now = Date.now();
+  const shown = stale.slice(0, MAX);
+  const lines = shown.map((p) => {
+    const ageMs = Math.max(0, now - p.lastSeenMs);
+    return `  - ${p.path} (edited ${ageMs}ms ago)`;
+  });
+  const more = stale.length > MAX ? `\n  - …and ${stale.length - MAX} more` : '';
+  return (
+    `(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
+    `sync but were not referenced above:\n${lines.join('\n')}${more})`
+  );
+}
+
 /**
 /**
  * MCP Tool definition
  * MCP Tool definition
  */
  */
@@ -802,6 +845,84 @@ export class ToolHandler {
     return result;
     return result;
   }
   }
 
 
+  /**
+   * Annotate a successful read-tool result with per-file staleness — the
+   * non-blocking answer to issue #403. The file watcher tracks every event
+   * it sees per path; here we intersect "files referenced in this response"
+   * against that pending set and prepend a compact banner so the agent can
+   * fall back to Read for those *specific* files without waiting for the
+   * debounced sync to fire. Other pending files in the project (not
+   * referenced by this response) get a small footer so the agent has a
+   * complete picture without bloating the banner.
+   *
+   * Cost when nothing is pending — the common case — is one boolean check.
+   * No I/O, no parsing of markdown beyond a per-pending-file substring scan.
+   */
+  private withStalenessNotice(result: ToolResult, projectPath?: string): ToolResult {
+    if (result.isError) return result;
+
+    let cg: CodeGraph;
+    try {
+      cg = this.getCodeGraph(projectPath);
+    } catch {
+      return result; // no default project — leave as is
+    }
+
+    // Cross-project `projectPath` calls open a cached CodeGraph WITHOUT a
+    // watcher (watchers are only attached to the default session project).
+    // When the cross-project path happens to be the same project as the
+    // default cg, the cached instance is the wrong one — its pendingFiles is
+    // permanently empty. Detect the equal-path case and prefer the default
+    // cg so the staleness signal still fires when an agent passes the
+    // explicit projectPath form of its own project.
+    if (this.cg && cg !== this.cg) {
+      try {
+        const sameProject =
+          resolvePath(this.cg.getProjectRoot()) === resolvePath(cg.getProjectRoot());
+        if (sameProject) cg = this.cg;
+      } catch {
+        /* getProjectRoot may throw on a closed instance — leave cg as is */
+      }
+    }
+
+    // Defensive: some test fakes inject a partial CodeGraph stub without the
+    // newer pending-files API. Treat missing/throwing as "no pending files."
+    let pending: PendingFile[] = [];
+    try {
+      pending = cg.getPendingFiles?.() ?? [];
+    } catch {
+      return result;
+    }
+    if (pending.length === 0) return result;
+
+    const [first, ...rest] = result.content;
+    if (!first || first.type !== 'text') return result;
+
+    const text = first.text;
+    const inResponse: PendingFile[] = [];
+    const elsewhere: PendingFile[] = [];
+    for (const p of pending) {
+      // Substring match against the project-relative POSIX path — that's
+      // exactly the format both the watcher and every codegraph response
+      // emit, so a plain includes() is sufficient and avoids regex pitfalls.
+      if (text.includes(p.path)) inResponse.push(p);
+      else elsewhere.push(p);
+    }
+
+    let banner = '';
+    if (inResponse.length > 0) {
+      banner = formatStaleBanner(inResponse);
+    }
+    let footer = '';
+    if (elsewhere.length > 0) {
+      footer = formatStaleFooter(elsewhere);
+    }
+    if (!banner && !footer) return result;
+
+    const composed = [banner, text, footer].filter(Boolean).join('\n\n');
+    return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
+  }
+
   /**
   /**
    * Execute a tool by name
    * Execute a tool by name
    */
    */
@@ -831,9 +952,12 @@ export class ToolHandler {
         if (typeof check === 'object' && check !== undefined) return check;
         if (typeof check === 'object' && check !== undefined) return check;
       }
       }
 
 
-      // Read tools resolve through a single result variable so the worktree
-      // mismatch notice can be prefixed in one place (issue #155). status is
-      // returned directly — it embeds its own verbose warning.
+      // Read tools resolve through a single result variable so cross-cutting
+      // notices — worktree-index mismatch (issue #155) and per-file
+      // staleness (issue #403) — can be applied in one place. status embeds
+      // its own verbose worktree warning but still flows through the
+      // staleness wrapper so its pending-files section stays consistent
+      // with what the read tools surface.
       let result: ToolResult;
       let result: ToolResult;
       switch (toolName) {
       switch (toolName) {
         case 'codegraph_search':
         case 'codegraph_search':
@@ -851,6 +975,9 @@ export class ToolHandler {
         case 'codegraph_node':
         case 'codegraph_node':
           result = await this.handleNode(args); break;
           result = await this.handleNode(args); break;
         case 'codegraph_status':
         case 'codegraph_status':
+          // status embeds the pending-files list as a first-class section
+          // (see handleStatus), so we skip the auto-banner wrapper here to
+          // avoid duplicating the same info at the top of the response.
           return await this.handleStatus(args);
           return await this.handleStatus(args);
         case 'codegraph_files':
         case 'codegraph_files':
           result = await this.handleFiles(args); break;
           result = await this.handleFiles(args); break;
@@ -859,7 +986,8 @@ export class ToolHandler {
         default:
         default:
           return this.errorResult(`Unknown tool: ${toolName}`);
           return this.errorResult(`Unknown tool: ${toolName}`);
       }
       }
-      return this.withWorktreeNotice(result, args.projectPath as string | undefined);
+      const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined);
+      return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined);
     } catch (err) {
     } catch (err) {
       return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
       return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
     }
     }
@@ -2016,7 +2144,18 @@ export class ToolHandler {
    * Handle codegraph_status
    * Handle codegraph_status
    */
    */
   private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
   private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
-    const cg = this.getCodeGraph(args.projectPath as string | undefined);
+    let cg = this.getCodeGraph(args.projectPath as string | undefined);
+    // Same trick as withStalenessNotice — when an explicit projectPath
+    // resolves to the same project as the default session cg, prefer the
+    // default so getPendingFiles() (only populated by the default's watcher)
+    // is non-empty when there are pending edits.
+    if (this.cg && cg !== this.cg) {
+      try {
+        if (resolvePath(this.cg.getProjectRoot()) === resolvePath(cg.getProjectRoot())) {
+          cg = this.cg;
+        }
+      } catch { /* closed instance — leave as is */ }
+    }
     const stats = cg.getStats();
     const stats = cg.getStats();
 
 
     // Warn when this index actually belongs to a different git working tree
     // Warn when this index actually belongs to a different git working tree
@@ -2073,6 +2212,21 @@ export class ToolHandler {
       }
       }
     }
     }
 
 
+    // Per-file freshness — the inverse of the auto-prepended staleness banner
+    // (issue #403). Surfacing it inside `status` gives the agent a single
+    // place to ask "is the index caught up?" rather than inferring from
+    // banners on other tool calls.
+    const pending = cg.getPendingFiles();
+    if (pending.length > 0) {
+      lines.push('', '### Pending sync:');
+      const now = Date.now();
+      for (const p of pending) {
+        const ageMs = Math.max(0, now - p.lastSeenMs);
+        const label = p.indexing ? 'indexing in progress' : 'pending sync';
+        lines.push(`- ${p.path} (edited ${ageMs}ms ago, ${label})`);
+      }
+    }
+
     return this.textResult(lines.join('\n'));
     return this.textResult(lines.join('\n'));
   }
   }
 
 

+ 1 - 1
src/sync/index.ts

@@ -13,7 +13,7 @@
  * - Incremental reindexing (in extraction module)
  * - Incremental reindexing (in extraction module)
  */
  */
 
 
-export { FileWatcher, WatchOptions } from './watcher';
+export { FileWatcher, WatchOptions, PendingFile } from './watcher';
 export { watchDisabledReason, detectWsl } from './watch-policy';
 export { watchDisabledReason, detectWsl } from './watch-policy';
 export {
 export {
   installGitSyncHook,
   installGitSyncHook,

+ 163 - 6
src/sync/watcher.ts

@@ -45,6 +45,26 @@ export interface WatchOptions {
   onSyncError?: (error: Error) => void;
   onSyncError?: (error: Error) => void;
 }
 }
 
 
+/**
+ * Per-file pending entry — tracks a source file the watcher saw an event for
+ * but hasn't yet synced into the index. Exposed via {@link FileWatcher.getPendingFiles}
+ * so MCP tool responses can mark stale results without forcing a wait.
+ */
+export interface PendingFile {
+  /** Project-relative POSIX path (e.g. "src/foo.ts"). */
+  path: string;
+  /** Wall-clock ms at the first event we saw for this path since the last sync. */
+  firstSeenMs: number;
+  /** Wall-clock ms at the most recent event we saw for this path. */
+  lastSeenMs: number;
+  /**
+   * True when a sync is currently in flight that began AFTER this file's most
+   * recent event — i.e. the next successful sync will pick it up. False when
+   * the file is still in the debounce window (no sync running yet).
+   */
+  indexing: boolean;
+}
+
 /**
 /**
  * FileWatcher monitors a project directory for changes and triggers
  * FileWatcher monitors a project directory for changes and triggers
  * debounced sync operations via a provided callback.
  * debounced sync operations via a provided callback.
@@ -55,13 +75,43 @@ export interface WatchOptions {
  * - Debounced to avoid thrashing on rapid saves
  * - Debounced to avoid thrashing on rapid saves
  * - Filters to supported source files by extension
  * - Filters to supported source files by extension
  * - Ignores .codegraph/ and .git/ regardless of .gitignore
  * - Ignores .codegraph/ and .git/ regardless of .gitignore
+ * - Tracks per-file pending state so MCP tools can flag stale results
+ *   without blocking on a sync (issue #403)
  */
  */
 export class FileWatcher {
 export class FileWatcher {
   private watcher: FSWatcher | null = null;
   private watcher: FSWatcher | null = null;
   private debounceTimer: ReturnType<typeof setTimeout> | null = null;
   private debounceTimer: ReturnType<typeof setTimeout> | null = null;
-  private hasChanges = false;
+  /**
+   * Files seen by the watcher since the last successful sync — populated on
+   * every chokidar event, cleared at the start of a sync, and re-populated by
+   * events that arrive mid-sync (or restored on sync failure). Keyed by the
+   * same project-relative POSIX path the rest of the codebase uses, so a
+   * caller can intersect tool-response file paths against this map cheaply.
+   */
+  private pendingFiles = new Map<string, { firstSeenMs: number; lastSeenMs: number }>();
+  /**
+   * Wall-clock ms at which the in-flight sync began. Combined with
+   * {@link pendingFiles}'s `lastSeenMs`, this distinguishes "still in the
+   * debounce window" (lastSeen > syncStarted, sync hasn't started yet for
+   * this edit) from "currently being indexed" (lastSeen <= syncStarted).
+   */
+  private syncStartedMs = 0;
   private syncing = false;
   private syncing = false;
   private stopped = false;
   private stopped = false;
+  /**
+   * False until chokidar fires its `ready` event. Gates `pendingFiles`
+   * insertion so the initial crawl's `add` events (one per pre-existing
+   * source file) don't pollute the per-file staleness signal. The events
+   * still flow into `scheduleSync()` to preserve the previous "initial
+   * scan triggers a reconciling sync" behavior.
+   */
+  private chokidarReady = false;
+  /**
+   * Callbacks that resolve when chokidar fires `ready`. Used by tests (and
+   * any production caller that cares about a clean baseline) to deterministically
+   * gate on the end of the initial scan instead of guessing at a sleep duration.
+   */
+  private readyWaiters: Array<() => void> = [];
   // The shared ignore matcher (built-in defaults + project .gitignore), built
   // The shared ignore matcher (built-in defaults + project .gitignore), built
   // once at start(). Same source of truth the indexer uses, so watcher scope
   // once at start(). Same source of truth the indexer uses, so watcher scope
   // can never diverge from index scope.
   // can never diverge from index scope.
@@ -116,6 +166,26 @@ export class FileWatcher {
         ignored: (testPath: string, stats?: Stats) => this.shouldIgnore(testPath, stats),
         ignored: (testPath: string, stats?: Stats) => this.shouldIgnore(testPath, stats),
       });
       });
 
 
+      // Chokidar emits `add` for every pre-existing source file during its
+      // initial scan. Those events should still trigger the post-startup
+      // reconciling sync (preserving prior behavior), but they must NOT land
+      // in pendingFiles — otherwise every file in the project shows up as
+      // "edited but not indexed" on startup, which is the opposite of the
+      // signal #403 is supposed to provide. Flip the flag on chokidar's
+      // `ready` event; from then on, real edits populate pendingFiles.
+      //
+      // We also clear `pendingFiles` here as defense-in-depth: chokidar can
+      // emit late initial-scan `add` events via setImmediate AFTER the
+      // `ready` callback runs (observed under test-parallelism load).
+      // Clearing once at ready guarantees a clean baseline; real subsequent
+      // edits repopulate the set normally.
+      this.watcher.on('ready', () => {
+        this.chokidarReady = true;
+        this.pendingFiles.clear();
+        for (const cb of this.readyWaiters) cb();
+        this.readyWaiters.length = 0;
+      });
+
       // chokidar emits 'all' for every event type; we only sync source files.
       // chokidar emits 'all' for every event type; we only sync source files.
       this.watcher.on('all', (_event: string, filePath: string) => {
       this.watcher.on('all', (_event: string, filePath: string) => {
         if (this.stopped) return;
         if (this.stopped) return;
@@ -128,7 +198,17 @@ export class FileWatcher {
         if (!isSourceFile(normalized)) return;
         if (!isSourceFile(normalized)) return;
 
 
         logDebug('File change detected', { file: normalized });
         logDebug('File change detected', { file: normalized });
-        this.hasChanges = true;
+        // Only track events from after chokidar's initial scan as pending
+        // edits — pre-existing files on disk are already represented by
+        // (or about to be reconciled by) the index, not a user edit.
+        if (this.chokidarReady) {
+          const now = Date.now();
+          const existing = this.pendingFiles.get(normalized);
+          this.pendingFiles.set(normalized, {
+            firstSeenMs: existing?.firstSeenMs ?? now,
+            lastSeenMs: now,
+          });
+        }
         this.scheduleSync();
         this.scheduleSync();
       });
       });
 
 
@@ -187,7 +267,8 @@ export class FileWatcher {
       this.watcher = null;
       this.watcher = null;
     }
     }
 
 
-    this.hasChanges = false;
+    this.pendingFiles.clear();
+    this.chokidarReady = false;
     this.ignoreMatcher = null;
     this.ignoreMatcher = null;
     logDebug('File watcher stopped');
     logDebug('File watcher stopped');
   }
   }
@@ -199,6 +280,30 @@ export class FileWatcher {
     return this.watcher !== null && !this.stopped;
     return this.watcher !== null && !this.stopped;
   }
   }
 
 
+  /**
+   * Resolves once chokidar has fired its `ready` event (or immediately if
+   * it has already done so). Useful for tests that need a deterministic
+   * boundary before asserting on `pendingFiles` — guessing a sleep duration
+   * is flaky under load because chokidar can take longer than expected to
+   * finish its initial crawl on slow filesystems / parallel test runs.
+   *
+   * Production callers don't need this: `pendingFiles` is read continuously,
+   * the staleness banner is always correct (empty or populated), and the
+   * initial-scan window is a small one-time startup cost.
+   */
+  waitUntilReady(timeoutMs = 10000): Promise<void> {
+    if (this.chokidarReady) return Promise.resolve();
+    return new Promise((resolve, reject) => {
+      const t = setTimeout(() => {
+        const idx = this.readyWaiters.indexOf(handler);
+        if (idx >= 0) this.readyWaiters.splice(idx, 1);
+        reject(new Error(`FileWatcher.waitUntilReady timed out after ${timeoutMs}ms`));
+      }, timeoutMs);
+      const handler = () => { clearTimeout(t); resolve(); };
+      this.readyWaiters.push(handler);
+    });
+  }
+
   /**
   /**
    * Schedule a debounced sync.
    * Schedule a debounced sync.
    */
    */
@@ -214,28 +319,80 @@ export class FileWatcher {
 
 
   /**
   /**
    * Flush pending changes by running sync.
    * Flush pending changes by running sync.
+   *
+   * pendingFiles is NOT cleared at the start of sync — entries are removed
+   * only after sync commits successfully, and only for entries whose
+   * lastSeenMs <= syncStartedMs. That way, a query that arrives mid-sync
+   * still sees the affected files marked stale (the DB hasn't been updated
+   * yet), and an event that lands mid-sync persists into the follow-up.
+   *
+   * On sync failure pendingFiles is left untouched — every edit is still
+   * unindexed, and the rescheduled sync will absorb the same set next time.
    */
    */
   private async flush(): Promise<void> {
   private async flush(): Promise<void> {
     // If already syncing, the post-sync check will re-trigger
     // If already syncing, the post-sync check will re-trigger
     if (this.syncing || this.stopped) return;
     if (this.syncing || this.stopped) return;
 
 
-    this.hasChanges = false;
+    this.syncStartedMs = Date.now();
     this.syncing = true;
     this.syncing = true;
 
 
     try {
     try {
       const result = await this.syncFn();
       const result = await this.syncFn();
+      // Remove entries whose most recent event predates this sync — those
+      // edits are now in the DB. Entries with lastSeenMs > syncStartedMs
+      // arrived mid-sync; whether the in-flight sync captured them depends
+      // on when sync read that file, so we keep them as pending and let
+      // the follow-up sync handle them. We prefer false positives ("shown
+      // stale, actually fresh" → at worst one extra Read) over false
+      // negatives ("shown fresh, actually stale" → misleads the agent).
+      for (const [filePath, info] of this.pendingFiles) {
+        if (info.lastSeenMs <= this.syncStartedMs) {
+          this.pendingFiles.delete(filePath);
+        }
+      }
       this.onSyncComplete?.(result);
       this.onSyncComplete?.(result);
     } catch (err) {
     } catch (err) {
       const error = err instanceof Error ? err : new Error(String(err));
       const error = err instanceof Error ? err : new Error(String(err));
       logWarn('Watch sync failed', { error: error.message });
       logWarn('Watch sync failed', { error: error.message });
+      // Failure: leave pendingFiles untouched. Every edit it tracks is
+      // still unindexed; the rescheduled sync sees the same set.
       this.onSyncError?.(error);
       this.onSyncError?.(error);
     } finally {
     } finally {
       this.syncing = false;
       this.syncing = false;
 
 
-      // If new changes arrived during sync, schedule another
-      if (this.hasChanges && !this.stopped) {
+      // If pending files remain (mid-sync events, or this sync failed),
+      // schedule another pass.
+      if (this.pendingFiles.size > 0 && !this.stopped) {
         this.scheduleSync();
         this.scheduleSync();
       }
       }
     }
     }
   }
   }
+
+  /**
+   * Snapshot of files seen by the watcher since the last successful sync.
+   *
+   * Used by MCP tool responses to mark stale results without blocking on a
+   * sync: a tool that returns a hit in `src/foo.ts` while `src/foo.ts` is in
+   * this list tells the agent "Read this file directly, the index lags."
+   *
+   * `indexing` is true when a sync is currently in flight whose start time is
+   * AFTER this file's most recent event — i.e. that sync will absorb the
+   * edit. False means the file is still inside the debounce window and no
+   * sync has started yet (a follow-up call a few hundred ms later may show
+   * `indexing: true` or the file may have left the list entirely).
+   *
+   * Cheap: O(pendingFiles.size), no I/O, no locks.
+   */
+  getPendingFiles(): PendingFile[] {
+    const result: PendingFile[] = [];
+    for (const [filePath, info] of this.pendingFiles) {
+      result.push({
+        path: filePath,
+        firstSeenMs: info.firstSeenMs,
+        lastSeenMs: info.lastSeenMs,
+        indexing: this.syncing && this.syncStartedMs >= info.lastSeenMs,
+      });
+    }
+    return result;
+  }
 }
 }