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:
2026-07-27 00:00:11 +00:00
co-authored by Claude Opus 4.8
parent b9d539c1bd
commit 449f28b1e5
10 changed files with 329 additions and 155 deletions
+8 -2
View File
@@ -126,7 +126,9 @@ export type ServerMessage =
| {
// Ack for a client 'disconnect': the session was torn down server-side.
type: 'disconnected';
};
}
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
export type ChatEvent =
| { type: 'text'; text: string }
@@ -148,7 +150,11 @@ export type ChatEvent =
cost: MessageCost;
}
| { type: 'error'; message: string }
| { type: 'stopped' };
| { type: 'stopped' }
// Background-task lifecycle (run_in_background / Monitor), delivered in-stream by the persistent
// session — including AFTER the turn's `result`, which is the whole point of the persistent worker.
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
export type UserSession = {
sessionId: string;
+45 -17
View File
@@ -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 });