Skip to content

feat(muse-proxy): warm sessions on a persistent muse serve (MSP) host (#480) - #769

Merged
dezren39 merged 2 commits into
mainfrom
warm/muse-proxy-serve-pool
Oct 25, 2026
Merged

dezren39 merged 2 commits into
mainfrom
warm/muse-proxy-serve-pool

Conversation

@dezren39

@dezren39 dezren39 commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

What

muse-proxy no longer pays a fresh muse exec spawn (~26k scaffolding tokens, ~8-20s) per chat completion when it can avoid it:

  • One persistent muse serve host (MSP = newline-delimited JSON-RPC 2.0 over stdio, schema via muse schema generate-json-schema), driven with Node stdlib only — no @muse-code/sdk runtime dependency (the SDK exists, v1.4.13, zero-dep, but raw MSP proved tractable in a spike, so the proxy stays dependency-free and ~/tools copies run standalone).
  • Conversation keying: sha256(model + system + first user message) → MSP session, persisted to warm-sessions.json in the workdir (LRU 64, 2h idle TTL, endpoint.json-style atomic write). Sessions are durable in the CLI's own store, so after a proxy/host restart the map re-attaches with session/resume.
  • Keyed hits send only the new user turn (turn/start). Measured on the sandbox with the real CLI: turn 2 input was 26,455 tokens of which 26,225 were cacheReadTokens (~230 uncached) vs 26,708 fully re-read input tokens for the same turn via exec; wall clock 8.2s vs 19.9s.
  • Diverged histories (prefix mismatch, extra user turns, expired/lost session) re-bootstrap as a fresh session whose first turn is the full flattened transcript — exactly what the exec path sends today.
  • Fallback: any warm failure before the response is committed (host won't start, RPC error, failed turn, dead host mid-turn before first byte) falls back to the unchanged spawn-per-request exec path. MUSE_PROXY_WARM=0 disables the warm path. All existing behaviour preserved: strict parseProxyPort, placeholder apiKey acceptance (no auth check added), child env (META_API_KEY deleted, egress proxy + CA pinned, NO_PROXY loopback added), streaming + terminal-text reconciliation, SSE error framing.
  • Also publishes endpoint.json like the sibling proxies (the muse proxy was missing it).

Investigation evidence

  • muse exec --help: --session-id <UUID> exists, but no --resume/--continue; muse resume is the interactive TUI. Exec-resume (option B) was therefore not the best available path once serve worked.
  • muse serve --help + schema export: full MSP method set (initialize, session/start|resume|leave, turn/start, session/tokenUsage, item/delta, turn/completed). Gotchas found by spiking: commandId must be UUIDv7 (v4 rejected, invalidParams), session objects carry sessionId (not id), usage arrives on session/tokenUsage (not on turn/completed).
  • Spike (raw NDJSON, real binary): initialize 1.9-4.9s cold; two turns in one session completed with the token split above.

Tests

node --test bin/muse-proxy.test.mjs — 9/9 pass. The stub muse now also speaks minimal MSP: warm tests assert one session per conversation, turn 2 sends only beta two (not the history), MSP usage flows into the response, streaming works over MSP deltas, and a dying serve host falls back to the exec answer. Exec-path tests pin MUSE_PROXY_WARM=0 and are unchanged in intent.

Deployment

Sandbox only (per #480): repo file + proxy-port.mjs copied to ~/tools/muse-proxy/, system service restarted, /health verified, before/after timings in the issue. No laptop unit/provider created (separate workstream).


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

…#480)

One persistent 'muse serve' process hosts durable MSP sessions keyed by
sha256(model + system + first user message), persisted to
warm-sessions.json in the workdir (LRU 64, 2h TTL) and resumed after
proxy/host restarts. Keyed hits send only the new user turn; the provider
prompt cache then serves the ~26k scaffolding tokens as cache reads
(measured: turn 2 input 26,455 tokens, 26,225 of them cacheReadTokens).
Diverged histories re-bootstrap as a fresh session whose first turn is
the flattened transcript (today's exec behaviour); any warm failure
before the response is committed falls back to the spawn-per-request
exec path, and MUSE_PROXY_WARM=0 disables the warm path. Raw MSP
(NDJSON JSON-RPC 2.0) over stdlib only - no SDK runtime dependency.
Also publishes endpoint.json like the sibling proxies and pins NO_PROXY
loopback for child processes.
@dezren39
dezren39 requested a review from a team as a code owner October 24, 2026 16:13
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T11:45:15.901345Z ff78f4c New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: a61e39bb23

ℹ️ 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 bin/muse-proxy.mjs
Comment on lines +609 to +611
const midUsers = suffix.slice(0, -1).filter(m => m.role === 'user').length;
if (prefixOk && suffix.length > 0 && last.role === 'user' && midUsers === 0) {
turnText = messageText(last);

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 Validate the assistant history before reusing a warm session

When a client edits an assistant reply or forks a conversation after the same opening user message, this condition still reuses the original session: entry.hashes covers only the previous request's inputs, so the newly supplied assistant reply is outside the checked prefix. Sending only messageText(last) then silently discards that reply and answers against the original session's history instead. The same condition also discards intervening tool results. Store and validate the generated assistant response, and re-bootstrap whenever the supplied suffix contains history that is not already represented in the session.

Useful? React with 👍 / 👎.

Comment thread bin/muse-proxy.mjs
Comment on lines +663 to +667
let fullContent = result.deltasText;
let unstreamedSuffix = '';
if (typeof result.fullText === 'string' && result.fullText.length >= fullContent.length) {
if (result.fullText.startsWith(fullContent)) unstreamedSuffix = result.fullText.slice(fullContent.length);
fullContent = result.fullText;

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 Use the authoritative final answer regardless of its length

When a warm turn emits intermediate agent-message commentary followed by a shorter final answer, deltasText contains the accumulated commentary while fullText contains the selected finalAnswer. This length check rejects the authoritative answer, so even a non-streaming response returns commentary concatenated with the answer instead of the final answer alone. A stub turn with accumulated deltas Long intermediate commentary. Done and final text Done reproduces this. Select the authoritative text unconditionally for non-streaming responses; keep prefix checks only for deciding what can safely be appended to an already-started stream.

Useful? React with 👍 / 👎.

Comment thread bin/muse-proxy.mjs

let result;
try {
result = await enqueueWarm(key, job);

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 Cancel warm work when the HTTP client disconnects

When a client aborts a request, the warm path neither cancels its active turn nor prevents its queued job from starting, unlike the exec path's response-close handler. This is reproducible by delaying one warm turn, queuing another request for the same conversation, and aborting the second request before the first finishes: the canceled request still starts another turn. Abandoned requests therefore continue consuming inference and holding the per-conversation queue, potentially delaying subsequent live requests for the full turn timeout. Observe response closure, skip canceled queued jobs, and cancel active work without terminating unrelated sessions.

Useful? React with 👍 / 👎.

Comment thread bin/muse-proxy.mjs
Comment on lines +338 to +339
child.on('error', err => this._onExit(`spawn error: ${err.message}`));
child.on('close', (code, signal) => this._onExit(`serve host exited (code ${code}, signal ${signal})`));

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 Ignore exit callbacks from replaced serve children

If initialization fails while another request is queued, _start() kills the failed child and clears its reference before that child's asynchronous close event arrives. The queued request can start a replacement host in that interval, but the old child's callback then invokes _onExit(), which unconditionally clears the replacement's state and rejects its pending RPCs. This makes the replacement initialization fail because of the previous process's exit. Bind these callbacks to their originating child and ignore them when that child is no longer the active host.

Useful? React with 👍 / 👎.

Comment thread bin/muse-proxy.mjs
const enqueueWarm = (key, job) => {
const prev = warmChains.get(key) || Promise.resolve();
const run = prev.then(job, job);
warmChains.set(key, run.catch(() => {}));

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 Release completed per-conversation chains

For a long-lived proxy serving distinct conversations, warmChains permanently retains one promise per conversation key. Because run.catch(() => {}) preserves successful fulfillment values, those promises also retain each completed turn's entire result, including fullText and deltasText; the 64-session LRU never removes these entries. Extracting this implementation and completing 256 unique conversations left all 256 result objects alive after repeated garbage collection, whereas clearing the map released them. Remove settled chains when they are still the latest chain for their key so response data does not accumulate without bound.

Useful? React with 👍 / 👎.

Comment thread bin/muse-proxy.mjs
Comment on lines +368 to +370
_write(obj) {
if (!this.child || !this.child.stdin.writable) throw new Error('serve host stdin is not writable');
this.child.stdin.write(JSON.stringify(obj) + '\n');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle asynchronous errors on the serve host's stdin

If the serve host exits while a buffered RPC write is pending, child.stdin emits an asynchronous error event that neither the surrounding write try/catch nor child.on('error') handles. This terminates the entire proxy instead of falling back to exec, dropping unrelated requests as well. A stub host that exits after reading the beginning of a 4 MiB turn/start request reproduces an uncaught Error: write EPIPE and proxy process exit. Register an error handler on the stdin stream and route the failure through the host/RPC rejection path.

Useful? React with 👍 / 👎.

bin/muse-proxy.mjs conflict resolved by taking main's version: the
live-tree commit a936e16 already carries this PR's warm serve pool
plus the follow-up CLI flag-probe and MUSE_PROXY_EGRESS override (the
superset later stacked as #775). This PR's test file merges cleanly
and passes 9/9 against it.
@dezren39
dezren39 merged commit ae8cc79 into main Oct 25, 2026
3 of 4 checks passed
@dezren39
dezren39 deleted the warm/muse-proxy-serve-pool branch October 25, 2026 11:45
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