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
+3 -1
View File
@@ -80,7 +80,7 @@ Event types:
counters named `prompt-hook-gate-<outcome>`, outcome ∈ `high-keyword` /
`high-token` / `medium-segment` / `nudge-projects` / `noop-shape` /
`noop-no-index` / `noop-unverified` / `noop-explore-keyword` /
`noop-explore-token` / `noop-vocab-empty` — decision names only, never
`noop-explore-token` / `noop-vocab-empty` / `noop-rebuild` — decision names only, never
prompt content. This is the gate's measured recall/precision funnel: a
rising `noop-*` share against the `high`/`medium` tiers is the signal that
the gate (keyword table or segment matching) is missing real questions.
@@ -89,6 +89,8 @@ Event types:
`noop-explore-<trigger>` instead (#1143), and a MEDIUM-eligible prompt
hitting a not-yet-backfilled segment vocabulary records `noop-vocab-empty`
rather than polluting `noop-unverified` (#1142).
A prompt that arrives while `codegraph index` rebuilds the project records
`noop-rebuild`: the hook leaves a rebuilding index alone (#2913).
- **`uninstall`** — one per `uninstall`/`uninit` run (churn signal). Props: `targets`.
One legacy field is still *accepted* and belongs in the mirror even though nothing sends