Skip to content

fix(mcp): make the file watcher honour workspace filters and harden the shared engine - #25

Merged
anvanster merged 3 commits into
mainfrom
fix/watcher-honours-workspace-filters
Oct 1, 2026
Merged

anvanster merged 3 commits into
mainfrom
fix/watcher-honours-workspace-filters

Conversation

@anvanster

Copy link
Copy Markdown
Member

Intent

The developer wanted to fix GitHub issue #23, where the MCP file watcher ignored workspace exclusion filters (--exclude, .codegraphignore), loaded the embedding model despite --graph-only, did repeated full-graph path scans per event, forwarded only --embedding-model to the auto-spawned shared engine, and allowed concurrent clients to start duplicate engines or load a workspace twice. They want to remain the sole maintainer, so they explicitly rejected merging the outside contributor's PR #24. They asked for the fix to be implemented independently and included alongside the other fixes. The work went on its own branch (fix/watcher-honours-workspace-filters) off main, with the reporter credited for the report and diagnosis. It also covers related pre-existing bugs found while testing, such as symlinked workspaces never removing stale symbols. The developer's final instruction was to commit this change and push it through the no-mistakes gate.

What Changed

  • The MCP file watcher now uses the same exclusion rule as the indexer, through a shared is_excluded_entry() and a new WorkspaceFilter. That means --exclude, .codegraphignore and the default skip dirs now apply to watched events, checked against every ancestor directory of the event path. Each batch is handled with one path-to-nodes map and one batched re-embed (update_files_vectors) instead of several full-graph scans per event. Orphan vectors are pruned after each batch.
  • Event paths in symlinked workspaces (e.g. /var -> /private/var) are now translated back to the indexed form. Before this, edits added symbols without removing the old ones, and deletes removed nothing. Deleted-directory events in such workspaces are matched too.
  • --graph-only no longer loads the embedding model through the memory manager, so memory tools are unavailable in that mode (README and tool-calling guide updated). An auto-spawned shared engine now receives --exclude, --max-files and --graph-only along with --embedding-model; --profile is deliberately not forwarded. The engine also holds an exclusive socket lock for its whole lifetime, and loads each workspace once through a per-workspace OnceCell, so concurrent clients can no longer start duplicate engines or load a workspace twice.

Reported and diagnosed by Christopher Schulze in #23.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The fix-round change is small and correct: it adds a check of the raw event path against the canonical root, which locates resolved-form deletes without needing the filesystem, and the new regression test fails without the fix.

Testing

I built the target and base binaries and drove both live. The watcher scenarios used stdio MCP on a workspace opened through an explicit symlink under /var -> /private/var. They covered excluded writes, .codegraphignore writes, a file edit and a whole-directory delete. I also ran --run-tool and --serve with --graph-only, and started four --connect clients at once against an unused socket. Every scenario passes on the target, and on base each one shows the original bug. The change's own focused unit tests also pass. The evidence is CLI transcripts and server logs in the evidence directory; there is no UI surface. I removed the temporary binaries, and the worktree is clean.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Writing a file under an --exclude'd directory while the MCP server runs does not add its symbols to the graph ✅ pass live target-symlink.txt: cached_ -> [] after writing cache/later.rs (base-symlink.txt: cached_later appears)
Writing a file matching a .codegraphignore pattern while running does not add its symbols ✅ pass live target-symlink.txt: genfn_ -> [] after writing src/generated/later.rs (base: genfn_later appears)
Writing a new, non-excluded file adds its symbols, under the workspace path as given ✅ pass live target-symlink.txt: kept_new found at src/new_kept.rs (base stored it at /private/... resolved path)
Editing a file in a symlinked workspace replaces its old symbols instead of duplicating them ✅ pass live target-symlink.txt: edit_ -> [edit_renamed] only (base: edit_original and edit_renamed both remain)
Adversarial: rm -rf a subdirectory of a symlinked workspace removes the symbols of every file in it ✅ pass live target-symlink.txt: old_ -> [] after rm -rf src/old; target-symlink.log shows 'Removed 2 nodes for deleted .../link/src/old/a.rs' (base: old_a, old_b remain)
--graph-only run via --run-tool never initialises the embedding model or memory manager ✅ pass live graphonly-target.log has no MemoryManager::initialize lines; graphonly-base.log shows MemoryManager initialisation
--serve --graph-only engine skips the shared model load ✅ pass live serve-graphonly-target.log: 'Engine: graph-only - not loading an embedding model'; base logs a memory-gated attempt and initialises the MemoryManager
--connect auto-spawns an engine that receives --exclude, --max-files and --graph-only (but not --profile), and that engine honours the exclusion ✅ pass live engine-target.txt: argv '--serve ... --embedding-model bge-small --max-files 777 --exclude cache --graph-only'; clients do not see cached_initial (base: only --embedding-model, cached_initial visible)
Adversarial: 4 --connect clients started at the same time against an unused socket produce exactly one engine, and every client is served ✅ pass live engine-target.txt: 1 engine process, all 4 clients answered; engine-base.txt: 3 engine processes plus graph.db.corrupt files in HOME
Evidence: Watcher transcript, target (symlinked workspace)

