Skip to content

fix(cli): read commands and the prompt hook open the index read-only - #2936

Merged
colbymchenry merged 1 commit into
mainfrom
claude/fix-readonly-cli-commands
Oct 27, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/fix-readonly-cli-commands

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Summary

Running codegraph 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". So could the hidden prompt-hook, which Claude Code runs on its own for structural prompts, which makes it the likeliest trigger. #2930 fixed exactly this for status; this is the follow-up it promised on #2913.

These commands now open the index read-only. While a rebuild runs they say so on stderr, and the prompt hook stays out of it altogether. They still read an index an older release built, without migrating it.

Root cause

Each command called CodeGraph.open(projectPath) read-write, so DatabaseConnection.open ran the writer's open-time repairs: migrations, recovery of the FTS triggers and secondary indexes an interrupted bulk load leaves out, and WAL maintenance. During a rebuild's bulk load, that recovery recreated the indexes the indexer had dropped. The indexer's next write, whose transaction had already read, then failed with SQLITE_BUSY_SNAPSHOT (517).

Fix

  • One helper, openIndexToRead (src/bin/codegraph.ts), opens with { readOnly: true } for query, files, node, callers/callees, impact, context, explore, affected and prompt-hook. Migrations, index recovery and WAL maintenance stay with the writer, as for status since fix(cli): codegraph status opens the index read-only, and says when a rebuild is running #2930.
  • A rebuild is named. While another live process holds rebuild.pid, these commands print one line on stderr, so --json stays parseable: the answer comes from a graph still being built. It is the check status makes (isRebuildInProgress).
  • The prompt hook stays out of a rebuild. It returns before opening anything while rebuild.pid is held. Until the rebuild ends the index is half-built, and the MCP tools answer "rebuild in progress" meanwhile. An open handle would also block the rebuild's file replacement on Windows. Its gate records a new outcome, noop-rebuild (docs/design/telemetry.md).
  • The hook's one write stays safe. On first use it fills an empty segment vocabulary (prompt-hook opens CodeGraph without sync, so the MEDIUM (segment-vocab) gate tier stays permanently empty on any repo indexed before v1.2.0 #1142). That fill now goes through a writer connection of its own, opened only once it holds the index lock (codegraph.lock). Every index and sync holds that lock while it writes, bulk loads included. While someone else holds it, the hook writes nothing, as before.
  • Old indexes still read. A read-only open migrates nothing, so QueryBuilder.hasSchema lets each read that needs a later table or column answer as that migration's defaults would. That covers files.generated (v9), unresolved_refs.status/name_tail (v8) and name_segment_vocab (v7). fix(cli): codegraph status opens the index read-only, and says when a rebuild is running #2930's pre-v8 check in the pending-reference count now uses the same helper. Every schema a release has written (4 to 11) reads the same as main after its migration (below), so no command needs the "run codegraph sync" fallback message.

Every CodeGraph.open in src/bin/

