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>
42 lines
1.9 KiB
TypeScript
42 lines
1.9 KiB
TypeScript
/**
|
|
* v2.6 Phase 2 — warm-vs-one-shot routing predicate for goose/qwen.
|
|
*
|
|
* The warm ACP backend keys its persistent process + ACP session on (chat_id,
|
|
* agent) — exactly like the opencode-server backend. A task therefore only routes
|
|
* to the warm pool when it carries BOTH a `session_id` and a `chat_id`, i.e. it
|
|
* came from a real chat tab (the coder message route + skills route stamp both).
|
|
*
|
|
* Session-less creators — arena contestants, MCP-created tasks, generic
|
|
* `POST /api/tasks`, `new_task` — leave one or both null. Those keep the existing
|
|
* one-shot worktree-per-task ACP path (`runExternalAgent`), which spawns a fresh
|
|
* `goose acp` / `qwen --acp` per turn and never holds a warm process. Routing them
|
|
* warm would either synthesize a degenerate (null, agent) key or create a chat per
|
|
* arena contestant — neither is wanted, so they stay one-shot.
|
|
*
|
|
* Pure, so it's unit-testable; the dispatcher consumes it.
|
|
*/
|
|
const WARM_CAPABLE_AGENTS = new Set(['goose', 'qwen']);
|
|
|
|
export function shouldUseWarmBackend(task: {
|
|
agent: string | null;
|
|
session_id: string | null;
|
|
chat_id: string | null;
|
|
}): boolean {
|
|
if (!task.agent || !WARM_CAPABLE_AGENTS.has(task.agent)) return false;
|
|
return task.session_id != null && task.chat_id != null;
|
|
}
|
|
|
|
/**
|
|
* Map an ACP prompt `stopReason` to the backend's ok/fail contract (TurnResult.ok).
|
|
*
|
|
* ACP's `StopReason` union includes normal completions (`end_turn`, `max_tokens`,
|
|
* `max_turn_requests`) and abnormal ones (`refusal`, `cancelled`). Only the latter
|
|
* two read as a failed turn; everything else (including an undefined/absent reason,
|
|
* which we default to `end_turn`) is a successful completion. Pure so it's testable
|
|
* independently of the warm process.
|
|
*/
|
|
export function isTurnOkForStopReason(stopReason: string | null | undefined): boolean {
|
|
const reason = stopReason ?? 'end_turn';
|
|
return reason !== 'refusal' && reason !== 'cancelled';
|
|
}
|