Repository navigation
feat(daemon): opt-in shared-mode install, status/recovery UX, benchmarks and acceptance gates (#19) - #50
Conversation
- enableSharedMode/disableSharedMode in claude-settings.ts: switch the Claude Code statusLine to the IPC client wrapper, remember the previous one-shot command in ccstatusline settings, restore it verbatim on return - daemon install / daemon uninstall subcommands drive the switch; the daemon is started only by these explicit commands, never from the render path (the wrapper fails clean with empty stdout when it is down) - daemon status now reports uptime, last render time, active renders and aggregate counters from /v1/health — identity and timings, no secrets - ship client/ccstatusline-ipc in the npm package (files += client/) Part of #19 Co-Authored-By: Claude Code <noreply@anthropic.com>
#19) - daemon-shared-mode.test.ts: enable/disable round-trip, previous-command memory, idempotent enable, unreadable-settings refusal, Windows refusal, wrapper resolution and command classification - lifecycle status test: uptime/counters surfaced, no token in the payload - benchmark-render.py: daemon-bench compares one-shot vs shared (cold/warm, 1/10/36/50 sessions, small/large, slow-command), records daemon health counters, and proves recovery: SIGKILL, dead-daemon wave must fail clean (rc!=0, empty stdout), restart, byte-identical re-renders. Aggregate CPU covers daemon + all clients (reaped children) - docs: USAGE.md daemon section, WINDOWS.md one-shot scope note - fix: isExecutableAvailable passes explicit env to execSync (bun ignores runtime process.env mutations otherwise) Part of #19 Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow
left a comment
There was a problem hiding this comment.
Review: PR #50 — daemon opt-in install, status/recovery UX, benchmarks
Verdict: changes requested. The overall shape is good: the opt-in gate is real (nothing on the render path starts a daemon), the enable/disable round-trip is well tested with verbatim restore, isKnownCommand/classifyInstallation integration with #48 is consistent, and the bench harness honestly splits cold/warm and folds daemon + clients into aggregate CPU via reaped children. All findings below are on failure paths — but one of them silently destroys user config, so I'm asking for changes.
Findings
-
[medium] enableSharedMode overwrites a corrupt settings.json with defaults — src/utils/claude-settings.ts:601.
loadSettings()returns in-memory defaults on invalid JSON (codebase convention: "file left unchanged"), andsaveSettings(configSettings)then silently rewrites the user's ccstatusline settings.json with defaults plus the daemonSharedMode marker — the whole user config is lost. The same input class right next to it (unreadable Claude settings) is correctly refused. Fix: useloadSettingsFrom/check the load error and refuse, mirroring the Claude-settings refusal. -
[low] disableSharedMode clears the restore memory before restoring the statusLine — src/utils/claude-settings.ts:643. If
saveClaudeSettings(claude)fails aftersaveSettings(rest), thedaemonSharedModemarker is already gone, the statusLine still points at the wrapper, and a retry ofdaemon uninstallanswers "shared mode was not enabled" — no tool-driven way back. Reversing the order (restore Claude settings first, then clear the memory) makes the failure path safe: a stale marker just repeats an idempotent restore. -
[low]
daemon uninstallwith unreadable Claude settings prints a false "off" and darkens the status line — src/daemon/lifecycle.ts:762. When the reason is "could not read Claude settings; refusing to modify", the restore did not happen and the statusLine still holds the wrapper, yet the message is "shared mode off (nothing to restore: ...)" and the daemon is stopped — the status line goes quiet while being told it is off. Distinguish "not enabled" (correct case) from "restore refused", and skip the daemon stop when the restore was refused. -
[low]
daemon install: a daemon-start failure does not say the statusLine was already switched — src/daemon/lifecycle.ts:752. IfenableSharedModesucceeded andensureDaemonthrows, the catch prints onlyccstatusline daemon install: <error>and exits 1, while the statusLine already points at the wrapper (currently dark). At minimum extend the message ("statusLine switched; run 'ccstatusline daemon start' or 'daemon uninstall'"); better, roll the switch back when the start fails. -
[low] The bench recovery proof does not gate — scripts/benchmark-render.py:388.
clients_that_still_succeeded,failed_clients_stdout_emptyandoutput_mismatchesare only reported;cmd_daemon_bench's exit code depends solely on run errors and the CPU gate. Since the scenario is billed as a "deterministic recovery proof", a client succeeding against the dead daemon or a hash mismatch should produce failed=1 / exit != 0 (or a dedicated flag), otherwise DAEMON-BENCH.md can say PASS with a violated invariant.
Checked and sound
isSharedModeCommand/classifyInstallation: the wrapper is recognized as ours; self-managed classification is consistent with #48 behavior.getDaemonClientWrapperPathresolves correctly for a dev checkout (src/utils to repo root) and the npm dist layout (--format=esmkeepsimport.meta.urlworking; package root is within the 5-level walk), andclient/is added to packagefiles.- Status fields (
uptimeSeconds,lastRenderMs,activeRenders,counters) match/v1/healthin server.ts; no token in the payload and a test pins that. - Windows refusals:
assertIpcPlatformplus the win32 check inenableSharedModeback the WINDOWS.md claims. - Bench bookkeeping: one-shot/shared label pairing lines up, the recovery scenario compares warm hashes against post-restart re-renders correctly, and the RUSAGE_CHILDREN accounting is fair to both sides.
Review fixes on PR #50: enableSharedMode refuses a corrupt ccstatusline settings.json instead of overwriting it with defaults; disableSharedMode restores the status line before clearing the restore memory, so a failed restore write can be retried; 'daemon uninstall' distinguishes a refused restore from 'not enabled' (no daemon stop, exit 1 on refusal); 'daemon install' rolls the switch back when the daemon start fails and this run did the switch, so the user is not stranded on a dark wrapper status line. Co-Authored-By: Claude Code <noreply@anthropic.com>
…#19) client/ccstatusline-ipc: a 503 busy answer (render queue momentarily full under a burst of repaints across sessions) is now retried up to 8 times with a 50ms backoff — the same thing the next Claude Code repaint tick would do. The payload is buffered to a temp file once so retries re-send identical stdin; stdout still stays empty on any final failure. Without this, 10+ concurrent sessions with distinct payloads lost status lines whenever their repaints collided (maxInFlight guard answers busy). benchmark-render.py: the recovery scenario is now a gated proof — a client succeeding against the dead daemon, a non-empty stdout on the dead wave, or any post-restart output mismatch fails the whole run (exit != 0), not just the report. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Addressed all five findings in 45f4f56 + f659990 (threads resolved with per-finding replies):
Bonus, found while running the acceptance benchmark: with 10+ concurrent sessions the render queue (
|
axisrow
left a comment
There was a problem hiding this comment.
Review: PR #50 (update) — fix commits 45f4f56 + f659990
Verdict: approved. Both fix commits address every finding from the previous review (5360702453), and the new client retry code is sound. Nothing blocking remains.
Previous findings — all resolved
- [medium, fixed] enableSharedMode now loads via loadSettingsFrom and refuses on a non-null loadError (claude-settings.ts:601-606) instead of saving in-memory defaults over a corrupt settings.json; disableSharedMode refuses the same way. Both refusals are pinned by new tests, including the assertion that the corrupt file is left byte-identical.
- [low, fixed] disableSharedMode restores the Claude statusLine before clearing the daemonSharedMode marker (claude-settings.ts:655-660), so a failed restore write leaves a retryable state instead of stranding the wrapper command.
- [low, fixed] daemon uninstall now distinguishes not-enabled from refused: a refusal prints to stderr and exits 1, and the daemon is stopped only when the restore actually happened (lifecycle.ts:772-789) — the dark-status-line-while-saying-off case is gone.
- [low, fixed] daemon install rolls the switch back via disableSharedMode when this run did the switch and ensureDaemon fails; when shared mode was already active it keeps the memory and says the status line still points at the client (lifecycle.ts:746-766). The original error is preserved as cause.
New code: client 503 retry — checked
The retry (client/ccstatusline-ipc:91-113) fires only on curl rc 22 with HTTP 503, which the server really emits when renderJobs.size >= maxInFlight (server.ts:421). It is bounded (8 retries, 50ms backoff; only fast 503s retry, a stalled response still hits --max-time and dies), the empty-stdout-on-failure contract is untouched (dead daemon = curl rc 7, no retry), and the bench recovery proof is unaffected. Temp files come from mktemp (0600), hold only the piped payload and the rendered line, and the token still travels only through the curl config on stdin. The recovery gate (benchmark-render.py:616-631) now enforces still_succeeded=0, empty stdout and zero mismatches in the exit code — previous finding 5 is closed.
Non-blocking notes
- client/ccstatusline-ipc:88 — the EXIT HUP INT TERM trap removes the temp files but does not exit; on SIGTERM during the backoff sleep POSIX sh resumes the retry loop instead of dying. Bounded in practice (curl --max-time), cosmetic.
- The 503-then-success retry path has no automated coverage in src/daemon/tests/client.test.ts (the file was not touched); the bench exercises only the dead-daemon path. A mock 503-then-200 case there would pin the new behavior.
…#19) HUP/INT/TERM handlers now exit with the conventional 128+signal code so a signal during the backoff sleep cannot resume the retry cycle; temp-file cleanup still happens through the EXIT trap. Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow
left a comment
There was a problem hiding this comment.
Review: PR #50 (update) — fix commit cb64c72
Verdict: approved. This commit closes the one open inline note from the previous review (5360853079): the signal traps now exit instead of returning into the retry loop.
Checked: trap cleanup EXIT runs on the exit from each signal handler, so the temp files are still removed on HUP/INT/TERM; the exit codes 129/130/143 are the conventional 128+signal values; both variables are assigned before the traps are installed, so cleanup is safe under set -u. No other files changed since the approved revision — all five original findings and the client 503 retry review from the previous round still stand.
Still open and still non-blocking: the 503-then-success retry path has no automated coverage in src/daemon/tests/client.test.ts; worth a follow-up, not a gate for this PR.
The flat 8x50ms budget (~0.4s) was shorter than the time a 50-session burst needs to drain the render queue (4 in-flight x render time), so every retry still saw busy and the client gave up: s50 shared-warm waves failed with 8x503 in stderr. Now 14 attempts on a 50ms-1s ladder (~8s budget) — enough for dozens of concurrent large renders or slow custom commands, while dead-daemon and bad-request failures still exit immediately on the first attempt. Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow
left a comment
There was a problem hiding this comment.
Review: PR #50 (update) — fix commit f15e1d8
Verdict: approved. The fixed 50ms backoff became a growing ladder (0.05, 0.1, 0.25, 0.5, then 1s) with the retry cap raised to 13.
Checked: only curl rc 22 with HTTP 503 retries, so the empty-stdout-on-failure contract is untouched — a dead daemon still fails fast with rc 7 and no retry, which keeps the bench recovery proof valid. The case statement is POSIX-correct and total wall time stays bounded (about 6.8s of sleeps plus fast 503 responses; a stalled response still dies at curl --max-time). No other files changed since the approved revision cb64c72.
Two non-blocking notes: the comment says an 8s total budget while the sleep ladder sums to about 6.8s across 13 retries (cosmetic); and the 503-then-success retry path still has no automated coverage in src/daemon/tests/client.test.ts — a mock 503-then-200 case remains a worthwhile follow-up.
Full 1/10/36/50-session one-shot vs shared run: 80.7% aggregate-CPU reduction on the warmed 36-session gate (target >=50%), output hashes equal in every passing pair, recovery proof PASS (52/52 clients fail clean on the dead daemon, byte-identical re-render after restart). s50-shared-warm-small-slow recorded as a reproducible measured limitation (busy-queue boundary), not tuned away. Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow
left a comment
There was a problem hiding this comment.
Review: PR #50 (update) — docs commit 7d2df32
Verdict: changes requested. The measured substance is solid and honestly reported — the 80.7% gate PASS on s36-warm-small matches the JSON, the s50-shared-warm-small-slow failure is recorded as failed (the 503 tail in the JSON confirms the documented queue-exhaustion story), recovery_gate is PASS with correct counts in the generated MD. But this commit is acceptance evidence, and it contains one wrong measured number plus a generator wart that visibly corrupts the committed table. Both are two-minute fixes.
Findings
-
[low, factual] The verification doc claims 52/52 clients failed the dead-daemon wave; the measured number is 50/50 — docs/daemon-19-verification.md:22. The results JSON and the generated DAEMON-BENCH.md both say 50 of 50 (50 sessions in the wave), and the doc itself promises measured numbers only. Fix the count to 50/50.
-
[low, cosmetic] write_daemon_summary does not strip newlines from a failed run error, so the traceback breaks the markdown table — scripts/benchmark-render.py:676. The committed docs/daemon-19-bench.md line 34-36 shows the s50-shared-warm-small-slow traceback spilling out of its table row (pipes are escaped, newlines are not). Add a newline-to-space replacement next to the pipe escape.
Everything else in the docs commit checks out against the JSON: gate numbers, per-scenario table values, recovery section, and the honest failure narrative (serial render at MAX_IN_FLIGHT_RENDERS = 4, retry ladder bounded, failure kept visible with exit 1).
19/19 checks against the real dist build in an isolated environment: piped render starts no daemon; install/status/wrapper-render/ fail-clean/uninstall/verbatim-restore all behave as documented; status output never contains the auth token. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ows (#19) The verification doc claimed 52/52 dead-daemon failures; the wave has 50 sessions and the results JSON says 50 of 50. write_daemon_summary now flattens newlines in failed-run errors so tracebacks stay inside the markdown table row; the committed daemon-19-bench.md row is repaired to match what the fixed generator emits. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Both findings fixed in ac4a4e3 (threads resolved with replies):
Live CLI verification also landed in this PR (b2921ce): 19/19 checks against the real dist build in an isolated environment — piped render starts no daemon, install → status → wrapper render → stop → fail-clean → uninstall → verbatim restore → honest no-op, no token in status output. Recorded in |
axisrow
left a comment
There was a problem hiding this comment.
Review: PR #50 (update) — commits b2921ce + ac4a4e3
Verdict: approved. Both findings from the previous review (5361047049) are fixed.
Checked: the recovery row in docs/daemon-19-verification.md now says 50/50, matching the results JSON and the generated table; write_daemon_summary strips newlines before the pipe escape (benchmark-render.py:676), and the regenerated daemon-19-bench.md FAILED row is a single well-formed table line. The new live CLI verification paragraph (19/19 opt-in gating checks on an isolated fake config) is consistent with the rest of the evidence. No other code changed since the approved revision f15e1d8.
Standing non-blocking note, unchanged: the 503-then-success retry path still lacks automated coverage in src/daemon/tests/client.test.ts — fine as a follow-up.
Closes #19
Daemon delivery step 4: opt-in installation, status/recovery UX, benchmarks with honest acceptance numbers. Builds on #16 (transport), #17 (lifecycle), #18 (providers/caches); branch base =
d79f0b4(latest main).What's here
Opt-in installation (shared mode)
enableSharedMode()/disableSharedMode()insrc/utils/claude-settings.ts: switch the Claude CodestatusLineto the IPC client wrapper (sh <install>/client/ccstatusline-ipc) and back. The previous one-shot command is remembered in ccstatusline's own settings (daemonSharedMode.previousStatusLine, incl.nullfor "there was no status line") and restored verbatim; enable is idempotent; corrupt/unreadable settings are refused on both sides; Windows refuses (documented indocs/WINDOWS.md).ccstatusline daemon install|uninstalldrive the switch. Nothing on the render path ever starts a daemon: the wrapper fails with empty stdout + exit 1 when it is down; starting one is only everdaemon start|install.installrolls the switch back if the daemon start fails and this run did the switch.client/ccstatusline-ipcships in the npm package (files+=client/).Status/recovery UX
daemon statussurfaces protocol/build identity, pid, uptime, started-at, last render time, active renders and aggregate request counters from/v1/health— identity and timings, never secrets (no token in the payload; pinned by test).daemon restartwhen a running daemon reportsincompatible.Benchmarks
scripts/benchmark-render.py daemon-bench: one-shot vs shared, same fixtures and wave pattern; shared aggregate CPU covers the daemon and every client (reaped children,getrusage(RUSAGE_CHILDREN)). Cold/warm × small/large × slow-command, 1/10/36/50 sessions, plus a deterministic recovery proof gated in the exit code. Results:docs/daemon-19-results.json(full JSON),docs/daemon-19-bench.md(generated tables),docs/daemon-19-verification.md(method + honesty).Acceptance gates (epic checklist) — measured, not assumed
Apple M5 (10 cores), macOS 26, Node runtime,
CCSL_FORK=1, width 120, load gate 14 (machine not idle; per-scenario loadavg stamped).output_hashes_equal=trueWarm rows (CPU ms/render, shared vs one-shot): s1 16.9/118.1 (−85.7%), s10 27.2/170.1 (−84.0%), s36 34.7/180.0 (−80.7%, gate), s50 37.5/178.9 (−79.0%), s50-large 46.8/215.7 (−78.3%). Aggregate RSS at 50 warm sessions: 3.5 GB (one-shot) vs 0.56 GB (shared).
Measured limitation (kept visible):
s50-shared-warm-small-slowfails reproducibly — 50 synchronous sessions × 0.4 s custom command is a ~6–7 s render queue, and the client's bounded ~8 s retry ladder does not guarantee a slot, so some clients give up with 503 busy. That is the designed backpressure boundary (a real repaint retries next tick); the run exits 1 and the row is documented, not tuned away. Details indocs/daemon-19-verification.md.Verification
bun test: 2732 passed / 0 failed (173 files), single full run.bun run lint: clean (tsc --noEmit+ ESLint--max-warnings=0).--self-testPASS.Notable fixes found by the acceptance run
🤖 Generated with Claude Code