Преглед на файлове

feat(mcp): surface degraded watcher state to the agent in tool responses (#892)

When live file watching permanently degrades (watch-resource exhaustion, or a
write lock held past the retry budget), getPendingFiles() goes empty — so the
existing per-file staleness banner can't fire even though the index is now
frozen and silently drifting stale. The agent kept getting clean-looking
responses off a no-longer-updating index.

Read-tool responses now lead with a whole-index banner ("CodeGraph auto-sync
is DISABLED…") whenever the watcher is degraded, and codegraph_status gets a
dedicated "Auto-sync disabled" section. Both carry the degrade reason and tell
the agent to Read files directly. Expose isWatcherDegraded() /
getWatcherDegradedReason() on the CodeGraph class, and document the new banner
in the MCP server instructions.

Completes the agent-notification half of #876 (the operator-facing onDegraded
wiring shipped in #891).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry преди 1 седмица
родител
ревизия
beca7116a0
променени са 5 файла, в които са добавени 115 реда и са изтрити 3 реда
  1. 1 1
      CHANGELOG.md
  2. 40 1
      __tests__/mcp-staleness-banner.test.ts
  3. 17 0
      src/index.ts
  4. 1 1
      src/mcp/server-instructions.ts
  5. 56 0
      src/mcp/tools.ts

+ 1 - 1
CHANGELOG.md

@@ -11,7 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
-- The file watcher that auto-syncs the graph now fails cleanly when live watching can no longer be trusted, instead of looking healthy while the index quietly goes stale. If the operating system runs out of file-watch resources, or another process holds the write lock far longer than a normal save, CodeGraph now disables auto-sync once — with a single clear message telling you to run `codegraph sync` (or rely on the git sync hooks) to refresh — rather than retrying forever or repeating the same error on a loop. This mostly matters for long-running MCP/daemon sessions, which could otherwise keep serving stale results while appearing to work. Thanks @thismilktea. (#876)
+- The file watcher that auto-syncs the graph now fails cleanly when live watching can no longer be trusted, instead of looking healthy while the index quietly goes stale. If the operating system runs out of file-watch resources, or another process holds the write lock far longer than a normal save, CodeGraph now disables auto-sync once — with a single clear message telling you to run `codegraph sync` (or rely on the git sync hooks) to refresh — rather than retrying forever or repeating the same error on a loop. And while auto-sync is disabled, CodeGraph's tool responses (and `codegraph status`) now say so plainly, so your AI agent knows to read files directly instead of trusting a frozen index. This mostly matters for long-running MCP/daemon sessions, which could otherwise keep serving stale results while appearing to work. Thanks @thismilktea. (#876)
 
 
 ## [1.0.1] - 2026-06-13

+ 40 - 1
__tests__/mcp-staleness-banner.test.ts

@@ -26,7 +26,7 @@ import * as path from 'path';
 import * as os from 'os';
 import CodeGraph from '../src/index';
 import { ToolHandler } from '../src/mcp/tools';
-import { __emitWatchEventForTests } from '../src/sync/watcher';
+import { __emitWatchEventForTests, __setFsWatchForTests } from '../src/sync/watcher';
 
 function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise<void> {
   return new Promise((resolve, reject) => {
@@ -71,11 +71,25 @@ describe('MCP staleness banner', () => {
   });
 
   afterEach(() => {
+    __setFsWatchForTests(null); // reset the injected fs.watch seam
     try { cg.unwatch(); } catch { /* ignore */ }
     try { cg.close(); } catch { /* ignore */ }
     if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
   });
 
+  // Force watch-resource exhaustion at startup so the real watcher degrades
+  // deterministically on any platform (recursive or per-directory strategy).
+  const degradeWatcher = () => {
+    __setFsWatchForTests(() => {
+      const err = new Error('too many open files') as NodeJS.ErrnoException;
+      err.code = 'EMFILE';
+      throw err;
+    });
+    const started = cg.watch({ debounceMs: 1000 }); // real (non-inert) watcher
+    expect(started).toBe(false);
+    expect(cg.isWatcherDegraded()).toBe(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, inertForTests: true });
@@ -170,4 +184,29 @@ describe('MCP staleness banner', () => {
   it('returns zero pending files when no watcher is active', () => {
     expect(cg.getPendingFiles()).toEqual([]);
   });
+
+  it('prepends a whole-index degraded banner once live watching has permanently stopped (#876)', async () => {
+    degradeWatcher();
+
+    const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
+    expect(res.isError).toBeFalsy();
+    const text = res.content[0].text;
+
+    expect(text.startsWith('⚠️')).toBe(true);
+    expect(text).toMatch(/auto-sync is DISABLED/i);
+    expect(text).toMatch(/Read files directly/i);
+    expect(text).toContain('OS watch/file limit exhausted'); // the degrade reason
+    expect(text).toMatch(/alphaOnly/); // the real result still follows the banner
+  });
+
+  it('surfaces the degraded state as its own section in codegraph_status (#876)', async () => {
+    degradeWatcher();
+
+    const res = await handler.execute('codegraph_status', {});
+    const text = res.content[0].text;
+    expect(text).toContain('### Auto-sync disabled:');
+    expect(text).toContain('OS watch/file limit exhausted');
+    // status renders the notice inline, so the auto-banner is not also prepended.
+    expect(text.startsWith('⚠️')).toBe(false);
+  });
 });

+ 17 - 0
src/index.ts

@@ -584,6 +584,23 @@ export class CodeGraph {
     return this.watcher?.isActive() ?? false;
   }
 
+  /**
+   * True once live watching has permanently degraded (OS watch-resource
+   * exhaustion, or a write lock held past the retry budget) and auto-sync is
+   * disabled until the next {@link watch} call. Distinct from `!isWatching()`:
+   * a stopped/never-started watcher is inactive but NOT degraded. MCP tools use
+   * this to surface a whole-index "results may be stale" notice, since
+   * `getPendingFiles()` goes empty once watching stops (#876).
+   */
+  isWatcherDegraded(): boolean {
+    return this.watcher?.isDegraded() ?? false;
+  }
+
+  /** The reason live watching degraded, or null if it is healthy (#876). */
+  getWatcherDegradedReason(): string | null {
+    return this.watcher?.getDegradedReason() ?? null;
+  }
+
   /**
    * Files seen by the file watcher since the last successful sync —
    * the per-file "stale" signal MCP tools attach to responses so an agent

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

@@ -66,7 +66,7 @@ typically one to a few calls; a grep/read exploration is dozens.
 - **Don't chain \`codegraph_search\` + \`codegraph_node\`** to understand an area — ONE \`codegraph_explore\` returns the relevant symbols' source together in a single 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 reach for the \`Read\` tool on an indexed source file** — \`codegraph_node\` with a \`file\` reads it for you (same \`<n>\\t<line>\` source, \`offset\`/\`limit\` like Read, faster, with its blast radius), and with a \`symbol\` it returns the source plus the caller/callee trail. Reach for raw \`Read\` only for what codegraph doesn't index (configs, docs) or when the staleness banner flags a file as pending re-index.
-- **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.
+- **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. A different, rarer banner — "⚠️ CodeGraph auto-sync is DISABLED…" — means live watching stopped entirely (the whole index is frozen, not just a few files); until it's resolved, Read files directly to confirm anything that may have changed.
 
 ## Limitations
 

+ 56 - 0
src/mcp/tools.ts

@@ -347,6 +347,23 @@ export function formatStaleFooter(stale: PendingFile[]): string {
   );
 }
 
+/**
+ * Whole-index degradation banner (issue #876). Emitted at the top of a read
+ * tool response when live watching has permanently stopped — at which point
+ * `getPendingFiles()` is empty, so the per-file banner above can't fire even
+ * though the index is now FROZEN and silently drifting stale. Leads with the
+ * agent-actionable instruction (Read directly) and carries the reason, which
+ * already names the operator remedy (`codegraph sync` / git hooks).
+ */
+export function formatDegradedBanner(reason: string | null): string {
+  return (
+    '⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the index is ' +
+    'frozen and any file edited since then is stale here. Read files directly to confirm ' +
+    'current content before relying on it.' +
+    (reason ? `\n  Reason: ${reason}` : '')
+  );
+}
+
 /**
  * MCP Tool definition
  */
@@ -1016,6 +1033,32 @@ export class ToolHandler {
       }
     }
 
+    // Whole-index degradation (#876): once live watching has permanently
+    // stopped, getPendingFiles() is empty so the per-file banner below can't
+    // fire — but the index is now FROZEN and silently drifting stale. Surface
+    // one global notice instead, so the agent Reads for current content rather
+    // than trusting a response off a no-longer-updating index. (Cross-project
+    // calls open a watcher-less CodeGraph, so this is false there — correct: we
+    // only know degraded state for the default session project.)
+    let degraded = false;
+    try {
+      degraded = cg.isWatcherDegraded?.() ?? false;
+    } catch {
+      degraded = false;
+    }
+    if (degraded) {
+      const [head, ...tail] = result.content;
+      if (!head || head.type !== 'text') return result;
+      let reason: string | null = null;
+      try {
+        reason = cg.getWatcherDegradedReason?.() ?? null;
+      } catch {
+        reason = null;
+      }
+      const composed = `${formatDegradedBanner(reason)}\n\n${head.text}`;
+      return { ...result, content: [{ type: 'text', text: composed }, ...tail] };
+    }
+
     // 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[] = [];
@@ -3325,6 +3368,19 @@ export class ToolHandler {
       }
     }
 
+    // Whole-index degradation (#876): when live watching has permanently
+    // stopped, getPendingFiles() is empty (so no "Pending sync" section below)
+    // but the index is frozen — call that out explicitly here, the one place an
+    // agent asks "is the index caught up?".
+    if (cg.isWatcherDegraded()) {
+      lines.push(
+        '',
+        '### Auto-sync disabled:',
+        `- ${cg.getWatcherDegradedReason() ?? 'live file watching stopped'}`,
+        '- The index is frozen; Read files directly for current content.'
+      );
+    }
+
     // 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