mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 19:41:47 +08:00
fix(cli): read commands and the prompt hook open the index read-only (#2936)
Running query, files, node, callers, callees, impact, context, explore or affected while `codegraph index` rebuilt a project could make the rebuild fail with "database is locked", and so could the hidden prompt-hook, which Claude Code runs on its own for structural prompts. Each opened the index read-write, so its open ran the writer's repairs: during the rebuild's bulk load they recreated the secondary indexes the indexer had dropped, and the indexer's next write, whose transaction had already read, failed with SQLITE_BUSY_SNAPSHOT (517). PR #2930 fixed the same thing for status. These commands now open the index read-only, through one helper, and leave migrations, index recovery and WAL maintenance to the writer: - query, files, node, callers, callees, impact, context, explore, affected and prompt-hook are read-only. explore and node run the MCP tool handler in process without a lifecycle, so they never sync or open another project. - While another live process holds rebuild.pid they say so in one line on stderr (a --json answer stays parseable), as status does. - The prompt hook stays out of a running rebuild altogether: the index is half-built until it ends, the MCP tools answer "rebuild in progress" meanwhile, and not opening it keeps the hook from holding the file the rebuild replaces. Its gate records the new outcome noop-rebuild. - The hook's one write, filling an empty segment vocabulary (issue #1142), goes through a writer connection of its own, opened only once it holds the index lock, which every index and sync holds while it writes. - status (read-only since PR #2930), sync, index, init and uninit are unchanged; the last four write. A read-only open migrates nothing. QueryBuilder.hasSchema lets each read that needs a later table or column answer as that migration's defaults would: files.generated (v9), unresolved_refs.status and name_tail (v8), and name_segment_vocab (v7). The status pending count from PR #2930 uses it too. Validation (Windows 11, Node 22.22): - The issue's interleaving, each command in another process inside the writer's edge transaction: on main all 14 command forms make the insert fail with errcode 517 and bring the 5 dropped edge indexes back; with this change all 14 inserts succeed and the indexes stay dropped. - Real rebuilds of excalidraw 95307fa1 (736 files) with query and the prompt hook looping beside them, default settings and with the resolution window forced: main failed 6 of 14 ("store worker: database is locked" with an empty index, or "database is locked" partway through resolution), and 12 of its 109 query runs failed at open with SQLITE_BUSY. This change failed 0 of 14, with 0 failures in 299 query and 284 hook runs and the same graph every time (14,236 nodes, 61,210 edges). - Old indexes: 24 command forms on indexes at schemas 4 to 16 (11 schemas rebuilt from a current index, plus indexes built by 0.9.9, 1.4.0, 1.5.0, 1.6.0 and 1.6.2): output identical to main's, which migrates first, 384 of 384. The schema stays as it was, except where the hook fills the vocabulary of a pre-v7 index; that writer's open migrates it, as every read-write open did. - Timing on excalidraw at rest, 22 interleaved runs per command: medians within -5.8% to +3.3%, -0.9% in total. - read-commands-during-rebuild.test.ts: 38/38 pass. With main's sources 35 fail; with only the CLI opens reverted 33, only the schema fallbacks 19, only the read-only vocabulary fill 2. Follow-up to PR #2930. Fixes #2913 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cbd9817f7c
commit
475e56b209
+46
-20
@@ -378,6 +378,32 @@ function warn(message: string): void {
|
||||
console.log(chalk.yellow(getGlyphs().warn) + ' ' + message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a project's index for a command that only reads it: `query`,
|
||||
* `explore`, `node`, the prompt hook and the rest (#2913).
|
||||
*
|
||||
* Opened read-write, the index runs the writer's repairs: migrations, the
|
||||
* rebuild of the indexes and search triggers an interrupted bulk load left
|
||||
* out, and WAL maintenance. While `codegraph index` or a sync writes in
|
||||
* another process, those repairs recreate the indexes its bulk load dropped,
|
||||
* and its next write fails with SQLITE_BUSY_SNAPSHOT, "database is locked".
|
||||
* Read-only, a command leaves all of that to the writer, as `status` does
|
||||
* since #2930; an index an older release built is read as it is (see
|
||||
* QueryBuilder.hasSchema).
|
||||
*
|
||||
* During a rebuild the answer comes from a graph still being built, so the
|
||||
* command says so, on stderr to keep a `--json` answer parseable.
|
||||
*/
|
||||
async function openIndexToRead(projectPath: string): Promise<import('../index').CodeGraph> {
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath, { readOnly: true });
|
||||
const { isRebuildInProgress } = await import('../mcp/writer-lock');
|
||||
if (isRebuildInProgress(projectPath)) {
|
||||
console.error(`${chalk.yellow(getGlyphs().warn)} An index rebuild ("codegraph index") is running in another process — this answer comes from the part of the index it has rebuilt so far.`);
|
||||
}
|
||||
return cg;
|
||||
}
|
||||
|
||||
/** "not found" (+ optional did-you-mean) when no exact symbol matches. */
|
||||
function formatSymbolNotFound(symbol: string, fuzzyNames: string[]): string {
|
||||
const suggestions = [...new Set(fuzzyNames.filter((n) => n !== symbol))].slice(0, 3);
|
||||
@@ -1344,8 +1370,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const cg = await openIndexToRead(projectPath);
|
||||
|
||||
const limit = parseInt(options.limit || '10', 10);
|
||||
const rawResults = cg.searchNodes(search, {
|
||||
@@ -1430,8 +1455,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const cg = await openIndexToRead(projectPath);
|
||||
const { ToolHandler } = await import('../mcp/tools');
|
||||
const handler = new ToolHandler(cg);
|
||||
|
||||
@@ -1487,8 +1511,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const cg = await openIndexToRead(projectPath);
|
||||
|
||||
const result = await cg.buildContext(taskParts.join(' '), {
|
||||
format,
|
||||
@@ -1592,8 +1615,15 @@ program
|
||||
`${lead}\n${projects.map((p) => ` - projectPath: "${p}"`).join('\n')}\n`;
|
||||
|
||||
if (plan.exploreRoot) {
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(plan.exploreRoot);
|
||||
// A `codegraph index` in another process is rebuilding this index: it
|
||||
// holds part of the graph until the rebuild ends, and the rebuild
|
||||
// replaces the file, which an open handle blocks on Windows. Inject
|
||||
// nothing, as the MCP tools answer "rebuild in progress" (#2913).
|
||||
const { isRebuildInProgress } = await import('../mcp/writer-lock');
|
||||
if (isRebuildInProgress(plan.exploreRoot)) { gate('noop-rebuild'); return; }
|
||||
// Claude Code runs this hook on its own, on every prompt: read-only, so
|
||||
// it never repairs what an index or sync is in the middle of writing.
|
||||
const cg = await openIndexToRead(plan.exploreRoot);
|
||||
try {
|
||||
const others = plan.nudgeProjects.length
|
||||
? `\n${nudge(plan.nudgeProjects, 'Other indexed projects in this workspace — pass projectPath to query them:')}`
|
||||
@@ -1644,8 +1674,9 @@ program
|
||||
// (#1142). Heal it here: on a populated vocab this is one SELECT;
|
||||
// the actual backfill is a one-time batched pass whose cost the MCP
|
||||
// server's own catch-up sync usually pays first (it runs at every
|
||||
// session start). A distinct noop outcome keeps a dormant vocab
|
||||
// from polluting the noop-unverified recall signal.
|
||||
// session start). It writes through a connection of its own, under
|
||||
// the index lock (#2913). A distinct noop outcome keeps a dormant
|
||||
// vocab from polluting the noop-unverified recall signal.
|
||||
const vocabReady = await cg.healSegmentVocabIfEmpty().catch(() => false);
|
||||
if (!vocabReady) { gate('noop-vocab-empty'); return; }
|
||||
const related = cg.getSegmentMatches(proseWords);
|
||||
@@ -1716,8 +1747,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const cg = await openIndexToRead(projectPath);
|
||||
const { ToolHandler } = await import('../mcp/tools');
|
||||
const handler = new ToolHandler(cg);
|
||||
|
||||
@@ -1788,8 +1818,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const cg = await openIndexToRead(projectPath);
|
||||
let files = cg.getFiles();
|
||||
|
||||
if (files.length === 0) {
|
||||
@@ -2361,8 +2390,7 @@ for (const direction of ['callers', 'callees'] as const) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const cg = await openIndexToRead(projectPath);
|
||||
try {
|
||||
const limit = parseInt(options.limit || '20', 10);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol, goPackageReaderFor(cg));
|
||||
@@ -2485,8 +2513,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const cg = await openIndexToRead(projectPath);
|
||||
try {
|
||||
const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol, goPackageReaderFor(cg));
|
||||
@@ -2626,8 +2653,7 @@ program
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const cg = await openIndexToRead(projectPath);
|
||||
const maxDepth = parseInt(options.depth || '5', 10);
|
||||
|
||||
// Custom filter pattern
|
||||
|
||||
Reference in New Issue
Block a user