Skip to content

cli: warm-CLI daemon — proxy query commands to a per-project warm daemon (MCP-parity latency + RSS)#525

Merged
justrach merged 10 commits into
release/0.2.5824from
feat/cli-warm-daemon
Jun 3, 2026
Merged

cli: warm-CLI daemon — proxy query commands to a per-project warm daemon (MCP-parity latency + RSS)#525
justrach merged 10 commits into
release/0.2.5824from
feat/cli-warm-daemon

Conversation

@justrach

@justrach justrach commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Summary

Makes the codedb CLI as fast as the warm codedb mcp daemon for read-only queries, without the per-invocation snapshot reload. Agents reach for the CLI over the codedb_* 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)

  • Factored read-only query commands into runQuery(...) u8 (returns an exit code, never std.process.exit); Out.sink lets the daemon capture and reuse the identical rendering.
  • CLI query commands connect to /tmp/codedb-<uid>-<wyhash(abs_root)>.sock (0600), send [u8 color][u32 len][NUL-joined argv], daemon runs runQuery and frames back [u8 code][u32 len][bytes]. Connect-fail → cold fallback.
  • A cold query with no daemon auto-spawns a detached 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_DAEMON suppresses.

CLI⇄MCP parity bridge (9e38633, 976c50f)

  • 7 read-only navigation commands now reachable from the CLI (symbol, callers, deps, glob, ls, file, context) via runCliTool in mcp.zig, which builds the MCP arg map and calls the existing handlers — zero logic duplication.

P0 crash fix (e211659)

  • writeSnapshot panicked ("integer does not fit") casting symbol-name / import / detail lengths to u16 on minified/generated files. Truncate each to maxInt(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)

  • Word-index disk load skips the file_words map (queries/BM25 never use it) and is now a zero-copy mmap (mmapFromDisk): a small sorted word_dir + doc-length table; postings stay in the mmap and are returned as []align(1) const WordHit. First write calls promoteIfBorrowed. Pre-size per-file symbol/import lists on snapshot load.
  • The cli-daemon now warms the trigram and word index at startup (both mmap-backed) so proxied search no longer rescans content per call (was ~31 ms) and word/context don'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)

cmd MCP CLI gap
search 1.7 ms 5.2 ms +3.5
callers 7.2 ms 11.0 ms +3.9
context 88.5 ms 92.1 ms +3.6
word 0.15 ms 3.0 ms +2.8
symbol 9.1 ms 12.2 ms +3.2
find 262 ms 5.1 ms CLI faster

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_find runs fuzzyScore over all ~25k symbols (~262 ms on openclaw) while the CLI find path returns in ~5 ms — a 30–50× win available by porting the fast path into handleFind.

🤖 Generated with Claude Code

justrach and others added 10 commits June 3, 2026 00:40
…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>
@justrach justrach merged commit 7c13b92 into release/0.2.5824 Jun 3, 2026
1 check passed
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

Benchmark Regression Report

Thresholds: 10.00% and 50,000 ns absolute delta

NOISE means the percentage threshold was exceeded, but the absolute delta was too small to fail CI.

Tool Base (ns) Head (ns) Delta Abs Delta (ns) Status
codedb_bundle 92192 90856 -1.45% -1336 OK
codedb_changes 14069 10034 -28.68% -4035 OK
codedb_context 1139831 1123739 -1.41% -16092 OK
codedb_deps 253 239 -5.53% -14 OK
codedb_edit 47560 46162 -2.94% -1398 OK
codedb_find 10308 10653 +3.35% +345 OK
codedb_hot 23783 25605 +7.66% +1822 OK
codedb_outline 33832 34121 +0.85% +289 OK
codedb_read 15259 15371 +0.73% +112 OK
codedb_search 29696 28182 -5.10% -1514 OK
codedb_snapshot 63875 70011 +9.61% +6136 OK
codedb_status 9823 9079 -7.57% -744 OK
codedb_symbol 20448 18806 -8.03% -1642 OK
codedb_tree 38031 37179 -2.24% -852 OK
codedb_word 11376 11395 +0.17% +19 OK

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/main.zig
// 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| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

justrach added a commit that referenced this pull request Jun 4, 2026
…, 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>
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