fix(watcher): follow a rebuilt index instead of syncing into the replaced file (#1902) (#1917)

* fix(watcher): follow a rebuilt index instead of syncing into the replaced file (#1902)

`codegraph index` rebuilds through `CodeGraph.recreate()`, which unlinks
`.codegraph/codegraph.db` and creates a new file at the same path. A running
MCP daemon kept its handle on the unlinked inode and its watcher kept
"auto-syncing" into it, so no edit reached the on-disk index. The #925
self-heal only ran on the tool-call path and ran no catch-up after reopening.

- `sync()` now checks, once it holds the index mutex and the write lock,
  whether the database was replaced on disk (one stat, the same inode check
  `reopenIfReplaced` uses). If so it reopens the live file in place and widens
  a scoped sync to a full reconcile. A failed reopen (rebuild mid-way) returns
  the lock-busy shape so the watcher keeps its pending files and retries.
- `reopenIfReplaced()` refuses while an index/sync holds the mutex, so the
  tool-call path never closes the handle an in-flight sync writes through.
- When a tool call's `freshen()` does reopen, the engine runs its existing
  catch-up sync (serialized on the same mutex), but only for its own watched
  instance, so an engine that is not the project's writer never writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): step aside for a rebuild that has recreated the file but not locked it yet

`codegraph index` recreates the database, then takes the write lock inside
indexAll. With the watcher now following a replaced file, a sync landing in
that gap reopened the empty file and started a full reconcile, holding the
lock the rebuild was about to request — `codegraph index` could then fail
with "another process may be indexing". Review flagged the gap; this closes
it. After following a replaced file, a sync that finds no index_state yet
(the rebuild has not started indexing) and a file written within the last
two minutes returns the lock-busy shape so the watcher retries; the first
sync after that reconciles the whole tree once, then syncs go back to scoped.
The time bound keeps a rebuild that died before indexing from parking the
watcher forever.

New test: fails without the guard, passes with it; 136 related tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): keep the full catch-up until a full sync completes (#1902)

Two gaps in following a replaced database:
- Only a reopen done by sync() itself set pendingFullReconcile. The tool-call
  self-heal (reopenIfReplaced) swapped the handle without it, so the next
  scoped watcher sync skipped whatever the old handle had absorbed. The flag
  is now set wherever the database is reopened.
- The flag was cleared before the sync ran, so a sync that threw lost the
  catch-up. It is now cleared only after a full sync completes.
Ported from the maintainer's hardening of this fix in #1919.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): report a rebuild in progress as lock contention, not an empty sync (#1902)

Since #2014 the watcher tells lock contention apart from success only by
LockUnavailableError; it no longer recognises the all-zero result. Following
a rebuilt index still returned that shape when the reopen failed or when the
recreated file had not been indexed yet, so the watcher counted the step-aside
as a clean sync and dropped the pending edits. Both paths now throw
LockUnavailableError. A watcher test proves the edit stays pending through the
rebuild gap and is reconciled once the rebuild finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: danusha2345 <danusha2345@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
This commit is contained in:
Daniil
2026-09-29 06:01:38 +03:00
committed by GitHub
parent 15a5d1d0cd
commit e5a3d85b0c
5 changed files with 411 additions and 2 deletions
+1
View File
@@ -173,6 +173,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- On Windows, the shared MCP daemon now waits longer for another program — an antivirus scan, an indexer, or another session reading its lock file — to let go of that file, so it starts instead of leaving the session to fall back to a slower in-process server. (#1773)
- File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964)
- Running `codegraph index` while the MCP server is running no longer leaves live auto-sync writing into the old, replaced index: the server now switches to the rebuilt index before its next update and catches up on everything it would otherwise have missed. (#1902)
- Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.
- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.
- Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829)
+317
View File
@@ -0,0 +1,317 @@
/**
* Live sync follows a rebuilt index (issue #1902).
*
* `codegraph index` rebuilds through `CodeGraph.recreate()`, which unlinks
* `.codegraph/codegraph.db` and creates a new file (a new inode) at the same
* path. A long-lived instance — the MCP daemon — keeps its handle on the old,
* unlinked inode. Before the fix its watcher kept "auto-syncing" into that dead
* inode (nothing it wrote was visible to any other process), and the #925
* self-heal on the tool-call path reopened the live file without a catch-up,
* so every edit absorbed in between was lost.
*
* POSIX-only: an open file can't be unlinked on Windows, and st_ino is
* unreliable there, so the replaced-inode hazard does not arise.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph, { LockUnavailableError } from '../src/index';
import type { MCPEngine } from '../src/mcp/engine';
const posixOnly = it.runIf(process.platform !== 'win32');
/** Rebuild the index in a separate instance, the way `codegraph index` does. */
async function rebuild(root: string): Promise<void> {
const cg = await CodeGraph.recreate(root);
await cg.indexAll();
cg.close();
}
/** Open the file at the path fresh (as a new CLI process would) and look up a name. */
async function onDiskHas(root: string, name: string): Promise<boolean> {
const cg = await CodeGraph.open(root);
try {
return cg.searchNodes(name).some((r) => r.node.name === name);
} finally {
cg.close();
}
}
async function waitFor(cond: () => boolean | Promise<boolean>, timeoutMs = 10000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await cond()) return;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error('timed out waiting for condition');
}
describe('live sync after the index is rebuilt by another process (#1902)', () => {
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1902-'));
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export function alpha() { return 1; }\n');
fs.writeFileSync(path.join(root, 'src', 'b.ts'), 'export function bravo() { return 2; }\n');
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
posixOnly('a watcher-picked-up edit lands in the NEW database file, not the unlinked one', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
let synced = 0;
expect(server.watch({ debounceMs: 100, onSyncComplete: () => { synced++; } })).toBe(true);
try {
await rebuild(root);
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function beta() { return alpha(); }\n');
await waitFor(() => synced > 0);
// A fresh open of the path sees the edit — the watcher wrote into the live file.
expect(await onDiskHas(root, 'beta')).toBe(true);
// And the server itself now holds the live file.
expect(server.searchNodes('beta').some((r) => r.node.name === 'beta')).toBe(true);
expect(server.reopenIfReplaced()).toBe(false);
} finally {
server.unwatch();
server.close();
}
});
posixOnly('a sync in the gap between recreate and indexAll steps aside, then reconciles in full', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
try {
// `codegraph index`: the file is recreated first, the write lock is taken later by indexAll.
const rebuilder = await CodeGraph.recreate(root);
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function gamma() { return 3; }\n');
// The server's sync lands in the gap: it must not claim the lock for a
// full reconcile of the empty file, and reports contention instead of success.
await expect(server.sync({ paths: ['src/a.ts'] })).rejects.toBeInstanceOf(LockUnavailableError);
// So the rebuild still gets its lock and completes.
const built = await rebuilder.indexAll();
expect(built.success).toBe(true);
rebuilder.close();
// An edit the rebuild did not see, made after it finished.
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function delta() { return 4; }\n');
// The next (scoped, unrelated) sync is widened to the whole tree once.
fs.writeFileSync(path.join(root, 'src', 'c.ts'), 'export function echo() { return 5; }\n');
const after = await server.sync({ paths: ['src/c.ts'] });
expect(after.filesChecked).toBeGreaterThan(1);
expect(await onDiskHas(root, 'gamma')).toBe(true);
expect(await onDiskHas(root, 'delta')).toBe(true);
expect(await onDiskHas(root, 'echo')).toBe(true);
// And only once: the following scoped sync stays scoped.
fs.appendFileSync(path.join(root, 'src', 'c.ts'), 'export function foxtrot() { return 6; }\n');
const scoped = await server.sync({ paths: ['src/c.ts'] });
expect(scoped.filesChecked).toBe(1);
} finally {
server.close();
}
});
posixOnly('a watcher sync that lands in the rebuild gap keeps its pending files until the rebuild is reconciled', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
// Record every sync the watcher starts, so the test can wait for the one in the gap.
const attempts: Array<Promise<unknown>> = [];
const realSync = server.sync.bind(server);
(server as any).sync = (options?: Parameters<CodeGraph['sync']>[0]) => {
const run = realSync(options);
attempts.push(run);
return run;
};
let synced = 0;
// The lock-contention backoff (debounce · 2^n, degrading after 5 retries)
// must outlast the rebuild below; 200 ms leaves several seconds.
expect(server.watch({ debounceMs: 200, onSyncComplete: () => { synced++; } })).toBe(true);
try {
// `codegraph index` has recreated the file but not taken the write lock yet.
const rebuilder = await CodeGraph.recreate(root);
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function gamma() { return 3; }\n');
await waitFor(() => attempts.length > 0);
await expect(attempts[0]).rejects.toBeInstanceOf(LockUnavailableError);
// Stepping aside is not a sync: the edit stays pending, nothing is reported as synced.
expect(synced).toBe(0);
expect(server.getPendingFiles().map((f) => f.path)).toContain('src/a.ts');
const built = await rebuilder.indexAll();
expect(built.success).toBe(true);
rebuilder.close();
// The watcher's retry reconciles the rebuilt file and only then clears the edit.
await waitFor(() => synced > 0);
expect(await onDiskHas(root, 'gamma')).toBe(true);
expect(server.getPendingFiles()).toEqual([]);
} finally {
server.unwatch();
server.close();
}
});
posixOnly('a scoped sync that finds the database replaced reconciles the whole tree', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
try {
await rebuild(root);
// Two edits after the rebuild, but the sync is told about only one of
// them — the other stands for everything the dead handle absorbed.
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function beta() { return 3; }\n');
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function charlie() { return 4; }\n');
await server.sync({ paths: ['src/a.ts'] });
expect(await onDiskHas(root, 'beta')).toBe(true);
expect(await onDiskHas(root, 'charlie')).toBe(true);
// Once the handle is live again, a scoped sync stays scoped.
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function delta() { return 5; }\n');
await server.sync({ paths: ['src/a.ts'] });
expect(await onDiskHas(root, 'delta')).toBe(false);
} finally {
server.close();
}
});
posixOnly('reopenIfReplaced does not swap the connection under an in-flight sync', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
try {
await rebuild(root);
const inFlight = server.sync();
expect(server.isIndexing()).toBe(true);
expect(server.reopenIfReplaced()).toBe(false);
await inFlight;
// The sync itself followed the path.
expect(server.reopenIfReplaced()).toBe(false);
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function echo() { return 6; }\n');
await server.sync({ paths: ['src/a.ts'] });
expect(await onDiskHas(root, 'echo')).toBe(true);
} finally {
server.close();
}
});
// The engine lazily `require`s the CodeGraph module, which only resolves in
// the built output, so this block drives the built engine (as the
// spawned-CLI suites do); `npm run build` first.
describe('tool-call reopen through the MCP engine', () => {
const ENGINE = path.resolve(__dirname, '../dist/mcp/engine.js');
let prevDebounce: string | undefined;
beforeEach(() => {
// Keep the watcher from syncing the edit on its own, so only the
// tool-call path's catch-up can put it in the rebuilt file.
prevDebounce = process.env.CODEGRAPH_WATCH_DEBOUNCE_MS;
process.env.CODEGRAPH_WATCH_DEBOUNCE_MS = '60000';
});
afterEach(() => {
if (prevDebounce === undefined) delete process.env.CODEGRAPH_WATCH_DEBOUNCE_MS;
else process.env.CODEGRAPH_WATCH_DEBOUNCE_MS = prevDebounce;
});
async function openEngine(watch: boolean): Promise<MCPEngine> {
const seed = CodeGraph.initSync(root);
await seed.indexAll();
seed.close();
if (!fs.existsSync(ENGINE)) throw new Error(`${ENGINE} missing — run \`npm run build\` first`);
const { MCPEngine: Engine } = require(ENGINE) as { MCPEngine: typeof MCPEngine };
const engine = new Engine({ watch, writerLockRoot: watch ? root : undefined });
await engine.ensureInitialized(root);
// Drain the post-open catch-up gate (and prove the project is loaded).
const first = await engine.getToolHandler().execute('codegraph_search', { query: 'alpha' });
expect(first.content[0].text).toMatch(/alpha/);
return engine;
}
posixOnly('reopening a replaced database runs a catch-up sync into the new file', async () => {
const engine = await openEngine(true);
try {
await rebuild(root);
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function foxtrot() { return 7; }\n');
expect(await onDiskHas(root, 'foxtrot')).toBe(false);
const handler = engine.getToolHandler();
// This call reopens the replaced database and starts the catch-up;
// the next call awaits its gate.
await handler.execute('codegraph_search', { query: 'alpha' });
const res = await handler.execute('codegraph_search', { query: 'foxtrot' });
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toMatch(/foxtrot/);
expect(await onDiskHas(root, 'foxtrot')).toBe(true);
} finally {
engine.stop();
}
});
posixOnly('an engine that is not watching reopens but does not write', async () => {
const engine = await openEngine(false);
try {
await rebuild(root);
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function golf() { return 8; }\n');
const handler = engine.getToolHandler();
await handler.execute('codegraph_search', { query: 'alpha' });
await handler.execute('codegraph_search', { query: 'golf' });
expect(await onDiskHas(root, 'golf')).toBe(false);
} finally {
engine.stop();
}
});
});
posixOnly('a reopen by a tool call widens the next scoped sync to the whole tree', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
try {
await rebuild(root);
// The self-heal on the tool-call path, not a sync, follows the path.
expect(server.reopenIfReplaced()).toBe(true);
const live = (server as any).orchestrator;
const calls: Array<string[] | undefined> = [];
const sync = live.sync.bind(live);
live.sync = (onProgress: unknown, paths: string[] | undefined, ...rest: unknown[]) => {
calls.push(paths);
return sync(onProgress, paths, ...rest);
};
await server.sync({ paths: ['src/a.ts'] });
await server.sync({ paths: ['src/a.ts'] });
// The first sync after the reopen reconciled everything; the next stays scoped.
expect(calls).toEqual([undefined, ['src/a.ts']]);
} finally {
server.close();
}
});
posixOnly('a catch-up sync that fails keeps the full reconcile for the next one', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
try {
await rebuild(root);
expect(server.reopenIfReplaced()).toBe(true);
const live = (server as any).orchestrator;
const calls: Array<string[] | undefined> = [];
const sync = live.sync.bind(live);
let failNext = true;
live.sync = (onProgress: unknown, paths: string[] | undefined, ...rest: unknown[]) => {
calls.push(paths);
if (failNext) {
failNext = false;
return Promise.reject(new Error('injected sync failure'));
}
return sync(onProgress, paths, ...rest);
};
await expect(server.sync({ paths: ['src/a.ts'] })).rejects.toThrow('injected sync failure');
await server.sync({ paths: ['src/a.ts'] });
expect(calls).toEqual([undefined, undefined]);
} finally {
server.close();
}
});
});
+67
View File
@@ -235,6 +235,25 @@ export class CodeGraph {
);
}
/**
* Set when this instance followed a database replaced on disk (#1902): the
* next sync that can run reconciles the whole tree, because whatever the old
* handle absorbed since the rebuild never reached the new file.
*/
private pendingFullReconcile = false;
/** How long a recreated, not-yet-indexed database is treated as a rebuild in progress. */
private static readonly RECREATE_GRACE_MS = 120_000;
/** The database file at the path was written within the recreate grace window. */
private isFreshlyRecreated(): boolean {
try {
return Date.now() - fs.statSync(getDatabasePath(this.projectRoot)).mtimeMs < CodeGraph.RECREATE_GRACE_MS;
} catch {
return false;
}
}
/**
* Heal a stale database handle in place. If `.codegraph/` was removed and
* recreated at the SAME path while this instance held the DB open — a git
@@ -248,8 +267,19 @@ export class CodeGraph {
*
* POSIX-only in practice: `isReplacedOnDisk` never fires on Windows (an open
* file can't be unlinked there, and st_ino is unreliable).
*
* Refuses (returns false) while an index/sync holds the index mutex: closing
* the handle that run is writing through would break it mid-flight. `sync()`
* performs the same check itself once it holds the mutex (#1902), so the
* replaced file is still picked up — by that sync, or by the caller's retry.
*/
reopenIfReplaced(): boolean {
if (this.indexMutex.isLocked()) return false;
return this.reopenReplacedDatabase();
}
/** The body of {@link reopenIfReplaced}, without the in-flight-sync guard. */
private reopenReplacedDatabase(): boolean {
if (!this.db.isReplacedOnDisk()) return false;
const dbPath = this.db.getPath();
// Open the live file FIRST — if that throws (e.g. mid-recreate), the old
@@ -260,6 +290,9 @@ export class CodeGraph {
this.db = fresh;
this.queries = new QueryBuilder(fresh.getDb());
this.wireLayers();
// Whoever reopened — a sync, or a tool call's self-heal — the next sync
// must reconcile the whole tree (#1902).
this.pendingFullReconcile = true;
// Releasing the dead handle also frees the leaked db/-wal/-shm fds that were
// pinning the unlinked inode (#925).
try { stale.close(); } catch { /* the old inode is gone; closing just frees fds */ }
@@ -798,6 +831,39 @@ export class CodeGraph {
`Sync could not acquire the file lock; retry when the index is available. ${err instanceof Error ? err.message : String(err)}`
);
}
// A full rebuild in another process (`codegraph index` → recreate)
// unlinks the database and creates a new file at the same path. A
// long-lived instance — the MCP daemon's watcher — would otherwise keep
// "syncing" into the dead inode, and nothing it wrote there is visible
// to anyone (#1902). Follow the path before writing (one stat), and
// widen a scoped sync to a full one: whatever the old handle absorbed
// since the rebuild is gone, so the new file has to be reconciled whole.
// If the reopen fails (the rebuild is mid-way), report lock contention
// so the watcher keeps its pending files and retries.
try {
this.reopenReplacedDatabase();
} catch (err) {
this.fileLock.release();
throw new LockUnavailableError(
`Sync could not open the rebuilt index yet; retry when the rebuild finishes. ${err instanceof Error ? err.message : String(err)}`
);
}
if (this.pendingFullReconcile) {
// `codegraph index` recreates the file, THEN takes the write lock in
// indexAll. A sync landing in that gap would otherwise run a full
// reconcile of the empty file and hold the lock the rebuild is about
// to ask for. A fresh file with no index_state yet is that rebuild:
// step aside (lock contention, the watcher retries) and reconcile in
// full once it is done. Bounded, so a rebuild that died before
// indexing does not park the watcher forever.
if (this.getIndexState() === null && this.isFreshlyRecreated()) {
this.fileLock.release();
throw new LockUnavailableError('A rebuild of this index is in progress; retry when it finishes.');
}
// Cleared only once this run completes (below): a sync that throws
// must leave the full catch-up for the next one.
options = { ...options, paths: undefined };
}
// Defer WAL auto-checkpointing for the whole incremental run, exactly
// as indexAll does for the bulk path (#1231): sync's store loop and its
// resolution passes churn the same FTS + secondary-index hot pages, and
@@ -1076,6 +1142,7 @@ export class CodeGraph {
this.orchestrator.finishGitIndexState(gitState, fullReconcile, result.failedFilePaths);
if (fullReconcile && result.filesChecked > 0) this.pendingFullReconcile = false;
return result;
} finally {
// Mirror indexAll's teardown: stop the valve, then restore the
+13 -2
View File
@@ -109,6 +109,15 @@ export class MCPEngine {
activate: (cg) => this.explicitProjects.get(cg)?.ready() ?? Promise.resolve(),
release: (cg) => this.releaseExplicitProject(cg),
});
// A tool call found the default project's database replaced on disk (a
// `codegraph index` rebuild) and reopened it (#1902). Reconcile the new
// file with the usual catch-up — `sync()` serializes on the index mutex,
// so it never overlaps an in-flight watcher sync. Only when this engine is
// watching, i.e. it is the project's writer: a read-only engine (writer
// lock held elsewhere, watching disabled) must not start writing.
this.toolHandler.setOnDatabaseReopened((cg) => {
if (cg === this.cg && cg.isWatching()) this.catchUpSync(true);
});
if (opts.writerLockRoot && !this.opts.readOnly) {
assertNoRebuild(opts.writerLockRoot);
const writer = tryAcquireWriterLock(opts.writerLockRoot, 'fallback');
@@ -407,10 +416,12 @@ export class MCPEngine {
* and the per-file staleness banner can't help because `getPendingFiles()`
* is populated by the watcher, not by catch-up).
*/
private catchUpSync(): void {
private catchUpSync(afterReopen = false): void {
const cg = this.cg;
if (!cg || this.opts.readOnly) return;
if (this.defaultLease) {
// The lease's gate is the startup reconcile and stays settled once caught
// up; a database reopened after a rebuild (#1902) needs a sync of its own.
if (this.defaultLease && !afterReopen) {
this.toolHandler.setCatchUpGate(this.defaultLease.ready());
return;
}
+13
View File
@@ -1929,6 +1929,9 @@ export class ToolHandler {
// (see {@link resolveCatchUpGateTimeoutMs}) so a minutes-long reconcile on a
// huge repo can't hang a call (#905); cleared when the reconcile settles.
private catchUpGate: Promise<void> | null = null;
// Engine hook fired when `freshen` reopened a replaced database (#1902), so
// the engine can reconcile the new file with a catch-up sync.
private onDatabaseReopened: ((cg: CodeGraph) => void) | null = null;
// Optional worker-thread pool for off-loop read-tool dispatch. When ready +
// healthy, heavy reads leave the main loop free for the MCP transport.
private queryPool: QueryPool | null = null;
@@ -1986,6 +1989,15 @@ export class ToolHandler {
});
}
/**
* Engine-only: called after a tool call's {@link freshen} reopened a database
* that was replaced on disk (#1902). The engine decides whether a catch-up
* sync is its to run (only for the instance it watches and writes).
*/
setOnDatabaseReopened(fn: ((cg: CodeGraph) => void) | null): void {
this.onDatabaseReopened = fn;
}
/**
* Await the catch-up gate, but no longer than the configured timeout (#905).
* If the reconcile settles first, we got the fully-reconciled answer. If the
@@ -2319,6 +2331,7 @@ export class ToolHandler {
'[CodeGraph MCP] The index was replaced on disk (e.g. a git worktree ' +
'recreated at the same path); reopened the live database in place.\n'
);
this.onDatabaseReopened?.(cg);
}
} catch {
// Best-effort self-heal — a failed reopen must never break the tool call;