You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Daemon auto-sync keeps writing into the unlinked old codegraph.db after codegraph index — edits lost until a tool call reopens it, and the reopen never catches up (1.5.0, 1.6.0, main) #1902
While the shared MCP daemon is running, a codegraph index (full rebuild) replaces .codegraph/codegraph.db with a new inode (CodeGraph.recreate() → removeDatabaseFiles() → DatabaseConnection.initialize()). The daemon keeps its file descriptors on the old, unlinked inode, and its file watcher keeps "auto-syncing" every subsequent edit into that dead inode — successfully, so daemon.log fills with Auto-synced N file(s) while the on-disk index at the path never changes.
The #925 self-heal (ToolHandler.freshen() → CodeGraph.reopenIfReplaced()) only runs on the MCP tool-call path (getCodeGraph). The watcher's sync path never checks the inode, and when a tool call finally does trigger the reopen, no catch-up sync runs, so every edit auto-synced between the re-index and that tool call is permanently missing from the live index until something touches those files again or the user runs codegraph sync.
Because codegraph status (1.5.0 / 1.6.0) only hash-checks the files git status nominates (#1829, fixed on main in 4871114 but not yet released), the moment the lost edits are committed status reports ✓ Index is up to date for an index that does not contain them.
Real-world impact: on one of our repos the daemon (started 2026-08-25) sat on a dead inode from a codegraph index run on 2026-09-01 until 2026-09-18 — 17 days and ~350 Auto-synced log lines whose writes went nowhere. codegraph node <new symbol> said "not found", codegraph status said "up to date", daemon.log said "auto-synced". codegraph sync . (1.5 s) fixed it: 4 698 → 5 373 nodes.
Environment
codegraph 1.5.0 (npm global) and 1.6.0 (npm install @colbymchenry/codegraph@1.6.0 in a scratch prefix) — identical behaviour on both.
Also verified by reading main @ ba3c21e5: src/sync/watcher.ts and src/mcp/engine.ts contain no inode check; recreate() still unlinks; reopenIfReplaced() still does not sync. The writer.pid lock from fix(mcp): fail fast on a second direct-mode writer per project (#1740) #1744 guards a second direct-mode writer, not the CLI index command, so it does not prevent this.
Minimal repro
#!/usr/bin/env bash# CG=/path/to/codegraph ./repro.sh (default: codegraph on PATH)set -euo pipefail
CG=${CG:-codegraph}
W=$(mktemp -d /tmp/cg-repro-XXXX); P=$W/proj; mkdir -p $P/pkg;cd$P
git init -q -b main;printf'def alpha(x):\n return x + 1\n'> pkg/core.py;:> pkg/__init__.py
git -c user.name=r -c user.email=r@x add -A; git -c user.name=r -c user.email=r@x commit -qm seed
$CG init .</dev/null >/dev/null 2>&1echo"codegraph $($CG --version)"# 1. start the shared daemon exactly as an MCP client would (one initialize + one tool call, then disconnect)
CG=$CG python3 - "$P"<<'PY'import json,os,subprocess,sysp=subprocess.Popen([os.environ["CG"],"serve","--mcp","--path",sys.argv[1]],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True,bufsize=1)def rpc(o): p.stdin.write(json.dumps(o)+"\n"); p.stdin.flush()rpc({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"r","version":"0"}}}); p.stdout.readline()rpc({"jsonrpc":"2.0","method":"notifications/initialized"})rpc({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codegraph_explore","arguments":{"query":"alpha"}}}); p.stdout.readline()p.stdin.close(); p.wait(10)PY
sleep 1; DP=$(python3 -c "import json;print(json.load(open('.codegraph/daemon.pid'))['pid'])");echo"daemon pid $DP"echo"db inode before index: $(stat -c %i .codegraph/codegraph.db)"# 2. full rebuild while the daemon is alive$CG index .</dev/null >/dev/null 2>&1echo"db inode after index: $(stat -c %i .codegraph/codegraph.db)"echo"daemon fds -> deleted inode: $(ls -l /proc/$DP/fd | grep -c 'codegraph.db.*(deleted)')"# 3. edit a file: the watcher "auto-syncs" — into the dead inodeprintf'\n\ndef beta(y):\n return alpha(y)\n'>> pkg/core.py; sleep 4
echo"daemon.log: $(grep -c Auto-synced .codegraph/daemon.log) auto-sync line(s); last: $(tail -1 .codegraph/daemon.log)"echo"on-disk index has beta: $(python3 -c "import sqlite3;c=sqlite3.connect('file:.codegraph/codegraph.db?mode=ro',uri=True);print(c.execute(\"select count(*) from nodes where name='beta'\").fetchone()[0])")"
git -c user.name=r -c user.email=r@x add -A; git -c user.name=r -c user.email=r@x commit -qm beta
echo"codegraph status: $($CG status 2>&1| grep -i 'up to date\|Pending')"echo"codegraph node beta: $($CG node beta 2>&1| head -1)"$CG sync .</dev/null >/dev/null 2>&1echo"after 'codegraph sync .': $($CG node beta 2>&1| head -1)"kill$DP2>/dev/null ||true
Actual (1.6.0)
codegraph 1.6.0
daemon pid 2786279
db inode before index: 16536545
db inode after index: 16536551
daemon fds -> deleted inode: 5
daemon.log: 1 auto-sync line(s); last: [2026-09-18T21:21:08.007Z] [CodeGraph MCP] Auto-synced 1 file(s) in 54ms
on-disk index has beta: 0
codegraph status: ✓ Index is up to date
codegraph node beta: Symbol "beta" not found in the codebase
after 'codegraph sync .': **beta** (function)
1.5.0 output is identical apart from the missing log timestamps.
Expected
After Auto-synced 1 file(s), codegraph node beta (a fresh process opening the file at the path) finds beta; or at minimum the daemon notices its handle is dead before writing, reopens, and re-syncs what it missed. status should not claim "up to date" for a file whose content hash differs from the indexed one.
Additional observations from the same session
ls -l /proc/<daemon>/fd shows codegraph.db (deleted), -wal (deleted), -shm (deleted) immediately after codegraph index; they stay that way through any number of auto-syncs.
The first MCP tool call afterwards logs The index was replaced on disk … reopened the live database in place (twice — once per getCodeGraph hit in one explore), the fds move to the new inode, and the edits synced in between are not in the reopened DB (verified: 3 symbols appended to one file during the window, 0 present after reopen; they reappeared only because a later edit to the same file re-synced it wholesale).
Edits made after the reopen land correctly. Edits in the same checkout, new files, and git checkout branch switches are all handled fine when the daemon's inode matches the path — the defect is strictly the re-index-under-live-daemon window plus the missing catch-up.
codegraph sync . always repairs it, because sync scans the tree and compares size/mtime/hash against the DB rather than trusting git status.
Likely code path (v1.6.0 tag)
src/index.tsCodeGraph.recreate() → removeDatabaseFiles(dbPath) unlinks the DB the daemon holds open; nothing signals the daemon.
src/mcp/engine.tsstartWatching() → cg.watch({...}) → src/index.tswatch() syncFn → this.sync({paths}) writes through this.db with no isReplacedOnDisk() check.
src/mcp/tools.tsfreshen() is the only caller of reopenIfReplaced(), and it is reached only via getCodeGraph() on a tool call.
src/index.tsreopenIfReplaced() swaps the connection and rewires layers but never triggers a sync(); engine.catchUpSync() runs only at open time.
Suggested fix (any of these closes the gap)
In the watcher's syncFn (or at the top of CodeGraph.sync()), call reopenIfReplaced() before writing — one stat(), same check freshen() already does.
When reopenIfReplaced() returns true, schedule a full catch-up sync() (the pre-reopen auto-syncs are gone) and log how many pending files were re-scanned.
Have codegraph index refuse, or stop-and-restart the daemon, when .codegraph/daemon.pid / writer.pid points at a live process — the same way recreate() already turns Windows EBUSY into an instruction.
Related: #925 (the tool-call-path reopen this is a gap in), #1829 (status blind spot that hides the result once committed; fixed on main, unreleased), #1740 / #1744 (writer.pid covers direct-mode writers but not the CLI index path), #1361 (sync silent zeros under lock contention — unrelated here; the daemon's syncs succeed, just into the wrong inode).
Summary
While the shared MCP daemon is running, a
codegraph index(full rebuild) replaces.codegraph/codegraph.dbwith a new inode (CodeGraph.recreate()→removeDatabaseFiles()→DatabaseConnection.initialize()). The daemon keeps its file descriptors on the old, unlinked inode, and its file watcher keeps "auto-syncing" every subsequent edit into that dead inode — successfully, sodaemon.logfills withAuto-synced N file(s)while the on-disk index at the path never changes.The #925 self-heal (
ToolHandler.freshen()→CodeGraph.reopenIfReplaced()) only runs on the MCP tool-call path (getCodeGraph). The watcher's sync path never checks the inode, and when a tool call finally does trigger the reopen, no catch-up sync runs, so every edit auto-synced between the re-index and that tool call is permanently missing from the live index until something touches those files again or the user runscodegraph sync.Because
codegraph status(1.5.0 / 1.6.0) only hash-checks the filesgit statusnominates (#1829, fixed onmainin 4871114 but not yet released), the moment the lost edits are committedstatusreports✓ Index is up to datefor an index that does not contain them.Real-world impact: on one of our repos the daemon (started 2026-08-25) sat on a dead inode from a
codegraph indexrun on 2026-09-01 until 2026-09-18 — 17 days and ~350Auto-syncedlog lines whose writes went nowhere.codegraph node <new symbol>said "not found",codegraph statussaid "up to date",daemon.logsaid "auto-synced".codegraph sync .(1.5 s) fixed it: 4 698 → 5 373 nodes.Environment
npm install @colbymchenry/codegraph@1.6.0in a scratch prefix) — identical behaviour on both.node:sqlitebackend, WAL journal, default daemon mode (codegraph serve --mcp→ proxy → detached daemon).main@ba3c21e5:src/sync/watcher.tsandsrc/mcp/engine.tscontain no inode check;recreate()still unlinks;reopenIfReplaced()still does not sync. Thewriter.pidlock from fix(mcp): fail fast on a second direct-mode writer per project (#1740) #1744 guards a second direct-mode writer, not the CLIindexcommand, so it does not prevent this.Minimal repro
Actual (1.6.0)
1.5.0 output is identical apart from the missing log timestamps.
Expected
After
Auto-synced 1 file(s),codegraph node beta(a fresh process opening the file at the path) findsbeta; or at minimum the daemon notices its handle is dead before writing, reopens, and re-syncs what it missed.statusshould not claim "up to date" for a file whose content hash differs from the indexed one.Additional observations from the same session
ls -l /proc/<daemon>/fdshowscodegraph.db (deleted),-wal (deleted),-shm (deleted)immediately aftercodegraph index; they stay that way through any number of auto-syncs.The index was replaced on disk … reopened the live database in place(twice — once pergetCodeGraphhit in oneexplore), the fds move to the new inode, and the edits synced in between are not in the reopened DB (verified: 3 symbols appended to one file during the window, 0 present after reopen; they reappeared only because a later edit to the same file re-synced it wholesale).git checkoutbranch switches are all handled fine when the daemon's inode matches the path — the defect is strictly the re-index-under-live-daemon window plus the missing catch-up.codegraph sync .always repairs it, becausesyncscans the tree and compares size/mtime/hash against the DB rather than trustinggit status.Likely code path (v1.6.0 tag)
src/index.tsCodeGraph.recreate()→removeDatabaseFiles(dbPath)unlinks the DB the daemon holds open; nothing signals the daemon.src/mcp/engine.tsstartWatching()→cg.watch({...})→src/index.tswatch()syncFn →this.sync({paths})writes throughthis.dbwith noisReplacedOnDisk()check.src/mcp/tools.tsfreshen()is the only caller ofreopenIfReplaced(), and it is reached only viagetCodeGraph()on a tool call.src/index.tsreopenIfReplaced()swaps the connection and rewires layers but never triggers async();engine.catchUpSync()runs only at open time.Suggested fix (any of these closes the gap)
syncFn(or at the top ofCodeGraph.sync()), callreopenIfReplaced()before writing — onestat(), same checkfreshen()already does.reopenIfReplaced()returns true, schedule a full catch-upsync()(the pre-reopen auto-syncs are gone) and log how many pending files were re-scanned.codegraph indexrefuse, or stop-and-restart the daemon, when.codegraph/daemon.pid/writer.pidpoints at a live process — the same wayrecreate()already turns Windows EBUSY into an instruction.Related: #925 (the tool-call-path reopen this is a gap in), #1829 (
statusblind spot that hides the result once committed; fixed onmain, unreleased), #1740 / #1744 (writer.pidcovers direct-mode writers but not the CLIindexpath), #1361 (syncsilent zeros under lock contention — unrelated here; the daemon's syncs succeed, just into the wrong inode).