feat(coder): v2.6 Phase 2 — warm ACP backend for goose/qwen
WarmAcpBackend (AgentBackend) holds one persistent goose acp / qwen --acp child + ClientSideConnection + ACP session per (chat,agent); initialize+session/new once, reused across turns. Abort = session/cancel the prompt only (never kills the child); child exit -> agent_sessions.status='crashed' -> re-spawn next turn. Dispatcher routes goose/qwen chat-tab tasks to the pooled warm backend via pure shouldUseWarmBackend (needs session_id+chat_id); one-shot runExternalAgent kept as fallback for arena/MCP/new_task. handleSessionUpdate extracted to a shared pure acp-event-map.ts (one-shot path byte-identical). SDK: installed @agentclientprotocol/sdk@^0.22.1 has stable resumeSession/loadSession; resume moot in the warm hot path, deferred to Phase 3. 15 new tests (warm-acp-routing, acp-event-map); 180 coder tests pass; tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import { persistExternalAgentTurn } from './agent-turn-persist.js';
|
||||
import { snapshotToWireToolCall, type AcpToolSnapshot } from './acp-tool-snapshot.js';
|
||||
import { agentPool } from './agent-pool.js';
|
||||
import { OpenCodeServerBackend } from './backends/opencode-server.js';
|
||||
import { WarmAcpBackend } from './backends/warm-acp.js';
|
||||
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
|
||||
import type { AgentBackend, AgentEvent } from './agent-backend.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
@@ -121,10 +123,15 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
|
||||
`;
|
||||
if (agentRow) {
|
||||
// v2.6 (1.7): opencode routes to the warm pool backend; every other
|
||||
// external agent keeps the existing one-shot ACP/PTY path untouched.
|
||||
// v2.6 (1.7): opencode routes to its warm HTTP-server backend.
|
||||
// v2.6 Phase 2 (2.4): goose/qwen route to the warm ACP backend WHEN the
|
||||
// task came from a real chat tab (session_id + chat_id) — shouldUseWarmBackend.
|
||||
// Session-less creators (arena, MCP, new_task, generic /api/tasks) keep the
|
||||
// existing one-shot worktree-per-task ACP/PTY path untouched.
|
||||
if (task.agent === 'opencode') {
|
||||
await runOpenCodeServerTask(task, agentRow.install_path);
|
||||
} else if (shouldUseWarmBackend(task)) {
|
||||
await runWarmAcpTask(task, agentRow.install_path);
|
||||
} else {
|
||||
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||
}
|
||||
@@ -788,6 +795,245 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Path B (warm ACP): goose / qwen warm backend (v2.6 Phase 2) ─────────────
|
||||
|
||||
// Warm ACP backends are per (chat, agent): each owns ONE stdio process + ACP
|
||||
// connection + session. Pool key = chatId; the AgentPool's secondary key is the
|
||||
// agent. This mirrors agent_sessions' (chat_id, agent) PK.
|
||||
function getWarmAcpBackend(chatId: string, agent: string, installPath: string | null): WarmAcpBackend {
|
||||
let backend = agentPool.get(chatId, agent);
|
||||
if (!backend) {
|
||||
backend = new WarmAcpBackend({
|
||||
sql,
|
||||
log,
|
||||
chatId,
|
||||
agent,
|
||||
installPath,
|
||||
resolved: getResolvedRegistry().get(agent),
|
||||
});
|
||||
agentPool.register(chatId, agent, backend);
|
||||
}
|
||||
return backend as WarmAcpBackend;
|
||||
}
|
||||
|
||||
async function runWarmAcpTask(
|
||||
task: {
|
||||
id: string;
|
||||
project_id: string;
|
||||
input: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
session_id: string | null;
|
||||
chat_id: string | null;
|
||||
},
|
||||
installPath: string | null,
|
||||
): Promise<void> {
|
||||
const taskId = task.id;
|
||||
const agent = task.agent!;
|
||||
// shouldUseWarmBackend guarantees both non-null before we get here.
|
||||
const sessionId = task.session_id!;
|
||||
const chatId = task.chat_id!;
|
||||
log.info({ taskId, agent, chatId }, 'dispatcher: starting task (path B — warm ACP)');
|
||||
|
||||
const [project] = await sql<{ path: string | null }[]>`
|
||||
SELECT path FROM projects WHERE id = ${task.project_id}
|
||||
`;
|
||||
const projectPath = project?.path;
|
||||
if (!projectPath) {
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
|
||||
try {
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'running', started_at = clock_timestamp(), execution_path = 'acp'
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
|
||||
// Persistent, session-keyed worktree (shared across turns + agents; NOT torn
|
||||
// down per turn — Phase 3 reaps it). Same as the opencode-server path so a
|
||||
// chat that switches opencode↔goose↔qwen shares one worktree.
|
||||
const { worktreeId, worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
|
||||
signal: ac.signal,
|
||||
});
|
||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready (warm ACP)');
|
||||
|
||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
role: 'assistant',
|
||||
} as WsFrame);
|
||||
|
||||
const manifestCommands = getManifestCommands(agent);
|
||||
if (manifestCommands.length > 0) {
|
||||
setTaskCommands(taskId, manifestCommands);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'agent_commands',
|
||||
task_id: taskId,
|
||||
session_id: sessionId,
|
||||
commands: manifestCommands,
|
||||
} as WsFrame);
|
||||
}
|
||||
|
||||
// Accumulate the turn's stream for persistence + the final message content.
|
||||
const textChunks: string[] = [];
|
||||
const reasoningChunks: string[] = [];
|
||||
const toolSnaps = new Map<string, AcpToolSnapshot>();
|
||||
|
||||
// Map transport-agnostic AgentEvents → the SAME WS frames the one-shot ACP
|
||||
// path emits (identical to runOpenCodeServerTask's onEvent). No dcp stripping:
|
||||
// that's an opencode-plugin artifact; goose/qwen don't emit dcp tags.
|
||||
const onEvent = (e: AgentEvent): void => {
|
||||
switch (e.type) {
|
||||
case 'text':
|
||||
textChunks.push(e.text);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
content: e.text,
|
||||
} as WsFrame);
|
||||
break;
|
||||
case 'reasoning':
|
||||
reasoningChunks.push(e.text);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'reasoning_delta',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
content: e.text,
|
||||
} as WsFrame);
|
||||
break;
|
||||
case 'tool_call':
|
||||
case 'tool_update':
|
||||
toolSnaps.set(e.toolCall.toolCallId, e.toolCall);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'tool_call',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
tool_call: snapshotToWireToolCall(e.toolCall),
|
||||
} as WsFrame);
|
||||
break;
|
||||
case 'commands':
|
||||
if (e.commands.length > 0) {
|
||||
setTaskCommands(taskId, e.commands);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'agent_commands',
|
||||
task_id: taskId,
|
||||
session_id: sessionId,
|
||||
commands: e.commands,
|
||||
} as WsFrame);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const model = task.model ?? undefined;
|
||||
const backend = getWarmAcpBackend(chatId, agent, installPath);
|
||||
const handle = await backend.ensureSession(sessionId, {
|
||||
agent,
|
||||
model: model ?? '',
|
||||
chatId,
|
||||
worktreePath,
|
||||
worktreeId,
|
||||
projectId: task.project_id,
|
||||
});
|
||||
const result = await backend.prompt(handle, task.input, {
|
||||
worktreePath,
|
||||
model: model ?? '',
|
||||
signal: ac.signal,
|
||||
onEvent,
|
||||
taskId,
|
||||
modeId: task.mode_id ?? undefined,
|
||||
});
|
||||
|
||||
const assistantContent = textChunks.join('').slice(0, 50_000);
|
||||
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
|
||||
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'warm ACP turn failed').slice(0, 500);
|
||||
|
||||
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
|
||||
|
||||
await sql`
|
||||
UPDATE messages
|
||||
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantId}
|
||||
`;
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
} as WsFrame);
|
||||
|
||||
if (stopping) {
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
|
||||
// Diff the persistent worktree against its captured baseline and SUPERSEDE
|
||||
// the session's prior pending row (latest-wins) — identical to opencode.
|
||||
const diff = await diffWorktree(worktreePath, projectPath, {
|
||||
signal: ac.signal,
|
||||
baseRef: baseCommit ?? 'HEAD',
|
||||
});
|
||||
if (diff) {
|
||||
await sql`
|
||||
DELETE FROM pending_changes WHERE session_id = ${sessionId} AND status = 'pending'
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}, ${agent})
|
||||
`;
|
||||
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff superseded prior pending change (warm ACP)');
|
||||
} else {
|
||||
log.info({ taskId }, 'dispatcher: no changes detected in session worktree (warm ACP)');
|
||||
}
|
||||
|
||||
// NO worktree cleanup — persistent (Phase 3 reaps it). Backend stays warm.
|
||||
|
||||
const [extCostRow] = await sql<{ total: number | null }[]>`
|
||||
SELECT SUM(tokens_used)::int AS total
|
||||
FROM messages
|
||||
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
|
||||
`;
|
||||
const extCostTokens = extCostRow?.total ?? null;
|
||||
|
||||
const finalState = result.ok ? 'completed' : 'failed';
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = ${finalState}, ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)');
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log.error({ taskId, agent, err: errMsg }, 'dispatcher: warm ACP error');
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForCompletion(assistantId: string): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user