feat(coder): v2.6 Phase 1 — OpenCode warm server backend
Persistent multi-turn opencode backend: one `opencode serve` HTTP server per BooCoder process, one opencode session per BooCode session (resumed on switch-back), single SSE read loop demuxed by session id. - backends/opencode-server.ts: AgentBackend implementation — spawn with waitForReady, session.next.* SSE event translation (text/reasoning/tool deltas), Paseo-ported reasoning dedup (streamedPartKeys), promptAsync fire-and-forget settled by session.idle, per-turn inactivity watchdog (180s) + reconnect reconciliation via session.messages, stale-session guard (crashed-not-resumed + config_hash fingerprint on model). - dispatcher.ts: opencode routes to pool backend (ensureSession→prompt); per-session concurrency Map replaces global running boolean (1.9); model coalesce (empty→DEFAULT_MODEL) + llama-swap/ prefix for opencode; diff-supersede (DELETE+INSERT pending_changes by session, stamp agent). - worktrees.ts: ensureSessionWorktree (session-keyed, captures base_commit, persists to session_worktrees); diffWorktree gains optional baseRef. - agent-probe.ts: mergeLlamaSwap branch fetches /v1/models, prefixes with llama-swap/, populates opencode's available_agents.models (was 0). - provider-snapshot.ts: export fetchLlamaSwapModels for probe reuse. - schema.sql: session_worktrees + agent_sessions tables (Phase 0) + config_hash column on agent_sessions, pending_changes.agent column. - package.json: @opencode-ai/sdk ~1.15.0 (resolved 1.15.12). Known Phase 1 limitation: single SSE stream scoped to most-recent session's directory; concurrent opencode sessions in different worktrees collide (warning logged, watchdog prevents hang). Phase 2 moves to per-session SSE. Smoke 1 verified: two turns in one session, both produce real tokens, same agent_session_id reused, same server port, turn 2 is 9x faster (no spawn). goose/qwen/claude paths untouched (runExternalAgent md5 identical). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,17 @@ import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||
import { dispatchViaPty } from './pty-dispatch.js';
|
||||
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
||||
import { getManifestCommands } from './provider-commands.js';
|
||||
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 type { AgentBackend, AgentEvent } from './agent-backend.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
@@ -35,47 +39,65 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
const { sql, inference, broker, log, config } = deps;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let listener: { unlisten: () => Promise<void> } | null = null;
|
||||
let running = false;
|
||||
let polling = false;
|
||||
let stopping = false;
|
||||
let inflightPromise: Promise<void> | null = null;
|
||||
// v2.6 (1.9): per-session in-flight registry replaces the global `running`
|
||||
// boolean. Key = session_id (or `task:<id>` for sessionless tasks). Sessions
|
||||
// without an in-flight turn run concurrently; within a session, strictly one
|
||||
// turn at a time.
|
||||
const inflight = new Map<string, Promise<void>>();
|
||||
|
||||
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
|
||||
// `running`/`stopping` guard makes this safe to call concurrently — a notify
|
||||
// arriving mid-task returns immediately and never double-dispatches.
|
||||
// `polling`/`stopping` guard makes this safe to call concurrently — a notify
|
||||
// arriving mid-poll returns immediately and never double-dispatches.
|
||||
function triggerPoll(reason: string): void {
|
||||
poll().catch((err) => {
|
||||
log.error({ err, reason }, 'dispatcher: poll error');
|
||||
});
|
||||
}
|
||||
|
||||
function concurrencyKey(task: { id: string; session_id: string | null }): string {
|
||||
return task.session_id ?? `task:${task.id}`;
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (running || stopping) return;
|
||||
|
||||
// Grab one pending task
|
||||
const rows = await sql<{
|
||||
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;
|
||||
}[]>`
|
||||
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
|
||||
FROM tasks
|
||||
WHERE state = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT 1
|
||||
`;
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const task = rows[0]!;
|
||||
running = true;
|
||||
inflightPromise = runTask(task).finally(() => {
|
||||
running = false;
|
||||
inflightPromise = null;
|
||||
});
|
||||
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
||||
// concurrently) so we never double-select a task. It does NOT serialize task
|
||||
// execution — that's what `inflight` (keyed per session) governs.
|
||||
if (polling || stopping) return;
|
||||
polling = true;
|
||||
try {
|
||||
// Oldest-first; start every pending task whose session isn't already busy.
|
||||
const rows = await sql<{
|
||||
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;
|
||||
}[]>`
|
||||
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
|
||||
FROM tasks
|
||||
WHERE state = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT 50
|
||||
`;
|
||||
for (const task of rows) {
|
||||
if (stopping) break;
|
||||
const key = concurrencyKey(task);
|
||||
if (inflight.has(key)) continue; // this session already has an in-flight turn
|
||||
// Register synchronously (before any await) so a later row in this pass
|
||||
// with the same key is skipped and a concurrent poll can't re-pick it.
|
||||
const p = runTask(task).finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, p);
|
||||
}
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTask(task: {
|
||||
@@ -96,7 +118,13 @@ 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) {
|
||||
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||
// v2.6 (1.7): opencode routes to the warm pool backend; every other
|
||||
// external agent keeps the existing one-shot ACP/PTY path untouched.
|
||||
if (task.agent === 'opencode') {
|
||||
await runOpenCodeServerTask(task, agentRow.install_path);
|
||||
} else {
|
||||
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Agent specified but not available — fall through to Path A with a warning
|
||||
@@ -456,6 +484,274 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Path B (opencode): warm OpenCode server backend (v2.6 1.7 + 1.10) ───────
|
||||
|
||||
// OpenCode runs ONE server per BooCoder process, shared across all sessions
|
||||
// (the backend multiplexes sessions internally), so it's pooled under a fixed
|
||||
// key rather than per-session. Warm ACP backends (Phase 2) will be per-session.
|
||||
const OPENCODE_POOL_KEY = '__opencode_server__';
|
||||
|
||||
function getOpenCodeBackend(installPath: string | null): AgentBackend {
|
||||
let backend = agentPool.get(OPENCODE_POOL_KEY, 'opencode');
|
||||
if (!backend) {
|
||||
backend = new OpenCodeServerBackend({ sql, log, opencodeBinary: installPath ?? 'opencode' });
|
||||
agentPool.register(OPENCODE_POOL_KEY, 'opencode', backend);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
async function runOpenCodeServerTask(
|
||||
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;
|
||||
},
|
||||
installPath: string | null,
|
||||
): Promise<void> {
|
||||
const taskId = task.id;
|
||||
const agent = 'opencode';
|
||||
log.info({ taskId, agent }, 'dispatcher: starting task (path B — opencode server)');
|
||||
|
||||
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 {
|
||||
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
|
||||
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
|
||||
// agent_sessions.backend. Reuse the closest existing value.
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'running', started_at = clock_timestamp(), execution_path = 'acp'
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
|
||||
// Resolve session + chat (mirrors runExternalAgent).
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
||||
`;
|
||||
if (chats.length === 0) {
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = chat!.id;
|
||||
} else {
|
||||
chatId = chats[0]!.id;
|
||||
}
|
||||
} else {
|
||||
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status)
|
||||
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
sessionId = session!.id;
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = chat!.id;
|
||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||
}
|
||||
|
||||
if (!task.session_id) {
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||
`;
|
||||
}
|
||||
|
||||
// Persistent, session-keyed worktree (shared across turns; NOT torn down
|
||||
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
|
||||
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
|
||||
signal: ac.signal,
|
||||
});
|
||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
||||
|
||||
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 ACP path emits.
|
||||
// This boundary is where message_id/chat_id get attached (the backend never
|
||||
// owns them).
|
||||
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':
|
||||
// opencode-server doesn't emit these today; ignore if it ever does.
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// opencode expects provider-prefixed model ids (e.g. 'llama-swap/qwen3.6-35b…').
|
||||
// DEFAULT_MODEL is bare (no prefix) because native inference uses it directly
|
||||
// against llama-swap. Coalesce empty string (frontend sends '' when no models
|
||||
// listed) and prefix bare ids so parseModel always succeeds.
|
||||
const rawModel = (task.model && task.model.trim()) || config.DEFAULT_MODEL;
|
||||
const model = rawModel.includes('/') ? rawModel : `llama-swap/${rawModel}`;
|
||||
const backend = getOpenCodeBackend(installPath);
|
||||
const handle = await backend.ensureSession(sessionId, {
|
||||
agent,
|
||||
model,
|
||||
worktreePath,
|
||||
projectId: task.project_id,
|
||||
});
|
||||
const result = await backend.prompt(handle, task.input, {
|
||||
worktreePath,
|
||||
model,
|
||||
signal: ac.signal,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
const assistantContent = textChunks.join('').slice(0, 50_000);
|
||||
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
|
||||
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode 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
|
||||
}
|
||||
|
||||
// 1.10: diff the persistent worktree against its captured baseline and
|
||||
// SUPERSEDE the session's prior pending row (latest-wins, one accumulating
|
||||
// diff) instead of stacking. Stamp agent for DiffPanel attribution.
|
||||
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');
|
||||
} else {
|
||||
log.info({ taskId }, 'dispatcher: no changes detected in session worktree');
|
||||
}
|
||||
|
||||
// NO worktree cleanup — it's 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, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log.error({ taskId, agent, err: errMsg }, 'dispatcher: opencode server 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> {
|
||||
@@ -514,9 +810,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
});
|
||||
listener = null;
|
||||
}
|
||||
if (inflightPromise) {
|
||||
log.info('dispatcher: waiting for in-flight task');
|
||||
await inflightPromise;
|
||||
if (inflight.size > 0) {
|
||||
log.info({ count: inflight.size }, 'dispatcher: waiting for in-flight tasks');
|
||||
await Promise.allSettled([...inflight.values()]);
|
||||
}
|
||||
log.info('dispatcher: stopped');
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user