Repository navigation
fix(cli): read commands and the prompt hook open the index read-only - #2936
Merged
Merged
Conversation
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>
colbymchenry
force-pushed
the
claude/fix-readonly-cli-commands
branch
from
October 27, 2026 18:58
8c15aea to
ccee3a7
Compare
This was referenced Oct 27, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Running
codegraph query,files,node,callers,callees,impact,context,exploreoraffectedwhilecodegraph indexrebuilt a project could make the rebuild fail with "database is locked". So could the hiddenprompt-hook, which Claude Code runs on its own for structural prompts, which makes it the likeliest trigger. #2930 fixed exactly this forstatus; 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, soDatabaseConnection.openran 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 withSQLITE_BUSY_SNAPSHOT(517).Fix
openIndexToRead(src/bin/codegraph.ts), opens with{ readOnly: true }forquery,files,node,callers/callees,impact,context,explore,affectedandprompt-hook. Migrations, index recovery and WAL maintenance stay with the writer, as forstatussince fix(cli): codegraph status opens the index read-only, and says when a rebuild is running #2930.rebuild.pid, these commands print one line on stderr, so--jsonstays parseable: the answer comes from a graph still being built. It is the checkstatusmakes (isRebuildInProgress).rebuild.pidis 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).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.QueryBuilder.hasSchemalets each read that needs a later table or column answer as that migration's defaults would. That coversfiles.generated(v9),unresolved_refs.status/name_tail(v8) andname_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 "runcodegraph sync" fallback message.Every
CodeGraph.openinsrc/bin/query,files,node,callers,callees,impact,context,affectedexploreprompt-hook(hidden)statussyncindexrebuild.pid,writer.pidand the index lockinituninit.codegraph/unlock,daemon,install,uninstall,telemetry,upgrade,versionui(hidden behindCODEGRAPH_UI=1)src/ui-server/api/session.ts)serve --mcpValidation (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
queryand a structuralprompt-hooklooping besidecodegraph index, interleaved, at default settings and with the resolution window forced (CODEGRAPH_PARALLEL_RESOLVE_MIN=0):query(SQLITE_BUSY at open)queryquery, 0 of 58 hookquery, 0 of 226 hookMain'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
--jsonvariants 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: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_DAEMONunset: 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 indexstops 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:src/mcp/query-worker.ts,openSync(root)), at pool start and on respawn;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 syncfrom 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.codegraph syncruns 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