Officer's hour-long idle timer was doing two unrelated jobs: collecting its own in-memory binding, which is its business, and terminating the agent, which is the sidecar's. It could not do the first without the second, because `unsub` was a closure reachable only through `kill`. So a browser that went away killed a live agent an hour later — including one the sidecar had deliberately protected. The sidecar already refuses to collect a session that is mid-turn or holding background tasks: `task:started` disarms its idle GC, and `armIdle` re-checks and re-arms rather than firing once. Officer had no view of any of that. A laptop running out of battery overnight took a `run_in_background` job with it for no reason. `detach` now sits beside `kill` on both streaming handles, and `_sidecarUnsub` — declared and called for a long time, never once assigned — is populated at all three sites. `releaseSession` unsubscribes and forgets the record without killing; the idle timer points at it. `deleteSession` is unchanged, so an explicit disconnect still ends the session. The third assignment site was not in the plan: `adoptOrphanedSession` sets `_claudeKill` but nothing else, so an adopted session that later idled out would have dropped its record while the listener stayed subscribed — a leak of one per adopt-then-leave. No double subscription: releasing unsubscribes first, so a returning browser either adopts with a fresh listener or starts a first turn with none behind it. Step 1 of docs/chat-session-lifetime.md. Step 2 (a list verb, so running sessions can be found after a restart) is still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
import { logger } from '@@/api/chat/logger';
|
|
import type { MessageCost, PromptImage, TurnMessage } from '@@/api/chat/types';
|
|
import * as sidecar from '@@/sidecar-registry';
|
|
|
|
type ClaudeCodeParams = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
prompt: string;
|
|
sessionKey: string;
|
|
model?: string;
|
|
};
|
|
|
|
type ClaudeCodeResult = {
|
|
text: string;
|
|
sessionId: string;
|
|
model: string;
|
|
cost: MessageCost;
|
|
};
|
|
|
|
export function clearClaudeCodeSession(sessionKey: string): void {
|
|
sidecar.clearClaudeSession(sessionKey);
|
|
}
|
|
|
|
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
|
logger.info('Claude Code exec (via sidecar)', { sessionKey: params.sessionKey });
|
|
return sidecar.spawnClaude(params);
|
|
}
|
|
|
|
// ── Streaming variant for Chat Panel WebSocket ──
|
|
|
|
type ClaudeCodeStreamingParams = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
prompt: string;
|
|
images?: PromptImage[];
|
|
sessionKey: string;
|
|
cwd?: string;
|
|
model?: string;
|
|
resumeSessionId?: string;
|
|
durable?: boolean;
|
|
// Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under.
|
|
onMessage: (msg: TurnMessage, seq?: number) => void;
|
|
};
|
|
|
|
type ClaudeCodeStreamingHandle = {
|
|
/** End the agent's session upstream and stop listening. For an explicit disconnect. */
|
|
kill: () => void;
|
|
/**
|
|
* Stop listening and leave the agent running.
|
|
*
|
|
* These are separate because officer's idle GC and a user's "disconnect" want different things, and
|
|
* for a long time they could not have them: `unsub` was a closure reachable only through `kill`, so
|
|
* letting go of a session necessarily killed it. That is why an idle browser took a live agent down
|
|
* with it — including one the sidecar had deliberately protected because background work was still
|
|
* in flight.
|
|
*/
|
|
detach: () => void;
|
|
};
|
|
|
|
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
|
|
const { onMessage, ...spawnParams } = params;
|
|
|
|
logger.info('Claude Code streaming exec (via sidecar)', { sessionKey: params.sessionKey });
|
|
|
|
// Session-scoped subscription. The persistent session outlives the turn, so background task events
|
|
// (task:notification) arrive AFTER 'result' — do NOT unsubscribe on a terminal turn event; only on
|
|
// an explicit kill/teardown (the returned handle, called from deleteSession/disconnect).
|
|
const unsub = sidecar.onClaudeMessage((sessionKey, msg, seq) => {
|
|
if (sessionKey === params.sessionKey) onMessage(msg, seq);
|
|
});
|
|
|
|
await sidecar.spawnClaudeStreaming(spawnParams);
|
|
|
|
return {
|
|
kill: () => {
|
|
sidecar.killClaude(params.sessionKey);
|
|
unsub();
|
|
},
|
|
detach: unsub,
|
|
};
|
|
}
|