after writes: kept_ -> [kept_new, kept_one]; cached_ -> []; genfn_ -> []; edit_ -> [edit_renamed] after rm -rf src/old: old_ -> []

tools exposed: 42; memory tools: ['codegraph_memory_store', 'codegraph_memory_search', 'codegraph_memory_get', 'codegraph_memory_context', 'codegraph_memory_invalidate', 'codegraph_memory_list', 'codegraph_memory_stats']
== initial index ==
  initial: search('kept_') -> [('kept_one', 'src/lib.rs')]
  initial: search('cached_') -> []
  initial: search('genfn_') -> []
  initial: search('old_') -> [('old_a', 'src/old/a.rs'), ('old_b', 'src/old/b.rs')]
  initial: search('edit_') -> [('edit_original', 'src/edit.rs')]
== live writes ==
  after writes: search('kept_') -> [('kept_new', 'src/new_kept.rs'), ('kept_one', 'src/lib.rs')]
  after writes: search('cached_') -> []
  after writes: search('genfn_') -> []
  after writes: search('edit_') -> [('edit_renamed', 'src/edit.rs')]
== delete a whole directory (rm -rf src/old) ==
  after rm -rf: search('old_') -> []
Evidence: Watcher transcript, base (bug reproduced)

after writes: cached_ -> [cached_later (/private/...)]; genfn_ -> [genfn_later]; edit_ -> [edit_original, edit_renamed] after rm -rf src/old: old_ -> [old_a, old_b]

tools exposed: 42; memory tools: ['codegraph_memory_store', 'codegraph_memory_search', 'codegraph_memory_get', 'codegraph_memory_context', 'codegraph_memory_invalidate', 'codegraph_memory_list', 'codegraph_memory_stats']
== initial index ==
  initial: search('kept_') -> [('kept_one', 'src/lib.rs')]
  initial: search('cached_') -> []
  initial: search('genfn_') -> []
  initial: search('old_') -> [('old_a', 'src/old/a.rs'), ('old_b', 'src/old/b.rs')]
  initial: search('edit_') -> [('edit_original', 'src/edit.rs')]
== live writes ==
  after writes: search('kept_') -> [('kept_new', '/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.k0Le0u3DVR/real/src/new_kept.rs'), ('kept_one', 'src/lib.rs')]
  after writes: search('cached_') -> [('cached_later', '/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.k0Le0u3DVR/real/cache/later.rs')]
  after writes: search('genfn_') -> [('genfn_later', '/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.k0Le0u3DVR/real/src/generated/later.rs')]
  after writes: search('edit_') -> [('edit_original', 'src/edit.rs'), ('edit_renamed', '/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.k0Le0u3DVR/real/src/edit.rs')]
== delete a whole directory (rm -rf src/old) ==
  after rm -rf: search('old_') -> [('old_a', 'src/old/a.rs'), ('old_b', 'src/old/b.rs')]
