chat: persistent Agent SDK session per chat — decouple worker from turn (Phase 1)
Root fix for orphaned background tasks: the platform drove Claude Code as a
one-shot `claude -p` per turn (stdin ignored, process exits at turn end), so
run_in_background/Monitor work — and its task_notification — had no live harness
to return to. Now each chat session runs ONE long-lived Agent SDK query() with
streaming input; turns are user messages pushed onto it, and the session stays
warm between turns.
- claude-manager: persistent `query({ prompt: AsyncIterable, options })` per
sessionKey (bypassPermissions, --resume, mcp via extraArgs, CLAUDECODE stripped).
Single consumer loop maps every SDK message → ChatEvent, incl. post-turn
task_started / task_notification. interrupt() = stop-turn; abort() = kill-session;
30-min idle GC.
- stream-parser: processMessage() (object-level, reused by the SDK loop) + task
message handling. ChatEvent/ServerMessage gain task:started / task:notification.
- API: the sidecar event subscription is now SESSION-scoped (no longer unsubscribes
on 'result'), so background events after turn-end still reach the client. First
turn opens the session; later turns push onto it. handleStop → interrupt (keeps
session warm); disconnect/deleteSession → kill.
- protocol/sidecar-registry/user-instance: claude:interrupt command + interruptClaude.
- client: render task:started / task:notification in the transcript.
Verified end-to-end through the real chat WS: a run_in_background task's completion
arrives ~6s AFTER the turn's result; multi-turn on one warm session works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -275,6 +275,18 @@ function createEventHandler(sessionId: string, model: string, cwd: string) {
|
||||
session.isGenerating = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'task:started': {
|
||||
// Background task launched (run_in_background / Monitor). Independent of turn state.
|
||||
sendToClient(ws, { type: 'task:started', taskId: event.taskId, description: event.description, taskType: event.taskType });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'task:notification': {
|
||||
// Background task finished — arrives AFTER the turn's 'result'; the whole point of the fix.
|
||||
sendToClient(ws, { type: 'task:notification', taskId: event.taskId, status: event.status, summary: event.summary });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -372,21 +384,35 @@ async function handleClaudeCodeChat(
|
||||
const onEvent = createEventHandler(sessionId, model, cwd);
|
||||
|
||||
try {
|
||||
const handle = await sendClaudeCodeStreaming({
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
prompt: effectivePrompt,
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
resumeSessionId: msg.resumeSessionId,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
// Store sentinel so handleStop can kill it via sidecar
|
||||
session.piProcess = sessionId as any;
|
||||
session._claudeKill = handle.kill;
|
||||
if (!session._claudeKill) {
|
||||
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
||||
// (survives turn-end so background task:notifications keep flowing). handle.kill tears both down.
|
||||
const handle = await sendClaudeCodeStreaming({
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
prompt: effectivePrompt,
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
resumeSessionId: msg.resumeSessionId,
|
||||
onEvent,
|
||||
});
|
||||
session.piProcess = sessionId as any;
|
||||
session._claudeKill = handle.kill;
|
||||
} else {
|
||||
// Session already live: push this turn onto the existing persistent session (no new subscription).
|
||||
await sidecar.spawnClaudeStreaming({
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
prompt: effectivePrompt,
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
resumeSessionId: msg.resumeSessionId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to start Claude Code streaming', { sessionId, error: String(err) });
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start Claude Code' });
|
||||
@@ -524,8 +550,10 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
if (session?.piProcess) {
|
||||
try {
|
||||
if (isClaudeModel(session.model)) {
|
||||
sidecar.killClaude(sessionId, session.email);
|
||||
logger.info('Killed Claude Code process via sidecar', { sessionId });
|
||||
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the
|
||||
// warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill.
|
||||
void sidecar.interruptClaude(sessionId, session.email);
|
||||
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
|
||||
} else {
|
||||
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
|
||||
logger.info('Aborted OpenCode turn', { sessionId });
|
||||
|
||||
Reference in New Issue
Block a user