Skip to content

Watcher keeps a stale codegraph.json exclude matcher for the daemon's lifetime — sync and the live watcher disagree after a config change #1590

Description

@K1nG11

Summary

Watcher builds its ScopeIgnore once in start() and never rebuilds it. Because the MCP daemon is long-lived, a codegraph.json that is created or edited after the daemon started is invisible to the live watcher — while codegraph sync (which rebuilds the matcher per invocation) honours it immediately.

The result is that the CLI and the daemon disagree about project scope, and the watcher silently re-adds files the user just excluded. From the user side this looks like "exclude doesn't work", with the confusing detail that running codegraph sync does remove the file — and then it comes back a few seconds later.

To be clear: buildScopeIgnore() does read codegraph.json correctly (loadExcludeMatcherloadExcludePatterns). This is purely a staleness issue, not a missing code path.

Reproduction

Minimal, on a 2-file scratch project. codegraph 1.5.0, Windows 11.

mkdir -p repro/keepme repro/skipme
printf 'local M = {}\nfunction M.hello() return 1 end\nreturn M\n' > repro/keepme/a.lua
printf 'local N = {}\nfunction N.world() return 2 end\nreturn N\n' > repro/skipme/b.lua
printf '{ "exclude": ["skipme/"] }\n' > repro/codegraph.json

cd repro
codegraph init .
# -> Indexed 1 files.  files table = ['keepme/a.lua']   ✅ exclude honoured

# start a long-lived MCP server (this is what an agent client does)
codegraph serve --mcp        # leave running in another shell

# 1) sanity: touching an already-excluded file does NOT re-add it   ✅
printf 'local N = {}\nfunction N.world() return 3 end\nreturn N\n' > skipme/b.lua
sleep 8
sqlite3 .codegraph/codegraph.db 'select path from files'
# -> keepme/a.lua        (correct)

# 2) now ADD a new exclude while the daemon is running
printf '{ "exclude": ["skipme/", "keepme/"] }\n' > codegraph.json

# 3) touch the newly-excluded file
printf 'local M = {}\nfunction M.hello() return 1 end\nfunction M.added() return 99 end\nreturn M\n' > keepme/a.lua
sleep 8
sqlite3 .codegraph/codegraph.db 'select path from files'
# -> keepme/a.lua        ❌ still indexed
sqlite3 .codegraph/codegraph.db 'select name from nodes'
# -> M, a.lua, added, hello
#    the new symbol `added` proves the watcher re-parsed the file
#    AFTER it had been excluded

# 4) CLI sync on the same project, same config
codegraph sync .
# -> Removed: 1
sqlite3 .codegraph/codegraph.db 'select path from files'
# -> (empty)             ✅ CLI honours the new exclude

Step 3 and step 4 read the same codegraph.json and reach opposite conclusions.

Expected

After codegraph.json changes, the live watcher applies the new exclude/include patterns — or at minimum, the daemon and codegraph sync agree on scope.

Actual

The watcher keeps enforcing the patterns that were in effect when the daemon started, for the daemon's entire lifetime. A restart is required, and nothing tells the user that.

Root cause

src/sync/watcher.ts — the matcher is captured once:

// in start()
this.ignoreMatcher = buildScopeIgnore(this.projectRoot);

and reused for every event:

private handleChange(rel: string): void {
  if (!rel || rel === '.' || rel.startsWith('..')) return;
  if (this.isAlwaysIgnored(rel)) return;
  if (this.ignoreMatcher && this.ignoreMatcher.ignores(rel)) return;
  if (!isSourceFile(rel, loadExtensionOverrides(this.projectRoot))) {
    this.maybeScheduleForRemovedDir(rel);
    return;
  }
  ...
}

There is an asymmetry inside that one function worth pointing at: loadExtensionOverrides() is re-read per event, so codegraph.json extensions changes take effect live — but exclude/include do not, because they are baked into this.ignoreMatcher. Two fields of the same config file behave differently.

project-config.ts already mtime-caches (Map<string, CacheEntry> keyed by root, guarded by mtimeMs), so re-reading is one stat in the common case.

Impact

  • Editing codegraph.json in an editor that has an agent attached (the normal case) has no effect on the live index until the daemon is restarted, with no warning.
  • codegraph sync and the daemon can hold different views of scope indefinitely.
  • Users who add exclude to trim a noisy directory see it removed by sync and re-added by the watcher, which reads as the feature being broken.

Suggested fix

Rebuild ignoreMatcher when codegraph.json changes. Either:

  1. call buildScopeIgnore(this.projectRoot) lazily per event behind the existing mtime cache (symmetric with the loadExtensionOverrides() call already on that line), or
  2. treat codegraph.json as a watched path and rebuild the matcher on change.

Option 2 avoids per-event work, but option 1 is a one-line change and project-config.ts is already built for it.

Environment

  • codegraph 1.5.0 (bundled runtime)
  • Windows 11 Pro 26200, local NTFS drive
  • Reproduced on a fresh 2-file project, and originally hit on a real 100-file project where the daemon had been running for 3 days before codegraph.json was created.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions