cli: warm-CLI daemon — proxy query commands to a per-project warm daemon (MCP-parity latency + RSS)#525
Conversation
…I daemon) Extract tree/outline/find/search/word/read/hot from mainImpl's inline dispatch into a top-level runQuery(... out: *Out, s) u8 that returns an exit code instead of calling std.process.exit. No behavior change for the cold CLI: it calls runQuery, then flushes out and exits with the returned code. This lets the identical rendering run inside a warm daemon writing to a socket — the foundation for routing CLI query commands to a warm daemon instead of reloading the snapshot on every invocation (so CLI latency approaches the warm MCP daemon's ~1-5ms instead of the ~10-130ms snapshot rebuild). zig build test 699/699. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cket When a codedb daemon (mcp/serve) is running for the project, the CLI's read-only query commands (tree/outline/find/search/word/read/hot) connect to a per-project Unix socket (/tmp/codedb-<uid>-<hash>.sock, 0600) and stream back the daemon's rendered output instead of reloading the snapshot in-process. The daemon runs the same runQuery against its warm index, captures the output via Out.sink, and frames [code][len][bytes] back; the client streams it to stdout and exits with the code. On any connect failure (no daemon) the client returns null and falls through to the existing cold in-process load — behavior and output unchanged. The listener (cliDaemonListen) is spawned detached in both the serve and mcp daemons; a bind failure just logs and the daemon keeps serving its primary API. Transport is blocking libc Unix sockets via std.c (std.posix dropped the socket syscalls in 0.16); runQuery still gets the daemon's real io. Measured on codedb's own 664-file index (ReleaseFast): `codedb find` 19.6ms cold -> 2.8ms proxied (~7x), in the warm MCP daemon's range. Output byte-identical modulo the cold-only "loaded snapshot" status line and the live duration token. No auto-spawn yet: a daemon must be running (codedb mcp / serve). zig build test 699/699. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a CLI query command finds no daemon listening, it spawns a detached, lightweight `cli-daemon` for the project (posix_spawn, stdio -> /dev/null, setsid) so the NEXT call is warm. cli-daemon is `serve` minus the TCP server: it loads the snapshot, runs the watcher + the Unix-socket listener, and exits after an idle timeout (default 5min; CODEDB_CLI_DAEMON_IDLE_MS overrides) so it self-cleans. A duplicate that loses the socket bind race exits promptly. Suppress with CODEDB_NO_CLI_DAEMON. No TCP port -> no cross-project conflict. End-to-end verified: a cold call spawns the daemon (socket up within ~250ms), the next call is the warm proxied path, idle shutdown removes the daemon + socket, and the suppress env disables spawning. zig build test 699/699. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
writeSnapshot cast symbol-name, import, and detail lengths to u16; files whose parser produces identifiers longer than 65535 bytes (minified/generated bundles) paniced with "integer does not fit in destination type". Truncate each to maxInt(u16) before the length write. Real-world repro: indexing crawlspherev11, which now exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WordIndex.readFromDisk built a file_words map (path -> contributed words) plus a transient tmp_file_words peak that queries and BM25 never read — only incremental removeFile needs it. Produce a skip_file_words index (the mode the bulk scan already uses; removeFile early-exits gracefully) and pre-size index/path_to_id/ doc_lengths. id_to_path owns the path strings now, freed via the skip_file_words deinit path. openclaw (13.6k files) context RSS: 547->380MB warm CLI, 516->384MB MCP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add symbol/callers/deps/glob/ls/file/context CLI commands. runQuery's fallthrough delegates to mcp.runCliTool, which builds the MCP argument map and reuses the existing handlers against the warm Explorer (no logic duplication). cliIsQueryCmd, isCommand, and the cold dispatch learn the new commands; the cold path now passes abs_root so it matches the warm proxy. callers loads the mmap trigram (not the heap word index) to keep its footprint at the MCP level. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… RSS ballooning WordIndex gains a zero-copy read mode. mmapFromDisk maps the word.index file and builds only a small sorted word directory + doc-length table; the postings stay in the mmap. search/searchPrefix binary-search the sorted on-disk words and return the postings as []align(1) const WordHit (records sit at unaligned byte offsets). The first write promotes the borrowed index to a heap copy (promoteIfBorrowed), so reads stay zero-copy and only an actual edit pays the cost. Loaders prefer mmapFromDisk with a heap readFromDisk fallback. openclaw (13.6k files) context RSS: 380->191MB warm CLI, 516->195MB MCP. The word index now adds ~43MB (directory + touched pages) instead of ~400MB of heap, while search/word/context/BM25 results are unchanged. 699 tests + a new zero-copy-vs-heap parity & promote test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadOutlineStateMap grew each file's symbols/imports ArrayLists by doubling (the maps it fills are already pre-sized — see the comment a few lines down — but the per-file lists weren't). Pre-size them to the known counts and appendAssumeCapacity; likewise the deps list in rebuildDepsFromOutline. Cuts the snapshot load ~7% (openclaw 13.6k files: load median 23.8 -> 22.2ms, measured interleaved to control for macOS E-core scheduling noise). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it the exec floor The warm cli-daemon loaded the snapshot but not the trigram or word index, so a proxied `search` did a full content scan per call (~31ms on openclaw) and the first `word`/`context` lazily rebuilt the word index on the heap (~200ms + a multi-hundred-MB RSS balloon). Warm both up front, mmap-backed: - trigram gate now fires for `cli-daemon` (matches serve/mcp) — proxied search/callers no longer rescan content. - word gate now fires for `cli-daemon` but skips the rebuild-if-incomplete branch: the daemon mmap-loads the on-disk index (zero-copy) and, on a miss, defers to a lazy rebuild on first query rather than holding a heap rebuild at startup. Measured on openclaw (interleaved A/B vs a warm MCP, idle machine): - search 31ms -> exec floor (MCP 1.7ms, CLI 5.2ms = +3.5ms launch floor) - every command is MCP-cost + ~3ms irreducible process-launch floor - fresh-daemon RSS 207-214MB vs MCP 211MB (mmap working set, bounded) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benchmark Regression ReportThresholds: 10.00% and 50,000 ns absolute delta
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fb8be0442c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // on this same stack frame for the whole process lifetime. | ||
| var cli_activity = std.atomic.Value(i64).init(cio.milliTimestamp()); | ||
| var cli_listener_dead = std.atomic.Value(bool).init(false); | ||
| if (std.Thread.spawn(.{}, cliDaemonListen, .{ io, allocator, &explorer, &store, abs_root, &cli_activity, &cli_listener_dead })) |cli_t| { |
There was a problem hiding this comment.
Defer CLI proxy until MCP scan is ready
When codedb <root> mcp starts without a usable snapshot, this branch starts scanBg with scan_done=false and then immediately exposes the new CLI proxy listener. During that initial scan window, a normal codedb <root> search/read/symbol ... invocation can proxy into the MCP process and run against an empty or partially populated Explorer, returning incomplete/no results instead of falling back to the cold path that would index synchronously. Please gate the proxy on scan readiness or make cliServeConn reject while the MCP scan state is not ready so clients fall back.
Useful? React with 👍 / 👎.
…, CLI hardening, ReScript - release_info.semver 0.2.5823 -> 0.2.5824 (the version codedb reports and update.zig compares against; build.zig.zon was already 0.2.5824). - CHANGELOG: document the warm CLI daemon (#525), faster fuzzy find (#526), CLI hardening (#529), ReScript .res/.resi (#532), and audit fixes (#530) alongside the existing code-graph / snapshot-load sections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Makes the
codedbCLI as fast as the warmcodedb mcpdaemon for read-only queries, without the per-invocation snapshot reload. Agents reach for the CLI over thecodedb_*MCP tools, but the CLI used to reload the snapshot every call (~10–130 ms). This branch adds a per-project warm daemon that CLI query commands proxy to over a Unix socket, plus the index/RSS work needed to make the daemon match the MCP's footprint.What's in it
Warm daemon + proxy (2bbc413, 37faafe, 3d631c8)
runQuery(...) u8(returns an exit code, neverstd.process.exit);Out.sinklets the daemon capture and reuse the identical rendering./tmp/codedb-<uid>-<wyhash(abs_root)>.sock(0600), send[u8 color][u32 len][NUL-joined argv], daemon runsrunQueryand frames back[u8 code][u32 len][bytes]. Connect-fail → cold fallback.cli-daemon(serve minus the TCP server) so the next call is warm. Idle watchdog self-cleans (CODEDB_CLI_DAEMON_IDLE_MS, default 5 min);CODEDB_NO_CLI_DAEMONsuppresses.CLI⇄MCP parity bridge (9e38633, 976c50f)
symbol,callers,deps,glob,ls,file,context) viarunCliToolin mcp.zig, which builds the MCP arg map and calls the existing handlers — zero logic duplication.P0 crash fix (e211659)
writeSnapshotpanicked ("integer does not fit") casting symbol-name / import / detail lengths to u16 on minified/generated files. Truncate each tomaxInt(u16)before the length write. Repro repo (crawlspherev11) now exits 0. Regression test indexes a 70k-char identifier.RSS: match the MCP footprint (efd01d1, 747e342, 8d4d3f0, fb8be04)
file_wordsmap (queries/BM25 never use it) and is now a zero-copy mmap (mmapFromDisk): a small sortedword_dir+ doc-length table; postings stay in the mmap and are returned as[]align(1) const WordHit. First write callspromoteIfBorrowed. Pre-size per-file symbol/import lists on snapshot load.searchno longer rescans content per call (was ~31 ms) andword/contextdon't pay a heap rebuild on first use. The cli-daemon is excluded from the rebuild-if-incomplete branch so it stays lean (defers to a lazy first-query rebuild on a miss).Verified (openclaw 670-file index, interleaved A/B vs a warm
codedb mcp, medians of 21)Every command is MCP query-cost + a flat ~3 ms irreducible process-launch floor — the only gap is the exec the persistent MCP never pays. RSS: fresh cli-daemon 207→214 MB across 120 mixed queries vs MCP 211 MB — matched and bounded (mmap working-set fault-in plateaus, no unbounded leak).
Tests
699/699 green (
zig build test). New tests: cli-mcp parity bridge, p0 u16-overflow snapshot, zero-copy mmap word-index parity (mmap == heap load + promote-on-write).Follow-up (separate PR)
MCP
codedb_findrunsfuzzyScoreover all ~25k symbols (~262 ms on openclaw) while the CLIfindpath returns in ~5 ms — a 30–50× win available by porting the fast path intohandleFind.🤖 Generated with Claude Code