Skip to content

perf(llm): keep agent subprocesses alive across chat turns#10554

Draft
eliandoran wants to merge 2 commits into
worktree-copilot-agent-providerfrom
llm-agent-keep-alive
Draft

perf(llm): keep agent subprocesses alive across chat turns#10554
eliandoran wants to merge 2 commits into
worktree-copilot-agent-providerfrom
llm-agent-keep-alive

Conversation

@eliandoran

Copy link
Copy Markdown
Contributor

Both agent providers spawned a CLI subprocess, ran a handshake, and threw it all away on every chat turn. This keeps the process warm between turns, cutting seconds of pure overhead off each message — none of it model latency.

Measured on Windows against the real CLIs (times to first token):

provider before after saved
Copilot 6912 ms 3078 ms 3.8 s (55%)
Claude 2265 ms 866 ms 1.4 s (62%)

Two commits, one per provider — they solve the same problem but the mechanisms have nothing in common.

Copilot: one shared client, many sessions

ACP is built for this — one connection hosts many sessions addressed by sessionId, and prompting is a call on a session that already exists. The client is now shared process-wide and reaped after 5 minutes idle. The protocol trace tells the story:

cold  [initialize, session/new, session/set_model, session/prompt]
warm  [session/set_model, session/prompt]

The session/new alone was ~2.4 s: it refreshes auth and fetches the model list.

Consequences worth reviewing:

  • Cancellation is now in-band. The client is shared, so disposing it on abort would kill every other chat's turn.
  • Session mappings record the process generation. A session lives inside its process, so after a reap or crash the mapping is stale and the turn falls back to session/load. Prompting a live session directly is what makes the warm path cheap — session/load on an already-loaded session is rejected by the CLI ("already loaded").
  • Flipping note access starts a new session: MCP servers are fixed at session/new.
  • AcpClient gained an onExit hook so a dead subprocess evicts itself.

Claude: streaming input mode

query({ prompt: "text" }) binds the subprocess to the returned iterator, so it dies when iteration ends. Passing an AsyncIterable instead keeps one subprocess per chat. That is also the only mode in which the Query control methods work — which makes a mid-chat model switch a ~0 ms setModel() instead of a cold start.

