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:
Colby Mchenry
2026-10-27 22:26:03 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cbd9817f7c
commit 475e56b209
6 changed files with 517 additions and 42 deletions
+46 -20
View File
@@ -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