diff --git a/CHANGELOG.md b/CHANGELOG.md index 75535a0bbd..ef11be71c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Restarting after an interrupted index no longer gets trapped in repeated watchdog restarts while repairing the database. (#1887) + - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) - Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869) diff --git a/__tests__/concurrent-locking.test.ts b/__tests__/concurrent-locking.test.ts index 5c8ab518db..580e0430ab 100644 --- a/__tests__/concurrent-locking.test.ts +++ b/__tests__/concurrent-locking.test.ts @@ -112,13 +112,13 @@ describe('issue #238 — ToolHandler reuses the default instance (#2)', () => { fs.rmSync(dir, { recursive: true, force: true }); }); - it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', () => { - const openSpy = vi.spyOn(CodeGraph, 'openSync'); + it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', async () => { + const openSpy = vi.spyOn(CodeGraph, 'open'); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resolved = (handler as any).getCodeGraph(root); + const resolved = await (handler as any).getCodeGraph(root); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nested = (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist')); + const nested = await (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist')); expect(resolved).toBe(cg); expect(nested).toBe(cg); // a sub-path resolves up to the same default project expect(openSpy).not.toHaveBeenCalled(); // no second connection opened @@ -128,7 +128,7 @@ describe('issue #238 — ToolHandler reuses the default instance (#2)', () => { }); it('concurrent read tool calls (mixed projectPath) all succeed without "database is locked"', async () => { - const openSpy = vi.spyOn(CodeGraph, 'openSync'); + const openSpy = vi.spyOn(CodeGraph, 'open'); try { const calls: Promise<{ content: Array<{ text: string }>; isError?: boolean }>[] = [ handler.execute('codegraph_search', { query: 'helper' }), diff --git a/__tests__/db-reopen-on-replace.test.ts b/__tests__/db-reopen-on-replace.test.ts index c00b3d803e..657170fd92 100644 --- a/__tests__/db-reopen-on-replace.test.ts +++ b/__tests__/db-reopen-on-replace.test.ts @@ -14,7 +14,7 @@ * repros are gated to non-Windows; `isReplacedOnDisk` is verified to stay false * on Windows. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -114,4 +114,29 @@ describe('CodeGraph.reopenIfReplaced (issue #925)', () => { expect(server.reopenIfReplaced()).toBe(false); server.destroy(); }); + + it('does not publish a replacement connection after close wins the race', async () => { + const server = CodeGraph.initSync(root); + const stale = (server as unknown as { db: DatabaseConnection }).db; + const fresh = DatabaseConnection.initialize(path.join(root, 'replacement.db')); + let finishOpen!: (connection: DatabaseConnection) => void; + const openGate = new Promise((resolve) => { finishOpen = resolve; }); + const replacedSpy = vi.spyOn(stale, 'isReplacedOnDisk').mockReturnValue(true); + const openSpy = vi.spyOn(DatabaseConnection, 'openAsync').mockReturnValue(openGate); + + try { + const reopening = server.reopenIfReplacedAsync(); + server.close(); + finishOpen(fresh); + + expect(await reopening).toBe(false); + expect(fresh.isOpen()).toBe(false); + expect(openSpy).toHaveBeenCalledOnce(); + } finally { + replacedSpy.mockRestore(); + openSpy.mockRestore(); + try { server.close(); } catch { /* may already be closed */ } + try { fresh.close(); } catch { /* recovery closes it on the guarded path */ } + } + }); }); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index b7616272a8..57350f801a 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -138,6 +138,24 @@ describe('CodeGraph Foundation', () => { expect(after).toEqual(before); }); + it('restores every secondary index through the asynchronous open path (#1887)', async () => { + const dbPath = getDatabasePath(tempDir); + const first = DatabaseConnection.initialize(dbPath); + const before = (first.getDb() + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name") + .all() as Array<{ name: string }>).map((r) => r.name); + first.beginBulkParseLoad(); + first.close(); + + const reopened = await DatabaseConnection.openAsync(dbPath); + const after = (reopened.getDb() + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name") + .all() as Array<{ name: string }>).map((r) => r.name); + reopened.close(); + + expect(after).toEqual(before); + }); + it('skips secondary-index DDL when the schema is already healthy', () => { const dbPath = getDatabasePath(tempDir); const connection = DatabaseConnection.initialize(dbPath); diff --git a/__tests__/liveness-watchdog.test.ts b/__tests__/liveness-watchdog.test.ts index 025b419ba2..b3e6090376 100644 --- a/__tests__/liveness-watchdog.test.ts +++ b/__tests__/liveness-watchdog.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; import { parseWatchdogTimeoutMs, deriveCheckIntervalMs, @@ -58,25 +61,35 @@ describe('liveness watchdog (spawned, real watchdog process)', () => { env: Record, body: string, hardTimeoutMs: number, - progressPaths?: string[] - ): Promise<{ code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }> { + progressPaths?: string[], + prelude = '' + ): Promise<{ code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null; stderr: string }> { const src = ` - const { installMainThreadWatchdog } = require(${JSON.stringify(MODULE)}); - installMainThreadWatchdog(${progressPaths ? JSON.stringify({ progressPaths }) : ''}); - ${body} + (async () => { + ${prelude} + const { installMainThreadWatchdog } = require(${JSON.stringify(MODULE)}); + installMainThreadWatchdog(${progressPaths ? JSON.stringify({ progressPaths }) : ''}); + ${body} + })().catch((error) => { + console.error(error); + process.exit(23); + }); `; const child = spawn(process.execPath, ['-e', src], { env: { ...process.env, ...env }, - stdio: ['ignore', 'ignore', 'ignore'], + stdio: ['ignore', 'ignore', 'pipe'], }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); return new Promise((resolve) => { const timer = setTimeout(() => { child.kill('SIGKILL'); - resolve({ code: null, signal: 'TIMEOUT' }); + resolve({ code: null, signal: 'TIMEOUT', stderr }); }, hardTimeoutMs); child.on('exit', (code, signal) => { clearTimeout(timer); - resolve({ code, signal }); + resolve({ code, signal, stderr }); }); }); } @@ -183,6 +196,69 @@ describe('liveness watchdog (spawned, real watchdog process)', () => { expectKilled(r); }, 20000); + it('keeps interrupted secondary-index recovery off the watched event loop (#1887)', async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-index-recovery-')); + try { + const project = CodeGraph.initSync(projectRoot); + project.close(); + + const dbPath = getDatabasePath(projectRoot); + const seed = DatabaseConnection.open(dbPath); + const expectedIndexes = (seed.getDb() + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name") + .all() as Array<{ name: string }>).map((row) => row.name); + + seed.beginBulkNodeLoad(); + seed.beginBulkParseLoad(); + const insert = seed.getDb().prepare( + `INSERT INTO nodes ( + id, kind, name, qualified_name, file_path, language, + start_line, end_line, start_column, end_column, updated_at + ) VALUES (?, 'function', ?, ?, ?, 'typescript', 1, 1, 0, 1, ?)` + ); + seed.getDb().exec('BEGIN'); + try { + for (let i = 0; i < 600_000; i++) { + const name = `symbol_${i}`; + insert.run(`node_${i}`, name, name, `src/file_${i % 1000}.ts`, Date.now()); + } + seed.getDb().exec('COMMIT'); + } catch (error) { + seed.getDb().exec('ROLLBACK'); + throw error; + } + seed.endBulkNodeLoad(); + seed.close(); + + const DIST_INDEX = path.resolve(__dirname, '../dist/index.js'); + const prelude = ` + const { CodeGraph, initGrammars } = require(${JSON.stringify(DIST_INDEX)}); + await initGrammars(); + `; + const body = ` + CodeGraph.open(${JSON.stringify(projectRoot)}).then((cg) => { + const names = cg.db.getDb() + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name") + .all().map((row) => row.name); + cg.close(); + process.exit(JSON.stringify(names) === ${JSON.stringify(JSON.stringify(expectedIndexes))} ? 0 : 21); + }).catch(() => process.exit(22)); + `; + const r = await runChild( + { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '100' }, + body, + 20_000, + [dbPath, `${dbPath}-wal`], + prelude + ); + + expect(r.signal).toBeNull(); + expect(r.code, r.stderr).toBe(0); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, 30000); + it('does NOT kill a wedged process when CODEGRAPH_NO_WATCHDOG=1', async () => { const { code, signal } = await runChild( { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500', CODEGRAPH_NO_WATCHDOG: '1' }, diff --git a/src/db/index.ts b/src/db/index.ts index c7ec1ee3bf..190b108d3e 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -163,6 +163,42 @@ export class DatabaseConnection { * Open an existing database */ static open(dbPath: string): DatabaseConnection { + const conn = DatabaseConnection.openBase(dbPath); + try { + conn.healBulkSecondaryIndexes(); + + // Self-heal a killed session's leftover oversized WAL (#1431) — one + // statSync when healthy, off-thread checkpoint+truncate when not. + void conn.healOversizedWal(); + return conn; + } catch (error) { + try { conn.close(); } catch { /* preserve the recovery error */ } + throw error; + } + } + + /** + * Open an existing database without rebuilding dropped secondary indexes on + * the caller's event loop. A killed bulk-load window can leave those indexes + * absent, and rebuilding them on a large graph is long-running SQLite work. + */ + static async openAsync(dbPath: string): Promise { + const conn = DatabaseConnection.openBase(dbPath); + try { + await conn.healBulkSecondaryIndexesOffThread(); + + // Self-heal a killed session's leftover oversized WAL (#1431) — one + // statSync when healthy, off-thread checkpoint+truncate when not. + void conn.healOversizedWal(); + return conn; + } catch (error) { + try { conn.close(); } catch { /* preserve the recovery error */ } + throw error; + } + } + + /** Open and perform the short, synchronous recovery shared by both paths. */ + private static openBase(dbPath: string): DatabaseConnection { if (!fs.existsSync(dbPath)) { throw new Error(`Database not found: ${dbPath}`); } @@ -191,12 +227,6 @@ export class DatabaseConnection { // beginBulkNodeLoad and endBulkNodeLoad): the FTS triggers are missing and // nodes_fts is stale. Rebuild + recreate so search stays in sync. conn.healBulkNodeLoad(); - conn.healBulkSecondaryIndexes(); - - // Self-heal a killed session's leftover oversized WAL (#1431) — one - // statSync when healthy, off-thread checkpoint+truncate when not. - void conn.healOversizedWal(); - return conn; } @@ -415,6 +445,20 @@ export class DatabaseConnection { /** Recreate every secondary index a killed bulk parse/ref/edge window may leave dropped. */ private healBulkSecondaryIndexes(): void { + for (const ddl of this.bulkSecondaryIndexDdls()) { + this.db.exec(ddl); + } + } + + /** Recreate dropped bulk-load indexes on a dedicated SQLite connection. */ + private async healBulkSecondaryIndexesOffThread(): Promise { + const ddls = this.bulkSecondaryIndexDdls(); + if (ddls.length === 0) return; + await this.runSqlOffThread(ddls); + } + + /** Return the canonical DDL needed to heal a killed bulk-load window. */ + private bulkSecondaryIndexDdls(): string[] { const names = [...new Set([ ...DatabaseConnection.BULK_PARSE_INDEX_NAMES, ...DatabaseConnection.BULK_REF_INDEX_NAMES, @@ -424,15 +468,15 @@ export class DatabaseConnection { const row = this.db .prepare(`SELECT count(*) AS c FROM sqlite_master WHERE type = 'index' AND name IN (${placeholders})`) .get(...names) as { c: number } | undefined; - if ((row?.c ?? 0) >= names.length) return; + if ((row?.c ?? 0) >= names.length) return []; const schemaPath = path.join(__dirname, 'schema.sql'); const schema = fs.readFileSync(schemaPath, 'utf-8'); - for (const idx of names) { + return names.map((idx) => { const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`)); if (!m) throw new Error(`schema.sql: index ${idx} not found for crash recovery`); - this.db.exec(m[0]); - } + return m[0]; + }); } /** @@ -804,6 +848,58 @@ export class DatabaseConnection { } } + /** + * Run required SQL on a worker-owned connection. Unlike maintenance pragmas, + * crash recovery is not best-effort: the database must not be handed to a + * caller until every statement succeeds. + */ + private async runSqlOffThread(statements: string[]): Promise { + const { Worker } = await import('node:worker_threads'); + const workerSource = ` + const { workerData, parentPort } = require('node:worker_threads'); + let db; + try { + const { DatabaseSync } = require('node:sqlite'); + db = new DatabaseSync(workerData.dbPath); + db.exec('PRAGMA busy_timeout=30000'); + for (const sql of workerData.statements) db.exec(sql); + db.close(); + db = undefined; + parentPort.postMessage({ ok: true }); + } catch (error) { + try { if (db) db.close(); } catch {} + parentPort.postMessage({ + ok: false, + error: error && error.message ? error.message : String(error), + }); + } + `; + + await new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + if (error) reject(error); + else resolve(); + }; + + const worker = new Worker(workerSource, { + eval: true, + workerData: { dbPath: this.dbPath, statements }, + }); + worker.once('message', (message: { ok: boolean; error?: string }) => { + if (message.ok) finish(); + else finish(new Error(`Secondary-index recovery failed: ${message.error ?? 'unknown worker error'}`)); + }); + worker.once('error', (error) => finish(error)); + worker.once('exit', (code) => { + if (code !== 0) finish(new Error(`Secondary-index recovery worker exited with code ${code}`)); + else finish(new Error('Secondary-index recovery worker exited before reporting completion')); + }); + }); + } + /** * Close the database connection */ diff --git a/src/index.ts b/src/index.ts index 7b8ef2ca78..d94959f511 100644 --- a/src/index.ts +++ b/src/index.ts @@ -160,6 +160,8 @@ export class CodeGraph { // File watcher for auto-sync on file changes private watcher: FileWatcher | null = null; + private closed = false; + private reopenPromise: Promise | null = null; private constructor( db: DatabaseConnection, @@ -248,6 +250,7 @@ export class CodeGraph { * file can't be unlinked there, and st_ino is unreliable). */ reopenIfReplaced(): boolean { + if (this.closed) return false; 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 @@ -264,6 +267,38 @@ export class CodeGraph { return true; } + /** Async counterpart used by long-lived servers so recovery stays off-loop. */ + async reopenIfReplacedAsync(): Promise { + if (this.closed) return false; + if (this.reopenPromise) return this.reopenPromise; + const reopening = this.doReopenIfReplacedAsync(); + this.reopenPromise = reopening; + try { + return await reopening; + } finally { + if (this.reopenPromise === reopening) this.reopenPromise = null; + } + } + + /** Serialized implementation for {@link reopenIfReplacedAsync}. */ + private async doReopenIfReplacedAsync(): Promise { + if (!this.db.isReplacedOnDisk()) return false; + const dbPath = this.db.getPath(); + // As above, complete the new open before disturbing the still-usable stale + // handle. This path also keeps secondary-index recovery off the event loop. + const fresh = await DatabaseConnection.openAsync(dbPath); + if (this.closed) { + try { fresh.close(); } catch { /* close() won the lifecycle race */ } + return false; + } + const stale = this.db; + this.db = fresh; + this.queries = new QueryBuilder(fresh.getDb()); + this.wireLayers(); + try { stale.close(); } catch { /* the old inode is gone; closing just frees fds */ } + return true; + } + // =========================================================================== // Lifecycle Methods // =========================================================================== @@ -350,7 +385,7 @@ export class CodeGraph { // Open database const dbPath = getDatabasePath(resolvedRoot); - const db = DatabaseConnection.open(dbPath); + const db = await DatabaseConnection.openAsync(dbPath); const queries = new QueryBuilder(db.getDb()); const instance = new CodeGraph(db, queries, resolvedRoot); @@ -447,6 +482,8 @@ export class CodeGraph { * Close the CodeGraph instance and release resources */ close(): void { + if (this.closed) return; + this.closed = true; this.unwatch(); // Release file lock if held this.fileLock.release(); diff --git a/src/mcp/engine.ts b/src/mcp/engine.ts index b88f1b1215..373e7ec3da 100644 --- a/src/mcp/engine.ts +++ b/src/mcp/engine.ts @@ -173,13 +173,26 @@ export class MCPEngine { } /** - * Synchronous last-resort init used by the per-session retry loop when the - * background `ensureInitialized` already finished (or failed) and we need - * to pick up a project that appeared *after* the engine started. + * Last-resort init used by the per-session retry loop when the background + * `ensureInitialized` already finished (or failed) and we need to pick up a + * project that appeared *after* the engine started. */ - retryInitializeSync(searchFrom: string): void { + async retryInitializeSync(searchFrom: string): Promise { if (this.closed) return; if (this.toolHandler.hasDefaultCodeGraph()) return; + if (this.initPromise) { + try { await this.initPromise; } catch { /* let caller retry */ } + return; + } + + this.initPromise = this.doRetryInitialize(searchFrom).finally(() => { + this.initPromise = null; + }); + try { await this.initPromise; } catch { /* let the next tool call retry */ } + } + + /** Serialized implementation for {@link retryInitializeSync}. */ + private async doRetryInitialize(searchFrom: string): Promise { this.toolHandler.setDefaultProjectHint(searchFrom); // Same resolution `doInitialize` used: up-walk, then the bounded workspace // down-scan (#1606) — this retry is exactly the path that picks up a @@ -201,7 +214,12 @@ export class MCPEngine { try { this.cg.close(); } catch { /* ignore */ } this.cg = null; } - this.cg = loadCodeGraph().openSync(resolvedRoot); + const opened = await loadCodeGraph().open(resolvedRoot); + if (this.closed) { + try { opened.close(); } catch { /* shutdown already owns cleanup */ } + return; + } + this.cg = opened; this.projectPath = resolvedRoot; this.toolHandler.setDefaultCodeGraph(this.cg); this.startWatching(); @@ -269,7 +287,12 @@ export class MCPEngine { this.projectPath = resolvedRoot; try { - this.cg = await loadCodeGraph().open(resolvedRoot); + const opened = await loadCodeGraph().open(resolvedRoot); + if (this.closed) { + try { opened.close(); } catch { /* shutdown already owns cleanup */ } + return; + } + this.cg = opened; this.toolHandler.setDefaultCodeGraph(this.cg); this.startWatching(); this.catchUpSync(); @@ -296,7 +319,7 @@ export class MCPEngine { * keep working. */ private startWatching(): void { - if (!this.cg || this.watcherStarted || !this.opts.watch) return; + if (this.closed || !this.cg || this.watcherStarted || !this.opts.watch) return; // #1740: only one live watcher/writer per project. Daemon and startDirect // usually already hold writer.pid (re-entrant for this pid). Proxy diff --git a/src/mcp/session.ts b/src/mcp/session.ts index 1d5bd79c38..fb658137c0 100644 --- a/src/mcp/session.ts +++ b/src/mcp/session.ts @@ -345,10 +345,10 @@ export class MCPSession { if (this.engine.hasDefaultCodeGraph()) return; } - // Last resort: walk from the best candidate (sync open). Picks up - // projects that appeared after the server started. + // Last resort: walk from the best candidate. Picks up projects that + // appeared after the server started. const candidate = hint ?? process.cwd(); - this.engine.retryInitializeSync(candidate); + await this.engine.retryInitializeSync(candidate); } /** diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 42fdd22884..f35635dcb9 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1465,6 +1465,11 @@ const DEFAULT_MCP_TOOLS = new Set(['explore']); export class ToolHandler { // Cache of opened CodeGraph instances for cross-project queries private projectCache: Map = new Map(); + // Coalesce concurrent first queries for the same project while its async + // recovery/open is still in flight, so only one connection is created. + private projectOpenPromises: Map> = new Map(); + // Invalidates an in-flight open when closeAll() begins during shutdown. + private projectCacheGeneration = 0; // The directory the server last searched for a default project. Surfaced in // the "not initialized" error so users can see why detection missed. private defaultProjectHint: string | null = null; @@ -1708,7 +1713,7 @@ export class ToolHandler { * Walks up parent directories to find the nearest .codegraph/ folder, * similar to how git finds .git/ directories. */ - private getCodeGraph(projectPath?: string): CodeGraph { + private async getCodeGraph(projectPath?: string): Promise { if (!projectPath) { if (!this.cg) { const searched = this.defaultProjectHint ?? process.cwd(); @@ -1781,9 +1786,25 @@ export class ToolHandler { const cached = this.projectCache.get(resolvedRoot); if (cached) return this.freshen(cached); - const cg = loadCodeGraph().openSync(resolvedRoot); - this.projectCache.set(resolvedRoot, cg); - return cg; + const generation = this.projectCacheGeneration; + let opening = this.projectOpenPromises.get(resolvedRoot); + if (!opening) { + opening = loadCodeGraph().open(resolvedRoot); + this.projectOpenPromises.set(resolvedRoot, opening); + } + try { + const cg = await opening; + if (generation !== this.projectCacheGeneration) { + try { cg.close(); } catch { /* another waiter may already have closed it */ } + throw new Error('Project cache closed while the database was opening'); + } + this.projectCache.set(resolvedRoot, cg); + return cg; + } finally { + if (this.projectOpenPromises.get(resolvedRoot) === opening) { + this.projectOpenPromises.delete(resolvedRoot); + } + } } /** @@ -1796,9 +1817,9 @@ export class ToolHandler { * stat() and a no-op unless the inode actually changed; it never throws into a * tool call. */ - private freshen(cg: CodeGraph): CodeGraph { + private async freshen(cg: CodeGraph): Promise { try { - if (cg.reopenIfReplaced()) { + if (await cg.reopenIfReplacedAsync()) { process.stderr.write( '[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' @@ -1815,10 +1836,12 @@ export class ToolHandler { * Close all cached project connections */ closeAll(): void { + this.projectCacheGeneration++; for (const cg of this.projectCache.values()) { cg.close(); } this.projectCache.clear(); + this.projectOpenPromises.clear(); this.worktreeMismatchCache.clear(); } @@ -1876,7 +1899,7 @@ export class ToolHandler { * (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never * broken by this check. */ - private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null { + private async worktreeMismatchFor(projectPath?: string): Promise { const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd(); // The verdict depends on BOTH the start path AND the index root it resolves @@ -1890,7 +1913,7 @@ export class ToolHandler { // that first verdict until restart (#926). let indexRoot: string; try { - indexRoot = this.getCodeGraph(projectPath).getProjectRoot(); + indexRoot = (await this.getCodeGraph(projectPath)).getProjectRoot(); } catch { // No resolvable project (or any other resolution error) → nothing to warn. return null; @@ -1913,9 +1936,9 @@ export class ToolHandler { * is no mismatch. `codegraph_status` is excluded — it embeds its own verbose * warning — so it stays out of this path. */ - private withWorktreeNotice(result: ToolResult, projectPath?: string): ToolResult { + private async withWorktreeNotice(result: ToolResult, projectPath?: string): Promise { if (result.isError) return result; - const mismatch = this.worktreeMismatchFor(projectPath); + const mismatch = await this.worktreeMismatchFor(projectPath); if (!mismatch) return result; const notice = worktreeMismatchNotice(mismatch); @@ -2000,12 +2023,12 @@ export class ToolHandler { return stale; } - private withStalenessNotice(result: ToolResult, projectPath?: string): ToolResult { + private async withStalenessNotice(result: ToolResult, projectPath?: string): Promise { if (result.isError) return result; let cg: CodeGraph; try { - cg = this.getCodeGraph(projectPath); + cg = await this.getCodeGraph(projectPath); } catch { return result; // no default project — leave as is } @@ -2180,7 +2203,7 @@ export class ToolHandler { // internal bookkeeping and must never reach the client, whether or not a // caller passed session state. const result = this.takeExploreEmission(raw, sessionState); - const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined); + const withWorktree = await this.withWorktreeNotice(result, args.projectPath as string | undefined); return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined); } catch (err) { // Expected condition, not a malfunction: answer as a SUCCESS so the @@ -2309,7 +2332,7 @@ export class ToolHandler { const query = this.validateString(args.query, 'query'); if (typeof query !== 'string') return query; - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const rawKind = args.kind as string | undefined; // The schema enum says 'type' (what agents naturally reach for); the // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently @@ -2369,7 +2392,7 @@ export class ToolHandler { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const limit = clamp((args.limit as number) || 20, 1, 100); const fileFilter = typeof args.file === 'string' ? args.file : undefined; @@ -2450,7 +2473,7 @@ export class ToolHandler { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const limit = clamp((args.limit as number) || 20, 1, 100); const fileFilter = typeof args.file === 'string' ? args.file : undefined; @@ -2528,7 +2551,7 @@ export class ToolHandler { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const depth = clamp((args.depth as number) || 2, 1, 10); const fileFilter = typeof args.file === 'string' ? args.file : undefined; @@ -3286,7 +3309,7 @@ export class ToolHandler { // ranking all see the same canonical spelling (Erlang `mod:fn/arity`). const query = normalizeQuerySpelling(rawQuery); - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const projectRoot = cg.getProjectRoot(); // Resolve adaptive output budget from project size. Falls back to the @@ -6129,7 +6152,7 @@ export class ToolHandler { * Handle codegraph_node */ private async handleNode(args: Record): Promise { - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); // Default to false to minimize context usage const includeCode = args.includeCode === true; const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined; @@ -6517,7 +6540,7 @@ export class ToolHandler { * Handle codegraph_status */ private async handleStatus(args: Record): Promise { - let cg = this.getCodeGraph(args.projectPath as string | undefined); + let cg = await this.getCodeGraph(args.projectPath as string | undefined); // Same trick as withStalenessNotice — when an explicit projectPath // resolves to the same project as the default session cg, prefer the // default so getPendingFiles() (only populated by the default's watcher) @@ -6536,7 +6559,7 @@ export class ToolHandler { // Queries then reflect that tree's branch, not the worktree being edited. // status shows the verbose, multi-line form; the read tools get the compact // one-liner via withWorktreeNotice. Both share the cached detection. - const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined); + const mismatch = await this.worktreeMismatchFor(args.projectPath as string | undefined); const lines: string[] = [ '**CodeGraph Status**', @@ -6641,7 +6664,7 @@ export class ToolHandler { * Handle codegraph_files - get project file structure from the index */ private async handleFiles(args: Record): Promise { - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const pathFilter = args.path as string | undefined; const pattern = args.pattern as string | undefined; const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';