Iteration is deliberately manual (query.next() in a loop, stopping at the turn's result) rather than for await … break: breaking out of a for-await calls iterator.return(), which closes the query and kills the process this exists to keep warm.

The design follows zed's claude-code-acp adapter, which solves the same problem — minus its persistent-consumer and turn-deferred machinery, which exists to interleave queued and autonomous turns. A Trilium chat is strictly one turn at a time.

Decisions worth reviewing:

  • Options fixed at query() construction (system prompt, note tools, thinking) are fingerprinted; a change retires the session.
  • An aborted turn closes the session rather than pooling it: it stops mid-stream, so the state is unknown and the SDK can wedge mid-next() (interrupt() is not guaranteed to yield — claude-agent-acp#680). The next turn pays a cold start.
  • Pushable.push after end() is a no-op, not a hang — enqueueing onto a dead stream would otherwise wait on a promise nobody settles (claude-agent-acp#338).
  • One subprocess per chat here (vs. one shared for Copilot), so the idle reaper and the warm-session cap bound real memory.

Testing

359 server LLM tests pass. New unit coverage: process reuse across turns and chats, a shared spawn for concurrent turns, per-session update routing, crash recovery, config-change reseeds, cancellation, and the idle reap.

Two opt-in live integration tests drive the real CLIs and assert the latency drop — skipped by default since they spend subscription budget:

TRILIUM_COPILOT_LIVE_TEST=1 pnpm --filter server test copilot_agent.live
TRILIUM_CLAUDE_LIVE_TEST=1  pnpm --filter server test claude_agent.live

Notes for the reviewer

  • Targets worktree-copilot-agent-provider, not main — the Copilot provider isn't in main yet, so this stacks on that branch.
  • Draft, since it can't land before its base does.
  • The behaviour change that touches the most existing tests is the resume path: within one live process, a resume is now a bare session/prompt rather than session/load. Those assertions were updated, not deleted.

🤖 Generated with Claude Code

eliandoran and others added 2 commits July 18, 2026 23:51
Every turn spawned `copilot --acp`, ran the ACP handshake, created a
session and then disposed the client. Measured on Windows that is ~785
ms to spawn plus ~2.4 s for the first session/new (it refreshes auth and
fetches the model list) -- ~3.2 s before the agent starts generating,
none of it model latency, repeated on every message.

ACP is built for the opposite: one connection hosts many sessions, each
addressed by sessionId, and prompting is a call on a session that
already exists. So the client is now shared process-wide and kept warm
between turns, reaped after 5 minutes idle. A turn whose session is
still loaded issues nothing but session/prompt.

Live against the real CLI (see copilot_agent.live.spec.ts):

  cold  6912 ms  [initialize, session/new, session/set_model, prompt]
  warm  3078 ms  [session/set_model, prompt]

Notable consequences:

- Cancellation is now in-band. The client is shared, so disposing it on
  abort would kill every other chat's turn; only the pool reaps.
- Session mappings record the process generation that created them. A
  session lives inside its process, so after a reap or crash the mapping
  is stale and the turn falls back to session/load. Prompting a live
  session directly is what makes the warm path cheap -- session/load on
  an already-loaded session is rejected by the CLI ("already loaded").
- Flipping note access starts a new session: MCP servers are fixed at
  session/new and cannot be reconfigured afterwards.
- AcpClient gained an onExit hook so a dead subprocess evicts itself and
  the next turn transparently starts a replacement.

Tests cover process reuse across turns and chats, a shared spawn for
concurrent turns, per-session update routing, crash recovery, the
note-tools reseed and the idle reap. The live test is opt-in via
TRILIUM_COPILOT_LIVE_TEST=1 -- it spends premium-request budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`query({ prompt: "text" })` binds the Claude Code subprocess to the
returned iterator: it spawns on call and dies when iteration ends, so
every turn re-paid the spawn. Measured on Windows that is ~2.3 s to the
first token cold against ~0.9 s once warm.

Switch to the SDK's streaming input mode -- pass an AsyncIterable as the
prompt and push each turn's message onto it -- keeping one subprocess
per chat. That is also the only mode in which the Query control methods
work, which is what makes a mid-chat model switch a ~0 ms setModel()
call instead of a cold start.

Live against the real CLI (see claude_agent.live.spec.ts):

  cold first token 2265 ms -> warm 866 ms

Iteration is deliberately manual (`query.next()` in a loop, stopping at
the turn's `result`) rather than `for await ... break`: breaking out of
a for-await calls iterator.return(), which closes the query and kills
the process this exists to keep warm.

The design follows zed's claude-code-acp adapter, which solves the same
problem, minus its persistent-consumer and turn-deferred machinery --
that exists to interleave queued and autonomous turns, whereas a Trilium
chat is strictly one turn at a time.

Notable decisions:

- Options fixed at query() construction (system prompt, note tools,
  thinking) are fingerprinted; a change retires the session and builds a
  new one, since they cannot be applied to a live query.
- An aborted turn closes the session rather than returning it to the
  pool: it stops mid-stream, so the session's state is unknown and the
  SDK can be wedged mid-next() (interrupt() is not guaranteed to yield --
  agentclientprotocol/claude-agent-acp#680). The next turn pays a cold start.
- Warm sessions are capped and reaped after 5 minutes idle. Unlike the
  Copilot pool this is one subprocess *per chat*, so the reaper bounds
  real memory rather than just being tidy.
- Pushable.push after end() is a no-op, not a hang: enqueueing onto a
  dead stream would otherwise wait on a promise nobody settles, the
  failure behind claude-agent-acp#338.

The live test is opt-in via TRILIUM_CLAUDE_LIVE_TEST=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces keep-alive session pooling for both the Claude and Copilot LLM providers to significantly reduce latency by reusing warm subprocesses across chat turns. For Claude, a new session pool keeps a subprocess alive per chat using streaming input mode, while for Copilot, a shared process-wide client multiplexes multiple sessions. Additionally, live integration and unit tests are added to verify keep-alive behavior, model switching, and idle reaping. Feedback is provided regarding the evictOldest logic in the Claude session pool, which could fail to evict idle sessions if the oldest session is busy, potentially leading to a resource leak.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +180 to +191
function evictOldest(): void {
while (sessionsByChatNote.size > MAX_WARM_SESSIONS) {
const [oldestId, oldest] = [...sessionsByChatNote.entries()][0];
// Never evict a session mid-turn; stop at the first busy one so a
// pathological all-busy map degrades to "no eviction" instead of
// killing a live reply.
if (oldest.busy) {
break;
}
closeSession(oldestId, oldest);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The evictOldest function stops at the first busy session it encounters. If the oldest session in the map is busy, no other idle sessions will be evicted, even if the pool size exceeds MAX_WARM_SESSIONS. This can lead to a resource leak of warm subprocesses. Instead, iterate through the map and evict any non-busy sessions until the pool size is within the limit.

function evictOldest(): void {
    for (const [chatNoteId, session] of sessionsByChatNote.entries()) {
        if (sessionsByChatNote.size <= MAX_WARM_SESSIONS) {
            break;
        }
        if (!session.busy) {
            closeSession(chatNoteId, session);
        }
    }
}

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR eliminates the per-turn subprocess spawn overhead for both the Claude and Copilot agent providers by keeping subprocesses warm across chat turns, cutting time-to-first-token by 55–62% on Windows.

  • Copilot: a single copilot --acp process is shared process-wide via a reference-counted lease pool (copilot_client_pool.ts); session-update notifications are routed per sessionId so concurrent chats don't bleed into each other; generation stamps detect stale session mappings after a reap or crash.
  • Claude: the SDK's streaming-input mode (Pushable<SDKUserMessage>) keeps one subprocess per chat (claude_session_pool.ts); the iteration loop is manual (query.next() in a for(;;)) to avoid for-await calling iterator.return() and killing the warm process; fingerprint-based invalidation forces a fresh session when construction-time options change.

Confidence Score: 3/5

The warm-session logic in claude_agent.ts has a reachable defect where a mid-stream SDK error silently returns a broken session to the pool, causing the next turn to inherit a dead or wedged query.

turnComplete is declared with let inside the try block and is out of scope in finally. When query.next() throws, held.closed stays false and signal?.aborted stays false, so reusable evaluates to true and the dead session is handed back to the pool. The fix is a one-line hoist of turnComplete to before the try block. Everything else — the Copilot pool's reference counting, generation stamps, crash self-eviction, and the idle reapers on both sides — is well-reasoned and well-tested.

claude_agent.ts around the finally block at the end of chatChunks; specifically the reusable condition that needs turnComplete in scope.

Important Files Changed

Filename Overview
apps/server/src/services/llm/providers/claude_agent.ts Core change: turns now reuse a per-chat subprocess via Pushable streaming-input mode. Has a P1 bug: turnComplete declared inside try is invisible to finally, so an SDK error returns a broken session to the pool instead of closing it.
apps/server/src/services/llm/providers/claude_session_pool.ts New module: manages warm Claude subprocess sessions per chat. Well-structured with idle reaping, fingerprint-based invalidation, and idempotent close. Minor: evictOldest breaks at the first busy entry, allowing the pool to exceed MAX_WARM_SESSIONS when the oldest session is mid-turn.
apps/server/src/services/llm/providers/copilot_agent.ts Refactored to share a single AcpClient across turns via copilot_client_pool.ts. Session-update notifications now routed per-session ID. generateTitle leaves CLI sessions open (no session/delete). buildMcpServersConfig() called twice on session/load failure.
apps/server/src/services/llm/providers/copilot_client_pool.ts New module: manages a single shared copilot --acp subprocess with reference-counted leases, generation-stamped sessions for stale-mapping detection, idle reaping, and crash self-eviction. Logic is clean and race conditions are handled by incrementing activeTurns before any await.
apps/server/src/services/llm/providers/acp_client.ts Added onExit callback and alive getter, plus a die() helper that fires the hook exactly once to avoid double-notification. Small, focused, and correct.
apps/server/src/services/llm/providers/claude_agent.spec.ts Updated and expanded test coverage for the new session-pool behaviour: process reuse across turns, shared spawn for concurrent turns, per-session update routing, crash recovery, config-change reseeding, cancellation, and idle reap.
apps/server/src/services/llm/providers/copilot_agent.spec.ts Updated and expanded: covers warm-path skipping of initialize/session-new, generation-based stale-session detection, notification routing, concurrent turn sharing, abort handling, and idle reap.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Chat as Chat Turn
    participant CA as claude_agent.ts
    participant Pool as claude_session_pool.ts
    participant SDK as Claude SDK (subprocess)

    Note over Chat,SDK: Turn 1 (cold)
    Chat->>CA: chatChunks(prompt)
    CA->>Pool: takeWarmSession() → undefined
    CA->>SDK: "query({ prompt: Pushable, ... })"
    CA->>Pool: rememberWarmSession(session)
    CA->>SDK: input.push(userMsg)
    loop query.next() until result
        SDK-->>CA: stream messages
    end
    CA-->>Chat: yield chunks
    CA->>Pool: releaseSession() → arms idle timer

    Note over Chat,SDK: Turn 2 (warm)
    Chat->>CA: chatChunks(prompt)
    CA->>Pool: takeWarmSession() → session
    CA->>SDK: input.push(userMsg)
    loop query.next() until result
        SDK-->>CA: stream messages
    end
    CA-->>Chat: yield chunks
    CA->>Pool: releaseSession()

    Note over Chat,SDK: Abort / SDK error
    Chat->>CA: signal.abort()
    CA->>Pool: closeSession()
    Pool->>SDK: input.end() + query.close()
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Chat as Chat Turn
    participant CA as claude_agent.ts
    participant Pool as claude_session_pool.ts
    participant SDK as Claude SDK (subprocess)

    Note over Chat,SDK: Turn 1 (cold)
    Chat->>CA: chatChunks(prompt)
    CA->>Pool: takeWarmSession() → undefined
    CA->>SDK: "query({ prompt: Pushable, ... })"
    CA->>Pool: rememberWarmSession(session)
    CA->>SDK: input.push(userMsg)
    loop query.next() until result
        SDK-->>CA: stream messages
    end
    CA-->>Chat: yield chunks
    CA->>Pool: releaseSession() → arms idle timer

    Note over Chat,SDK: Turn 2 (warm)
    Chat->>CA: chatChunks(prompt)
    CA->>Pool: takeWarmSession() → session
    CA->>SDK: input.push(userMsg)
    loop query.next() until result
        SDK-->>CA: stream messages
    end
    CA-->>Chat: yield chunks
    CA->>Pool: releaseSession()

    Note over Chat,SDK: Abort / SDK error
    Chat->>CA: signal.abort()
    CA->>Pool: closeSession()
    Pool->>SDK: input.end() + query.close()
Loading

Comments Outside Diff (1)

  1. apps/server/src/services/llm/providers/copilot_agent.ts, line 347-398 (link)

    P2 generateTitle leaves CLI sessions permanently open

    generateTitle now borrows the shared client and calls session/new, but never requests session/delete (or an equivalent teardown). In the original design client.dispose() killed the subprocess, cleaning up every session implicitly. With the shared process, each title generation deposits a one-off session in the CLI that is never explicitly closed — it is only swept when the whole subprocess is reaped after IDLE_TIMEOUT_MS idle. If a user generates many titles in quick succession the CLI accumulates them until the reap fires.

    If the ACP protocol provides a session/delete or similar method, calling it at the end of title generation would cap the leak to at most one ephemeral session at a time.

    Fix in Claude Code

Fix All in Claude Code

Reviews (1): Last reviewed commit: "perf(llm): keep the Claude agent process..." | Re-trigger Greptile

// Manual iteration on purpose: `for await (…) { break }` calls
// iterator.return(), which closes the query and kills the process
// this whole pool exists to keep warm.
let turnComplete = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Session reused after SDK error

turnComplete is declared inside the try block and is therefore invisible to the finally block. When active.query.next() throws (subprocess crash, protocol error, rate-limit, etc.) the turn exits via catch without ever setting turnComplete. In finally, held.closed remains false and signal?.aborted is false, so reusable evaluates to true — the broken session is handed back to the pool via releaseSession. The next turn picks it up as warm, pushes a new user message into a dead stream, and either gets an immediate error or hangs on query.next().

The fix is to hoist turnComplete to the outer scope (alongside held) so finally can gate on it: const reusable = … && !signal?.aborted && turnComplete;.

Fix in Claude Code

Comment on lines +180 to +191
function evictOldest(): void {
while (sessionsByChatNote.size > MAX_WARM_SESSIONS) {
const [oldestId, oldest] = [...sessionsByChatNote.entries()][0];
// Never evict a session mid-turn; stop at the first busy one so a
// pathological all-busy map degrades to "no eviction" instead of
// killing a live reply.
if (oldest.busy) {
break;
}
closeSession(oldestId, oldest);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Eviction stops at first busy entry, skipping all younger idle sessions

The while loop retrieves only the oldest map entry and breaks immediately if it is busy. When the oldest session belongs to a long-running conversation, all younger idle sessions behind it are never examined and the map grows beyond MAX_WARM_SESSIONS indefinitely — one extra slot per new session added while the oldest is busy. The idle reaper eventually recovers, but the stated memory bound isn't enforced during active use.

Scanning past busy entries toward the first idle one would honour the bound more faithfully without risking a live reply.

Fix in Claude Code

Comment on lines +216 to +231
} else if (resumable !== undefined) {
// The session outlived its process (reaped, crashed, or the
// server restarted). session/load replays its history from
// disk; no listener is registered yet, so that replay is
// dropped rather than streamed to the user as a fresh reply.
const mcpServers = noteToolsEnabled ? await buildMcpServersConfig() : [];
try {
await client.request("session/load", { sessionId: resume, cwd: getAgentCwd(), mcpServers }, SESSION_TIMEOUT_MS);
sessionId = resume;
await client.request("session/load", { sessionId: resumable, cwd: getAgentCwd(), mcpServers }, SESSION_TIMEOUT_MS);
resolved = resumable;
} catch (err) {
getLog().info(`Copilot Agent provider: session/load failed (${describeError(err)}); reseeding a fresh session.`);
} finally {
collector.muted = false;
}
}

if (!sessionId) {
if (!resolved) {
const mcpServers = noteToolsEnabled ? await buildMcpServersConfig() : [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 buildMcpServersConfig() is an async call that is invoked a second time unconditionally in the session/new branch even when it was already awaited in the session/load branch and failed. Extract it into a shared variable to avoid the redundant network round-trip on the failure path.

Suggested change
} else if (resumable !== undefined) {
// The session outlived its process (reaped, crashed, or the
// server restarted). session/load replays its history from
// disk; no listener is registered yet, so that replay is
// dropped rather than streamed to the user as a fresh reply.
const mcpServers = noteToolsEnabled ? await buildMcpServersConfig() : [];
try {
await client.request("session/load", { sessionId: resume, cwd: getAgentCwd(), mcpServers }, SESSION_TIMEOUT_MS);
sessionId = resume;
await client.request("session/load", { sessionId: resumable, cwd: getAgentCwd(), mcpServers }, SESSION_TIMEOUT_MS);
resolved = resumable;
} catch (err) {
getLog().info(`Copilot Agent provider: session/load failed (${describeError(err)}); reseeding a fresh session.`);
} finally {
collector.muted = false;
}
}
if (!sessionId) {
if (!resolved) {
const mcpServers = noteToolsEnabled ? await buildMcpServersConfig() : [];
const mcpServers = noteToolsEnabled ? await buildMcpServersConfig() : [];
if (resumable !== undefined) {
// The session outlived its process (reaped, crashed, or the
// server restarted). session/load replays its history from
// disk; no listener is registered yet, so that replay is
// dropped rather than streamed to the user as a fresh reply.
try {
await client.request("session/load", { sessionId: resumable, cwd: getAgentCwd(), mcpServers }, SESSION_TIMEOUT_MS);
resolved = resumable;
} catch (err) {
getLog().info(`Copilot Agent provider: session/load failed (${describeError(err)}); reseeding a fresh session.`);
}
}
if (!resolved) {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

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