Command Mode now Why
query, files, node, callers, callees, impact, context, affected read-only (was read-write) they only read
explore read-only (was read-write) it runs the MCP tool handler in process with no lifecycle, so it never syncs or opens another project
prompt-hook (hidden) read-only (was read-write), and no open at all during a rebuild it only reads; its vocabulary fill writes through its own connection under the index lock
status read-only (since #2930) unchanged
sync read-write writes, under the index lock
index recreates the database writes; holds rebuild.pid, writer.pid and the index lock
init creates the database writes
uninit read-write, then deletes .codegraph/ removes the index; unchanged
unlock, daemon, install, uninstall, telemetry, upgrade, version no open —
ui (hidden behind CODEGRAPH_UI=1) read-write (src/ui-server/api/session.ts) not changed here: unreleased, and it relies on the open's migrations. Same exposure; a follow-up
serve --mcp MCP engine out of scope (below)

Validation (Windows 11, Node 22.22)

  • The issue's interleaving, every command. The writer drops the 5 edge indexes for a bulk load and reads its endpoints inside the edge transaction. The command then runs in another process, and the writer inserts. On main (2afee00; nothing since touches these paths) all 14 command forms, including three hook tiers, make the insert fail with errcode 517 and bring the indexes back (1 → 6). With this change all 14 inserts succeed and the indexes stay dropped.

  • Real rebuilds of excalidraw 95307fa1 (736 files) with query and a structural prompt-hook looping beside codegraph index, interleaved, at default settings and with the resolution window forced (CODEGRAPH_PARALLEL_RESOLVE_MIN=0):

    rebuilds failed reader runs failed
    main, 1 reader loop 1 of 6 4 of 31 query (SQLITE_BUSY at open)
    main, 3 reader loops 5 of 8 8 of 78 query
    this change, 1 loop 0 of 6 0 of 63 query, 0 of 58 hook
    this change, 3 loops 0 of 8 0 of 236 query, 0 of 226 hook

    Main's failures were "store worker: database is locked", which left an empty index, or "database is locked" partway through resolution. Every rebuild on this change produced the same graph: 14,236 nodes, 61,210 edges. The hook stayed silent during each rebuild; its 7 answers came after the rebuild had released its lock.

  • Old indexes. I ran 24 command forms, including the --json variants and three hook tiers, on indexes at 11 schemas from 4 to 16, cut back from a current index, and on indexes built by 0.9.9, 1.4.0, 1.5.0, 1.6.0 and 1.6.2. The output matched main's (main migrates first) in 384 of 384, all with exit 0. The schema stays as it was, except where the hook fills a pre-v7 index's vocabulary: that writer's open migrates it, as every read-write open did. Before the fallbacks, 14 of 24 forms broke on schemas 7 and 8, and 15 on schemas 4 and 6: 12 exited with "no such column: generated", and the hook answered nothing.

  • Timing on excalidraw at rest, interleaved, 22 runs per command per arm: medians from −5.8% to +3.3% per command, −0.9% in total. Output is byte-identical.

  • Tests: __tests__/read-commands-during-rebuild.test.ts, 38 tests:

    • every command inside the writer's edge transaction (a rebuild's locks, or a sync's for the hook);
    • the hook staying out of a rebuild;
    • each command on schema-7 and schema-4 indexes;
    • each guarded read on its own;
    • the hook's vocabulary fill, with the index lock held and free.

    38/38 pass. With main's sources 35 fail. With only the CLI opens reverted 33 fail, with only the schema fallbacks reverted 19, and with only the read-only vocabulary fill reverted 2.

  • Related suites (status-during-rebuild, segment-vocab, frontload-hook, the cli-* read commands, dead-code, generated-detection, explore-empty-diagnostics, orphaned-refs-sweep, status-json and the sqlite backends): 21 files, 264 tests pass.

  • Full suite on this commit (ccee3a7, on main c306967), kernel built, CODEGRAPH_KERNEL_EXPECT=1, CODEGRAPH_NO_DAEMON unset: 771 files (765 passed, 6 skipped), 9,213 tests: 9,128 passed, 0 failed, 85 skipped.

The MCP server's read path (not changed)

It has the same exposure in a few places. The daemon's own connection is the writer: it holds writer.pid, codegraph index stops the daemon before rebuilding, and engine opens refuse during a rebuild (assertNoRebuild). But some of its reads open read-write and run the same open-time repairs:

  • the query-pool workers (src/mcp/query-worker.ts, openSync(root)), at pool start and on respawn;
  • the index-freshness worker behind codegraph_status (src/mcp/index-freshness-worker.ts), on every measurement;
  • freshen()'s reopen of a database replaced on disk (POSIX only), which keeps the instance's mode.

A daemon that does one of these while another process is in a bulk load, such as a large codegraph sync from the git hook, can break that writer the same way. Opening those workers read-only would be the follow-up.

Left out

  • codegraph ui, as above.
  • A writer's own open still runs the repairs before it takes the index lock. So two writers can collide the same way, for example two quick codegraph sync runs during a large sync's bulk load. Fixing that means moving the repairs under the lock, which is outside this change.

Follow-up to #2930 (#2913).

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant