Merge branch 'sidecars-claude' into sidecars
This commit is contained in:
@@ -138,6 +138,27 @@ export type ServerMessage =
|
||||
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
|
||||
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
|
||||
|
||||
// The turn-output subset of ServerMessage — everything the agent sidecar produces on its own. The
|
||||
// remaining members (session:init, sync:messages, disconnected, connection-level errors) are officer's:
|
||||
// they describe the browser's connection, not the turn.
|
||||
//
|
||||
// The sidecar builds these, commits them to chat_session_events, and hands officer a finished message
|
||||
// plus its cursor id; officer relays it verbatim. `prevSeq` is the writer's continuity claim — the
|
||||
// cursor of the previous durable message in the same session — which lets a reconnecting client tell a
|
||||
// contiguous replay from one with a hole in it. Absent when the writer cannot vouch for it.
|
||||
export type TurnMessageType =
|
||||
| 'assistant:delta'
|
||||
| 'assistant:text'
|
||||
| 'tool:start'
|
||||
| 'tool:result'
|
||||
| 'result'
|
||||
| 'error'
|
||||
| 'stopped'
|
||||
| 'task:started'
|
||||
| 'task:notification';
|
||||
|
||||
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
|
||||
|
||||
export type ChatEvent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'delta'; text: string }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { ClientMessage, ServerMessage, Message, ChatEvent } from './types';
|
||||
import type { ClientMessage, ServerMessage, Message, ChatEvent, TurnMessage, UserSession } from './types';
|
||||
import { sessionManager } from './session-manager';
|
||||
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||
@@ -165,6 +165,82 @@ export function close(ws: ServerWebSocket<WSData>): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Claude Code: relay, don't rebuild ──
|
||||
|
||||
// The agent sidecar owns Claude's turn output end to end — it translates the parser stream, commits each
|
||||
// message to chat_session_events and hands us a finished message plus its cursor id. Officer relays it.
|
||||
// That is what makes a restart survivable: the durable record no longer travels over the socket between
|
||||
// the two processes, so if this one is down the output is already written and the client replays it.
|
||||
//
|
||||
// Officer keeps only the in-memory transcript, which exists to answer a `resume` with sync:messages —
|
||||
// Claude's own transcript is the real record — so it is folded from the same messages, not rebuilt.
|
||||
function foldIntoSession(session: UserSession, msg: TurnMessage, model: string): void {
|
||||
switch (msg.type) {
|
||||
case 'assistant:delta':
|
||||
session.streamBuffer += msg.text;
|
||||
break;
|
||||
|
||||
case 'assistant:text':
|
||||
session.messages.push({ id: randomUUID(), timestamp: Date.now(), role: 'assistant', text: msg.text, model });
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
break;
|
||||
|
||||
case 'tool:start':
|
||||
session.messages.push({
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'tool',
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
});
|
||||
session.meta.messageCount += 1;
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i]!;
|
||||
if (m.role === 'tool' && m.toolCallId === msg.toolCallId) {
|
||||
m.output = msg.output;
|
||||
m.isError = msg.isError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'result': {
|
||||
session.isGenerating = false;
|
||||
session.meta.cost.inputTokens += msg.cost.inputTokens;
|
||||
session.meta.cost.outputTokens += msg.cost.outputTokens;
|
||||
session.meta.cost.totalUSD += msg.cost.totalUSD;
|
||||
session.meta.updatedAt = Date.now();
|
||||
// The turn's cost belongs to the assistant message it paid for (as it did when officer built these).
|
||||
const last = session.messages[session.messages.length - 1];
|
||||
if (last?.role === 'assistant' && !last.cost) last.cost = msg.cost;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
case 'stopped':
|
||||
session.isGenerating = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function createClaudeMessageHandler(sessionId: string, model: string) {
|
||||
return (msg: TurnMessage, seq?: number): void => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) return;
|
||||
foldIntoSession(session, msg, model);
|
||||
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
|
||||
};
|
||||
}
|
||||
|
||||
// ── OpenCode: officer still translates and persists ──
|
||||
|
||||
// Unchanged from before the split, and still correct for OpenCode: that sidecar reports raw ChatEvents,
|
||||
// so officer does the translation and owns the durable write. Moving it is the `sidecars-opencode` branch.
|
||||
function createEventHandler(sessionId: string, model: string, cwd: string) {
|
||||
return async (event: ChatEvent): Promise<void> => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
@@ -298,13 +374,23 @@ function createEventHandler(sessionId: string, model: string, cwd: string) {
|
||||
|
||||
case 'task:started': {
|
||||
// Background task launched (run_in_background / Monitor). Independent of turn state.
|
||||
await emitToSession(sessionId, { type: 'task:started', taskId: event.taskId, description: event.description, taskType: event.taskType });
|
||||
await emitToSession(sessionId, {
|
||||
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.
|
||||
await emitToSession(sessionId, { type: 'task:notification', taskId: event.taskId, status: event.status, summary: event.summary });
|
||||
await emitToSession(sessionId, {
|
||||
type: 'task:notification',
|
||||
taskId: event.taskId,
|
||||
status: event.status,
|
||||
summary: event.summary,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -401,7 +487,7 @@ async function handleClaudeCodeChat(
|
||||
|
||||
session.isGenerating = true;
|
||||
|
||||
const onEvent = createEventHandler(sessionId, model, cwd);
|
||||
const onMessage = createClaudeMessageHandler(sessionId, model);
|
||||
|
||||
try {
|
||||
if (!session._claudeKill) {
|
||||
@@ -416,7 +502,7 @@ async function handleClaudeCodeChat(
|
||||
cwd,
|
||||
model,
|
||||
resumeSessionId: msg.resumeSessionId,
|
||||
onEvent,
|
||||
onMessage,
|
||||
});
|
||||
session.piProcess = sessionId as any;
|
||||
session._claudeKill = handle.kill;
|
||||
@@ -572,7 +658,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
if (isClaudeModel(session.model)) {
|
||||
// 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);
|
||||
void sidecar.interruptClaude(sessionId);
|
||||
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
|
||||
} else {
|
||||
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getTaskByDirName } from './task-files';
|
||||
import { getHomeDir } from '../../data-path';
|
||||
import { resolveBaseCwd } from '../chat/websocket';
|
||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||
import type { ChatEvent, MessageCost } from '../chat/types';
|
||||
import type { TurnMessage, MessageCost } from '../chat/types';
|
||||
import * as jobManager from './pipeline-job-manager';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-code';
|
||||
@@ -148,23 +148,26 @@ async function runAgenticStep({
|
||||
fn();
|
||||
};
|
||||
|
||||
const onEvent = (event: ChatEvent) => {
|
||||
// The agent sidecar now hands over finished turn messages rather than raw parser events, so this is a
|
||||
// re-label onto the pipeline's own event stream. The `seq` is ignored: pipeline steps have their own
|
||||
// durable record (the job's events), not the chat cursor.
|
||||
const onMessage = (msg: TurnMessage) => {
|
||||
if (abortSignal.aborted) return;
|
||||
lastActivity = Date.now();
|
||||
|
||||
switch (event.type) {
|
||||
case 'delta':
|
||||
emit({ type: 'assistant:delta', text: event.text, stepIndex, iterationLabel });
|
||||
switch (msg.type) {
|
||||
case 'assistant:delta':
|
||||
emit({ type: 'assistant:delta', text: msg.text, stepIndex, iterationLabel });
|
||||
break;
|
||||
case 'text':
|
||||
emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel });
|
||||
case 'assistant:text':
|
||||
emit({ type: 'assistant:text', text: msg.text, stepIndex, iterationLabel });
|
||||
break;
|
||||
case 'tool:start':
|
||||
emit({
|
||||
type: 'tool:start',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
stepIndex,
|
||||
iterationLabel,
|
||||
});
|
||||
@@ -172,25 +175,29 @@ async function runAgenticStep({
|
||||
case 'tool:result':
|
||||
emit({
|
||||
type: 'tool:result',
|
||||
toolCallId: event.toolCallId,
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
toolCallId: msg.toolCallId,
|
||||
output: msg.output,
|
||||
isError: msg.isError,
|
||||
stepIndex,
|
||||
iterationLabel,
|
||||
});
|
||||
break;
|
||||
case 'result':
|
||||
case 'result': {
|
||||
const cost = msg.cost;
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
resolve(event.cost);
|
||||
resolve(cost);
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
}
|
||||
case 'error': {
|
||||
const message = msg.message;
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error(event.message));
|
||||
reject(new Error(message));
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'stopped':
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
@@ -236,7 +243,8 @@ async function runAgenticStep({
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
onEvent,
|
||||
durable: false,
|
||||
onMessage,
|
||||
});
|
||||
cleanup = handle.kill;
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user