Evidence: Server log, target watcher run
�[2m2026-10-01T01:36:15.738383Z�[0m �[32m INFO�[0m �[2mcodegraph_server�[0m�[2m:�[0m Starting CodeGraph MCP server
�[2m2026-10-01T01:36:15.738466Z�[0m �[32m INFO�[0m �[2mcodegraph_server�[0m�[2m:�[0m Workspaces: ["/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.C4srO11EIH/link"]
�[2m2026-10-01T01:36:15.738493Z�[0m �[32m INFO�[0m �[2mcodegraph_server�[0m�[2m:�[0m Embedding model: BGE-Small-EN-v1.5 (384d, 512-tok context)
�[2m2026-10-01T01:36:15.738501Z�[0m �[32m INFO�[0m �[2mcodegraph_server�[0m�[2m:�[0m Full-body embedding: true
�[2m2026-10-01T01:36:15.738507Z�[0m �[32m INFO�[0m �[2mcodegraph_server�[0m�[2m:�[0m Excluding: ["cache"]
�[2m2026-10-01T01:36:15.738618Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Project slug: real-a711
�[2m2026-10-01T01:36:15.738626Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Workspace folders: ["/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.C4srO11EIH/link"] (1 total)
�[2m2026-10-01T01:36:15.744928Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Storage detached — operating in memory-only mode
�[2m2026-10-01T01:36:15.745016Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m No persisted graph found — starting fresh
�[2m2026-10-01T01:36:15.745033Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Extension path for models: Some("~/.no-mistakes/worktrees/b8216ba13cd2/01M3TH3DZXVSHTR6BGZSAZ824F")
�[2m2026-10-01T01:36:15.745190Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m MCP server starting...
TEL: {"arch":"aarch64","embeddingModel":"bge-small","event":"mcp.start","os":"macos","version":"0.20.1"}
�[2m2026-10-01T01:36:15.745582Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Client: t 0
�[2m2026-10-01T01:36:19.828229Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Indexing workspace: ["/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.C4srO11EIH/link"]
�[2m2026-10-01T01:36:19.828932Z�[0m �[32m INFO�[0m �[2mcodegraph_server::indexer�[0m�[2m:�[0m Loaded 1 patterns from /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.C4srO11EIH/link/.codegraphignore
�[2m2026-10-01T01:36:19.842667Z�[0m �[32m INFO�[0m �[2mcodegraph_server::watcher�[0m�[2m:�[0m [resolve_cross_file] Phase 2 complete: call edges added
�[2m2026-10-01T01:36:19.843661Z�[0m �[32m INFO�[0m �[2mcodegraph_server::index_state�[0m�[2m:�[0m Saved index state (4 files)
�[2m2026-10-01T01:36:19.848747Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Persisting 8 nodes and 4 edges to storage
�[2m2026-10-01T01:36:19.849714Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Persist complete
�[2m2026-10-01T01:36:19.849992Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Persisted 8 nodes, 4 edges to graph.db (namespace: real-a711)
�[2m2026-10-01T01:36:19.850244Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Graph-only mode — skipping embedding generation
�[2m2026-10-01T01:36:19.850258Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Indexed 4 files (4 parsed, 0 skipped)
�[2m2026-10-01T01:36:19.851831Z�[0m �[32m INFO�[0m �[2mcodegraph_server::indexer�[0m�[2m:�[0m Loaded 1 patterns from /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.C4srO11EIH/link/.codegraphignore
�[2m2026-10-01T01:36:19.852658Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m MCP file watcher started (1 directories)
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
�[2m2026-10-01T01:36:21.872788Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m [file-watcher] Processing 2 changes (2 modified, 0 deleted)
�[2m2026-10-01T01:36:21.873840Z�[0m �[32m INFO�[0m �[2mcodegraph_server::watcher�[0m�[2m:�[0m [resolve_cross_file] Phase 2 complete: call edges added
�[2m2026-10-01T01:36:21.874049Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m [file-watcher] Indexed 2 files (incl. dependents), indexes rebuilt
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
�[2m2026-10-01T01:36:26.948273Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m [file-watcher] Processing 2 changes (0 modified, 2 deleted)
�[2m2026-10-01T01:36:26.948457Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m [file-watcher] Removed 2 nodes for deleted "/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.C4srO11EIH/link/src/old/a.rs"
�[2m2026-10-01T01:36:26.948498Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m [file-watcher] Removed 2 nodes for deleted "/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.C4srO11EIH/link/src/old/b.rs"
�[2m2026-10-01T01:36:26.948574Z�[0m �[32m INFO�[0m �[2mcodegraph_server::watcher�[0m�[2m:�[0m [resolve_cross_file] Phase 2 complete: call edges added
�[2m2026-10-01T01:36:26.948701Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m [file-watcher] Indexed 0 files (incl. dependents), indexes rebuilt
TEL: {"durationMs":0,"event":"mcp.tool_invoke","ok":true,"tool":"codegraph_symbol_search"}
�[2m2026-10-01T01:36:29.996966Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Client disconnected
TEL: {"event":"mcp.shutdown","toolCalls":0,"uptimeSeconds":14}
Evidence: Concurrent --connect, target

engine processes running for this socket: 1 <bin> --serve --socket ... --embedding-model bge-small --max-files 777 --exclude cache --graph-only

4 concurrent --connect clients against a cold socket /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.u3gqJM5QVd/s.sock
  client 0: tools=42 symbols=['kept_one']
  client 1: tools=42 symbols=['kept_one']
  client 2: tools=42 symbols=['kept_one']
  client 3: tools=42 symbols=['kept_one']
engine processes running for this socket: 1
  35869 <bin> --serve --socket /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.u3gqJM5QVd/s.sock --embedding-model bge-small --max-files 777 --exclude cache --graph-only
Evidence: Concurrent --connect, base

engine processes running for this socket: 3 (each started with only --embedding-model bge-small); clients see excluded cached_initial

4 concurrent --connect clients against a cold socket /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.z2OQo9Csf2/s.sock
  client 0: tools=42 symbols=['cached_initial', 'kept_one']
  client 1: tools=42 symbols=['cached_initial', 'kept_one']
  client 2: tools=42 symbols=['cached_initial', 'kept_one']
  client 3: tools=42 symbols=['cached_initial', 'kept_one']
engine processes running for this socket: 3
  35888 <bin> --serve --socket /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.z2OQo9Csf2/s.sock --embedding-model bge-small
  35889 <bin> --serve --socket /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.z2OQo9Csf2/s.sock --embedding-model bge-small
  35891 <bin> --serve --socket /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.z2OQo9Csf2/s.sock --embedding-model bge-small
Evidence: --serve --graph-only log, target
�[2m2026-10-01T01:38:44.311690Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::engine::imp�[0m�[2m:�[0m Engine: graph-only — not loading an embedding model
�[2m2026-10-01T01:38:44.311792Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::engine::imp�[0m�[2m:�[0m Engine: loading workspace /private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.EsTbIUk2eS/ws
�[2m2026-10-01T01:38:44.311841Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Project slug: ws-a803
�[2m2026-10-01T01:38:44.311849Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Workspace folders: ["/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.EsTbIUk2eS/ws"] (1 total)
�[2m2026-10-01T01:38:44.317543Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Storage detached — operating in memory-only mode
�[2m2026-10-01T01:38:44.317687Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m No persisted graph found — starting fresh
�[2m2026-10-01T01:38:44.317721Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Extension path for models: Some("/")
�[2m2026-10-01T01:38:44.317898Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Indexing workspace: ["/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.EsTbIUk2eS/ws"]
�[2m2026-10-01T01:38:44.318028Z�[0m �[32m INFO�[0m �[2mcodegraph_server::indexer�[0m�[2m:�[0m Loaded 1 patterns from /private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.EsTbIUk2eS/ws/.codegraphignore
�[2m2026-10-01T01:38:44.323476Z�[0m �[32m INFO�[0m �[2mcodegraph_server::watcher�[0m�[2m:�[0m [resolve_cross_file] Phase 2 complete: call edges added
�[2m2026-10-01T01:38:44.323708Z�[0m �[32m INFO�[0m �[2mcodegraph_server::index_state�[0m�[2m:�[0m Saved index state (5 files)
�[2m2026-10-01T01:38:44.326938Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Persisting 10 nodes and 5 edges to storage
�[2m2026-10-01T01:38:44.328039Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Persist complete
�[2m2026-10-01T01:38:44.328316Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Persisted 10 nodes, 5 edges to graph.db (namespace: ws-a803)
�[2m2026-10-01T01:38:44.328547Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Graph-only mode — skipping embedding generation
�[2m2026-10-01T01:38:44.328558Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Indexed 5 files (5 parsed, 0 skipped)
�[2m2026-10-01T01:38:44.329882Z�[0m �[32m INFO�[0m �[2mcodegraph_server::indexer�[0m�[2m:�[0m Loaded 1 patterns from /private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.EsTbIUk2eS/ws/.codegraphignore
�[2m2026-10-01T01:38:44.330664Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m MCP file watcher started (1 directories)
�[2m2026-10-01T01:38:44.330763Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::engine::imp�[0m�[2m:�[0m Engine listening on /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.EsTbIUk2eS/s.sock
�[2m2026-10-01T01:38:52.389979Z�[0m �[32m INFO�[0m �[2mcodegraph_server�[0m�[2m:�[0m SIGTERM received — shutting down
Evidence: --serve --graph-only log, base
�[2m2026-10-01T01:38:52.440576Z�[0m �[33m WARN�[0m �[2mcodegraph_server::mcp::engine::imp�[0m�[2m:�[0m Engine: only 1208 MB available — skipping shared embedding model to avoid OOM; running graph-only. Set CODEGRAPH_SKIP_MEMORY_CHECK=1 to override.
�[2m2026-10-01T01:38:52.440680Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::engine::imp�[0m�[2m:�[0m Engine: loading workspace /private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.2PDc7M973K/ws
�[2m2026-10-01T01:38:52.440733Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Project slug: ws-3d42
�[2m2026-10-01T01:38:52.440743Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Workspace folders: ["/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.2PDc7M973K/ws"] (1 total)
�[2m2026-10-01T01:38:52.446746Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Storage detached — operating in memory-only mode
�[2m2026-10-01T01:38:52.446867Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m No persisted graph found — starting fresh
�[2m2026-10-01T01:38:52.446886Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Extension path for models: Some("/")
�[2m2026-10-01T01:38:52.447030Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Indexing workspace: ["/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.2PDc7M973K/ws"]
�[2m2026-10-01T01:38:52.447066Z�[0m �[32m INFO�[0m �[2mcodegraph_server::memory�[0m�[2m:�[0m [MemoryManager::initialize] Starting initialization
�[2m2026-10-01T01:38:52.447073Z�[0m �[32m INFO�[0m �[2mcodegraph_server::memory�[0m�[2m:�[0m [MemoryManager::initialize] Workspace path: "/private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.2PDc7M973K/ws"
�[2m2026-10-01T01:38:52.447103Z�[0m �[32m INFO�[0m �[2mcodegraph_server::memory�[0m�[2m:�[0m [MemoryManager::initialize] Data directory: "/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.2PDc7M973K/home/.codegraph/projects/ws-3d42/memory"
�[2m2026-10-01T01:38:52.447261Z�[0m �[32m INFO�[0m �[2mcodegraph_server::memory�[0m�[2m:�[0m [MemoryManager::initialize] available memory: 1209 MB
�[2m2026-10-01T01:38:52.447325Z�[0m �[33m WARN�[0m �[2mcodegraph_server::memory�[0m�[2m:�[0m [MemoryManager::initialize] only 1209 MB available — skipping embedding model to avoid OOM; semantic search disabled (graph-only). Set CODEGRAPH_SKIP_MEMORY_CHECK=1 to override.
�[2m2026-10-01T01:38:52.447339Z�[0m �[33m WARN�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Failed to initialize memory manager: Other("insufficient memory (1209 MB available) to load embedding model; running graph-only (set CODEGRAPH_SKIP_MEMORY_CHECK=1 to override)")
�[2m2026-10-01T01:38:52.447436Z�[0m �[32m INFO�[0m �[2mcodegraph_server::indexer�[0m�[2m:�[0m Loaded 1 patterns from /private/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.2PDc7M973K/ws/.codegraphignore
�[2m2026-10-01T01:38:52.452754Z�[0m �[32m INFO�[0m �[2mcodegraph_server::watcher�[0m�[2m:�[0m [resolve_cross_file] Phase 2 complete: call edges added
�[2m2026-10-01T01:38:52.452931Z�[0m �[32m INFO�[0m �[2mcodegraph_server::index_state�[0m�[2m:�[0m Saved index state (5 files)
�[2m2026-10-01T01:38:52.456560Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Persisting 10 nodes and 5 edges to storage
�[2m2026-10-01T01:38:52.457592Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Persist complete
�[2m2026-10-01T01:38:52.457866Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Persisted 10 nodes, 5 edges to graph.db (namespace: ws-3d42)
�[2m2026-10-01T01:38:52.458128Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Indexed 5 files (5 parsed, 0 skipped)
�[2m2026-10-01T01:38:52.459259Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m MCP file watcher started (1 directories)
�[2m2026-10-01T01:38:52.459358Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::engine::imp�[0m�[2m:�[0m Engine listening on /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.2PDc7M973K/s.sock
�[2m2026-10-01T01:39:00.516950Z�[0m �[32m INFO�[0m �[2mcodegraph_server�[0m�[2m:�[0m SIGTERM received — shutting down
Evidence: --run-tool --graph-only logs (target/base)
�[2m2026-10-01T01:38:17.819165Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Project slug: ws-c87e
�[2m2026-10-01T01:38:17.819236Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Workspace folders: ["/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.4Z1bdrIhKz/ws"] (1 total)
�[2m2026-10-01T01:38:17.828344Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Storage detached — operating in memory-only mode
�[2m2026-10-01T01:38:17.828629Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m No persisted graph found — starting fresh
�[2m2026-10-01T01:38:17.828780Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Extension path for models: Some("/")
�[2m2026-10-01T01:38:17.829639Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Indexing workspace: ["/var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.4Z1bdrIhKz/ws"]
�[2m2026-10-01T01:38:17.830072Z�[0m �[32m INFO�[0m �[2mcodegraph_server::indexer�[0m�[2m:�[0m Loaded 1 patterns from /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.4Z1bdrIhKz/ws/.codegraphignore
�[2m2026-10-01T01:38:17.838177Z�[0m �[32m INFO�[0m �[2mcodegraph_server::watcher�[0m�[2m:�[0m [resolve_cross_file] Phase 2 complete: call edges added
�[2m2026-10-01T01:38:17.838733Z�[0m �[32m INFO�[0m �[2mcodegraph_server::index_state�[0m�[2m:�[0m Saved index state (5 files)
�[2m2026-10-01T01:38:17.842553Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Persisting 10 nodes and 5 edges to storage
�[2m2026-10-01T01:38:17.843485Z�[0m �[32m INFO�[0m �[2mcodegraph::graph::codegraph�[0m�[2m:�[0m Persist complete
�[2m2026-10-01T01:38:17.843719Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Persisted 10 nodes, 5 edges to graph.db (namespace: ws-c87e)
�[2m2026-10-01T01:38:17.844057Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Graph-only mode — skipping embedding generation
�[2m2026-10-01T01:38:17.844078Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::server�[0m�[2m:�[0m Indexed 5 files (5 parsed, 0 skipped)
�[2m2026-10-01T01:38:17.845416Z�[0m �[32m INFO�[0m �[2mcodegraph_server::indexer�[0m�[2m:�[0m Loaded 1 patterns from /var/folders/dk/62tyv1993n5dy5jqms827nlc0000gn/T/tmp.4Z1bdrIhKz/ws/.codegraphignore
�[2m2026-10-01T01:38:17.846223Z�[0m �[32m INFO�[0m �[2mcodegraph_server::mcp::file_watcher�[0m�[2m:�[0m MCP file watcher started (1 directories)
Evidence: Driver scripts
#!/usr/bin/env python3
"""Drive codegraph-server --mcp over stdio and exercise the file watcher live.

Usage: drive_watcher.py <binary> <workspace-as-given> <real-dir-for-edits> <log> [extra args...]
"""
import json, os, shutil, subprocess, sys, time

binary, ws, real, log = sys.argv[1:5]
extra = sys.argv[5:]
env = dict(os.environ, HOME=os.path.join(os.path.dirname(log), "home-" + os.path.basename(log)),
           RUST_LOG="info", CODEGRAPH_TELEMETRY="0", DO_NOT_TRACK="1")
os.makedirs(env["HOME"], exist_ok=True)
errf = open(log, "w")
p = subprocess.Popen([binary, "--mcp", "-w", ws, *extra], stdin=subprocess.PIPE,
                     stdout=subprocess.PIPE, stderr=errf, env=env, text=True, bufsize=1)
mid = 0
def rpc(method, params=None, notify=False):
    global mid
    msg = {"jsonrpc": "2.0", "method": method}
    if params is not None: msg["params"] = params
    if not notify:
        mid += 1; msg["id"] = mid
    p.stdin.write(json.dumps(msg) + "\n"); p.stdin.flush()
    if notify: return None
    while True:
        line = p.stdout.readline()
        if not line: raise SystemExit("server closed stdout")
        r = json.loads(line)
        if r.get("id") == mid: return r

def search(q):
    r = rpc("tools/call", {"name": "codegraph_symbol_search", "arguments": {"query": q, "limit": 50}})
    text = r["result"]["content"][0]["text"]
    d = json.loads(text)
    return sorted({(s["symbol"]["name"], s["symbol"]["location"]["file"].split(ws.rstrip('/') + '/')[-1])
                   for s in d.get("results", []) if s["symbol"]["name"].startswith(q)})

def show(label, q):
    print(f"  {label}: search('{q}') -> {search(q)}")

rpc("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}})
rpc("notifications/initialized", notify=True)
time.sleep(4)  # initial index + watcher start
tools = rpc("tools/list")
names = [t["name"] for t in tools["result"]["tools"]]
print(f"tools exposed: {len(names)}; memory tools: {[n for n in names if 'memory' in n]}")

print("== initial index ==")
for q in ["kept_", "cached_", "genfn_", "old_", "edit_"]: show("initial", q)

def W(rel, body):
    full = os.path.join(real, rel); os.makedirs(os.path.dirname(full), exist_ok=True)
    open(full, "w").write(body)

print("== live writes ==")
W("src/new_kept.rs", "fn kept_new() {}\n")
W("cache/later.rs", "fn cached_later() {}\n")          # --exclude cache
W("src/generated/later.rs", "fn genfn_later() {}\n")   # .codegraphignore **/generated/**
W("src/edit.rs", "fn edit_renamed() {}\n")              # replaces edit_original
time.sleep(5)
for q in ["kept_", "cached_", "genfn_", "edit_"]: show("after writes", q)

print("== delete a whole directory (rm -rf src/old) ==")
shutil.rmtree(os.path.join(real, "src/old"))
time.sleep(5)
show("after rm -rf", "old_")

p.stdin.close()
try: p.wait(10)
except subprocess.TimeoutExpired: p.kill()
errf.close()
- Outcome: ⚠️ 1 info across 1 run (7m43s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ crates/codegraph-server/src/indexer.rs:129 - Deleting a directory in a symlinked workspace still leaves its symbols in the graph, which is the same 'graph only grows' bug this change says it fixes. WorkspaceFilter::locate builds the resolved form of an event path by canonicalizing the path's parent directory. It then compares that resolved form with the canonical root, and compares the raw event path only with the root as given. Example: the workspace is opened as /var/folders/x/ws and the user runs rm -rf src/old. FSEvents reports /private/var/folders/x/ws/src/old/a.rs, but by intake time src/old is gone, so canonicalize() fails and resolved is None. The raw path does not start with /var/folders/x/ws, so admits() returns false and is_watchable drops the event. The nodes for every file under the deleted directory are never removed. The same happens when FSEvents reports such a delete as a modify, the 'vanished' path. Fix: also compare the raw event path against the canonical root ((Some(path), canonical) in the candidate list). FSEvents already reports resolved paths, so this needs no filesystem access and works after the parent is deleted. Adding a test that deletes a whole subdirectory under the symlinked root would cover it.

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Test** - 1 info
  • ℹ️ crates/codegraph-server/src/main.rs - This was already the case on base and the change does not touch it. A --connect client started with --profile core is still shown all 42 tools. The engine-args comment says the profile filters the tools one client is shown, but in --connect mode nothing on either side applies it. This may be worth a follow-up issue.
  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Writing a file under an --exclude'd directory while the MCP server runs does not add its symbols to the graph ✅ pass live target-symlink.txt: cached_ -> [] after writing cache/later.rs (base-symlink.txt: cached_later appears)
Writing a file matching a .codegraphignore pattern while running does not add its symbols ✅ pass live target-symlink.txt: genfn_ -> [] after writing src/generated/later.rs (base: genfn_later appears)
Writing a new, non-excluded file adds its symbols, under the workspace path as given ✅ pass live target-symlink.txt: kept_new found at src/new_kept.rs (base stored it at /private/... resolved path)
Editing a file in a symlinked workspace replaces its old symbols instead of duplicating them ✅ pass live target-symlink.txt: edit_ -> [edit_renamed] only (base: edit_original and edit_renamed both remain)
Adversarial: rm -rf a subdirectory of a symlinked workspace removes the symbols of every file in it ✅ pass live target-symlink.txt: old_ -> [] after rm -rf src/old; target-symlink.log shows 'Removed 2 nodes for deleted .../link/src/old/a.rs' (base: old_a, old_b remain)
--graph-only run via --run-tool never initialises the embedding model or memory manager ✅ pass live graphonly-target.log has no MemoryManager::initialize lines; graphonly-base.log shows MemoryManager initialisation
--serve --graph-only engine skips the shared model load ✅ pass live serve-graphonly-target.log: 'Engine: graph-only - not loading an embedding model'; base logs a memory-gated attempt and initialises the MemoryManager
--connect auto-spawns an engine that receives --exclude, --max-files and --graph-only (but not --profile), and that engine honours the exclusion ✅ pass live engine-target.txt: argv '--serve ... --embedding-model bge-small --max-files 777 --exclude cache --graph-only'; clients do not see cached_initial (base: only --embedding-model, cached_initial visible)
Adversarial: 4 --connect clients started at the same time against an unused socket produce exactly one engine, and every client is served ✅ pass live engine-target.txt: 1 engine process, all 4 clients answered; engine-base.txt: 3 engine processes plus graph.db.corrupt files in HOME
  • cargo build -p codegraph-server at the target commit and at base 64f4f30 (base taken from git archive into a temporary directory), so the two binaries could be compared
  • python3 drive_watcher.py &lt;bin&gt; &lt;symlinked-ws&gt; &lt;real-dir&gt; &lt;log&gt; --graph-only --exclude cache: drives --mcp over stdio, writes to included, --excluded and .codegraphignored paths, edits a file, runs rm -rf src/old, and checks the results with codegraph_symbol_search. Run against both binaries.
  • codegraph-server --graph-only -w &lt;ws&gt; --run-tool codegraph_symbol_search on target and base, checking the logs for MemoryManager initialisation
  • python3 drive_engine.py &lt;bin&gt; &lt;ws&gt; &lt;sock&gt; &lt;home&gt; 4: starts 4 --connect clients at the same time against an unused socket with --graph-only --exclude cache --max-files 777 --profile core, then lists the --serve processes with ps and their argv. Run against both binaries.
  • codegraph-server --serve --socket &lt;sock&gt; --graph-only -w &lt;ws&gt; on target and base, checking the logs for a model load
  • cargo test -p codegraph-server --lib -- workspace_filter lock_tests and cargo test -p codegraph-server --bin codegraph-server engine_args (the change's own focused tests, all pass)
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 info
  • ℹ️ crates/codegraph-server/src/indexer.rs:290 - These problems were already there before this change, and the files this change touched are clean. cargo fmt --check reports drift in about 60 files in other crates (language parsers, codegraph-harness, codegraph-memory). cargo clippy -p codegraph-server reports warnings in lines this change did not touch: indexer.rs:290-292 (doc list indentation), indexer.rs:587 (type_complexity), server.rs:1403/4027/4074/4173, plus unused imports in the parser crates. A separate repo-wide cargo fmt --all and clippy cleanup commit would clear them without mixing unrelated churn into this fix.
✅ **Push** - passed

✅ No issues found.

anvanster and others added 3 commits September 30, 2026 18:26
…d engine

Generated files kept out of the initial index were let straight back in by
the MCP file watcher, and each event they caused cost several full-graph
scans. On an 8 GB machine running several agent sessions that showed up as
sustained watcher work and memory pressure (#23).

Watcher

- One exclusion rule. The watcher carried its own ten-entry SKIP_DIRS list
  and was never given the index config, so --exclude and .codegraphignore
  applied to the initial index only. is_excluded_entry() now states the
  indexer's rule once; the indexer's walk and a new WorkspaceFilter both use
  it. The watcher checks every ancestor of an event path, since an event
  names only the file and the walk only ever reached a file through its
  parents. default_exclude_dirs is a strict superset of the old SKIP_DIRS,
  so nothing the watcher skipped before is admitted now.

- One pass per batch. process_changes ran a full-graph property("path")
  query for every deleted file, vanished file, changed file, its dependents
  and each dependent. It now builds one path-to-nodes map per batch, and a
  delete for a file the index never held takes no lock at all. Vector
  re-embedding is batched the same way (update_files_vectors), replacing a
  full node walk per changed file.

- Symlinked workspaces. The indexer stores paths as the workspace was given;
  FSEvents reports the resolved location (/var -> /private/var, or any
  symlinked folder). Every lookup by event path missed, so on such a
  workspace an edit re-parsed a file without removing its old symbols and a
  delete removed nothing: the graph only grew. Present in 0.20.1. Event
  paths are now translated into the indexed form at intake, and the filter
  matches roots both as given and resolved.

- Orphan vectors. remove_file_vectors ran before the nodes were deleted and
  only drops vectors whose node is already gone, so it could not see the
  file it was called for. One prune_orphan_vectors after the batch covers
  deletions and the vectors left behind by re-parsing, which nothing
  cleaned up before.

- A delete-only batch now rebuilds the indexes too. This is index hygiene:
  search resolves results against the graph, so deleted symbols were not
  visible before either.

Graph-only

- index_workspace and the daemon-attach path initialised the memory manager,
  which loads the embedding model, before anything checked --graph-only. The
  existing comment on the graph-only branch already promised the model would
  never load; it now doesn't. Memory tools are unavailable in graph-only
  mode, the state the RAM gate already leaves them in on low-memory hosts.

Shared engine

- Resource settings. An auto-spawned engine was passed only the model name,
  so a client's --exclude, --max-files and --graph-only were dropped.
  engine_args() forwards the engine-level settings, EngineConfig carries
  graph_only, and a graph-only engine no longer loads the shared model.
  --profile is deliberately not forwarded: it filters one client's tool list,
  and a shared engine would impose it on every other client.

- One load per workspace. Two attaches to a cold workspace both built a full
  backend, each indexing and starting a watcher, and the loser returned its
  own unregistered copy, serving that connection from it for its whole life.
  The registry now holds a per-workspace OnceCell.

- One engine per socket. Concurrent auto-starts were guarded only by a
  socket probe taken before a model load that can take minutes; the second
  engine then removed the first one's live socket to bind its own, leaving
  the first running and unreachable. Reproduced: two clients, two engines.
  An exclusive lock (std File::try_lock) is now taken before anything is
  loaded and held for the engine's lifetime.

Verified end to end against the shipped 0.20.1 binary and this build, each
watcher test with a positive control (a fresh, non-excluded file must still
be picked up). Unit tests cover the filter (including an explicit symlink,
since Linux CI has no /var -> /private/var), most-specific-root selection,
max depth, the engine lock and the forwarded arguments.

Not addressed: .gitignore is honoured by neither the watcher nor the initial
index, which stay consistent; supporting it means adding the ignore crate.

Reported and diagnosed by Christopher Schulze in #23, whose analysis of the
watcher filter, the per-event scans, graph-only ordering and the shared
engine's dropped settings was accurate on every point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rVbt7rENTwXkdHt3Bpgb5
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

🔍 CodeGraph PR Review

8 files changed (+658/−234, 35 functions) · Risk: 🔴 high

Blast radius

58 direct callers affected (20 breaking) across CodeGraph/vscode/src, codegraph-c/src/pipeline, codegraph-server/src/ai_query, codegraph-server/src/mcp, codegraph/jetbrains/ui

⚠️ Test gaps (20 functions, 0 coverage)

  • update_files_vectors (crates/codegraph-server/src/ai_query/engine.rs) — signature_changed
  • update_file_vectors (crates/codegraph-server/src/ai_query/engine.rs) — body_changed
  • locate (crates/codegraph-server/src/indexer.rs) — signature_changed
  • new (crates/codegraph-server/src/indexer.rs) — signature_changed
  • is_excluded_entry (crates/codegraph-server/src/indexer.rs) — signature_changed
  • index_directory (crates/codegraph-server/src/indexer.rs) — body_changed
  • run (crates/codegraph-server/src/main.rs) — body_changed
  • engine_args (crates/codegraph-server/src/main.rs) — signature_changed
  • serve (crates/codegraph-server/src/mcp/engine.rs) — body_changed
  • get_or_load (crates/codegraph-server/src/mcp/engine.rs) — signature_changed
  • …and 10 more

Suggested reviewers

Andrey Vasilevsky (104 lines), anvanster (11 lines)

Suggested commit: feat(src): <describe the change> · 9 tests cover the changes
🤖 Generated by CodeGraph

@anvanster
anvanster merged commit 1e6084d into main Oct 1, 2026
1 check passed
@anvanster
anvanster deleted the fix/watcher-honours-workspace-filters branch October 1, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant