Files
boocode/apps/coder/src/services/backends/opencode-server.ts
indifferentketchup 8c200216eb refactor: codebase audit cleanup — dead code, dedup, module splits
Multi-agent audit + aggressive cleanup across server/web/coder/booterm,
delivered behind a DEFER discipline so none of the in-flight files were
touched. Removes dead code/deps/columns, dedups server + coder helpers,
and splits the oversized modules (tools.ts, opencode-server.ts,
sentinel-summaries, turn.ts, TerminalPane.tsx) behind stable contracts.
Adds 78 parity/unit tests (server 587, coder 323); fixes two latent bugs
(ChatPane queue keys, FileViewerOverlay blank-line parity).

Intended tag: v2.7.12-audit-cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:12:29 +00:00

608 lines
24 KiB
TypeScript

/**
* v2.6 Phase 1 — OpenCodeServerBackend (slimmed, v2.7 audit reshape).
*
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
* server per BooCoder process; one opencode session per BooCode session (resumed
* on switch-back); one SSE read loop PER session, each scoped to that session's
* worktree directory so sessions in different directories stream concurrently.
*
* This file is now just the `AgentBackend` SURFACE — ensureSession / prompt /
* accumulateUsage / closeSession + the per-session demux side effects (watchdog,
* reconcile, usage). It composes three extracted collaborators:
* - `OpenCodeServerSupervisor` (opencode-server-process.ts) — child/client/port/
* health lifecycle, spawn/crash/restart/dispose.
* - the per-session SSE loop (opencode-sse.ts) — subscribe + reconnect/backoff.
* - the pure event map (opencode-event-map.ts) — Event → AgentEvent translation,
* dedup gate, dcp-strip, tool-snapshot.
*
* Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
* `AgentEvent`s; the dispatcher maps them to WS frames.
*
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §2a.
*/
import { createHash } from 'node:crypto';
import type { FastifyBaseLogger } from 'fastify';
import type { Event, AssistantMessage } from '@opencode-ai/sdk/v2/client';
import type { Sql } from '../../db.js';
import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js';
import { stepEndedToUsage, type StepUsage } from './opencode-usage.js';
import { OpenCodeServerSupervisor, type ServerDownInfo } from './opencode-server-process.js';
import {
startSessionEventLoop,
type SessionState,
type TurnState,
type SseLoopDeps,
} from './opencode-sse.js';
import {
classifyPartDelta,
classifyUpdatedPart,
toolCalledSnapshot,
toolSuccessSnapshot,
toolFailedSnapshot,
stripDcpTags,
errMsg,
errToString,
} from './opencode-event-map.js';
import type {
AgentBackend,
AgentSessionHandle,
EnsureSessionOpts,
PromptCtx,
TurnResult,
} from '../agent-backend.js';
/**
* No-activity backstop for an in-flight turn. opencode streams reasoning/text/tool
* deltas continuously while working, so "zero events for this long" means the turn
* is wedged or its terminal event (session.idle) was lost. Generous so a
* legitimately slow turn never trips it.
*/
const TURN_INACTIVITY_MS = 180_000;
export interface OpenCodeServerBackendDeps {
sql: Sql;
log: FastifyBaseLogger;
/** Absolute path to the opencode binary (resolved from available_agents at wiring time, Phase 1.7). */
opencodeBinary: string;
}
export class OpenCodeServerBackend implements AgentBackend {
readonly backend = 'opencode_server' as const;
private readonly sql: Sql;
private readonly log: FastifyBaseLogger;
private readonly supervisor: OpenCodeServerSupervisor;
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
private readonly byOpencodeId = new Map<string, SessionState>();
/** Coalesces concurrent ensureSession calls for the same (chat, agent) key. */
private readonly ensuring = new Map<string, Promise<AgentSessionHandle>>();
constructor(deps: OpenCodeServerBackendDeps) {
this.sql = deps.sql;
this.log = deps.log;
this.supervisor = new OpenCodeServerSupervisor({
opencodeBinary: deps.opencodeBinary,
log: deps.log,
hooks: {
isBusy: () => this.isBusy(),
onServerDown: (info) => this.onServerDown(info),
},
});
}
/** §2: liveness for the health endpoint + dispatcher fallback decision. */
health(): 'up' | 'down' {
return this.supervisor.health();
}
/** Phase 3: busy iff ANY pooled opencode session has an in-flight turn. */
isBusy(): boolean {
for (const st of this.byOpencodeId.values()) {
if (st.activeTurn) return true;
}
return false;
}
/** Phase 3 proactive health probe + busy-aware self-restart, run by the pool's
* periodic sweep. Delegates to the supervisor. */
async tickHealth(now: number = Date.now()): Promise<void> {
await this.supervisor.tickHealth(now);
}
/**
* Server down (crash-exit or forced restart): fail every in-flight turn so its
* dispatcher unblocks, mark each session crashed so ensureSession won't resume a
* now-dead native id, and tear down the SSE loops + demux state. Invoked by the
* supervisor (it owns the process/port reset). Mirrors the original
* handleServerCrash session-half byte-for-byte.
*/
private onServerDown(info: ServerDownInfo): void {
const states = [...this.byOpencodeId.values()];
this.log.warn(
{ code: info.code, signal: info.signal, port: info.port, liveSessions: states.length },
'opencode-server: child exited — recovering (fail in-flight, mark crashed, re-spawn next turn)',
);
const crashedIds: string[] = [];
for (const st of states) {
st.sseAbort?.abort();
if (st.activeTurn) {
st.activeTurn.settle({ ok: false, error: 'opencode server crashed mid-turn' });
st.activeTurn = null;
}
if (st.watchdog) {
clearTimeout(st.watchdog);
st.watchdog = null;
}
crashedIds.push(st.agentSessionId);
}
// Drop the demux map: every session id is stale against a fresh server.
this.byOpencodeId.clear();
if (crashedIds.length > 0) {
this.sql`
UPDATE agent_sessions SET status = 'crashed'
WHERE agent_session_id = ANY(${crashedIds}) AND status <> 'closed'
`.catch((err) => {
this.log.warn({ err: errMsg(err) }, 'opencode-server: failed to mark crashed sessions (non-fatal)');
});
}
}
// ─── SSE loop wiring ─────────────────────────────────────────────────────────
/** The dependency bundle the per-session SSE loop reads. */
private sseDeps(): SseLoopDeps {
return {
isUp: () => this.supervisor.isUp(),
getClient: () => this.supervisor.client,
dispatchEvent: (ev) => this.dispatchEvent(ev),
reconcile: (st) => this.reconcile(st),
onReconnectGiveUp: (st) => this.onReconnectGiveUp(st),
log: this.log,
};
}
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
private dispatchEvent(ev: Event): void {
switch (ev.type) {
// ─── session.next.* — live streaming events (the primary path) ─────────
case 'session.next.text.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const cleaned = stripDcpTags(p.delta);
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
return;
}
case 'session.next.reasoning.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
return;
}
case 'session.next.tool.called': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'tool_call', toolCall: toolCalledSnapshot(p) });
return;
}
case 'session.next.tool.success': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'tool_update', toolCall: toolSuccessSnapshot(p) });
return;
}
case 'session.next.tool.failed': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'tool_update', toolCall: toolFailedSnapshot(p) });
return;
}
// ─── per-step usage (U.6) — token/cost accounting for opencode sessions ──
case 'session.next.step.ended': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
// Accumulate this step's normalized usage onto the (chat_id, agent) row.
// Fire-and-forget: a DB hiccup must not stall the turn.
const usage = stepEndedToUsage(p);
void this.accumulateUsage(st, usage);
return;
}
// ─── message.part.* — terminal/post-hoc events (dedup gate) ────────────
case 'message.part.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const e = classifyPartDelta(p, st);
if (e) st.activeTurn.onEvent(e);
return;
}
case 'message.part.updated': {
const part = ev.properties.part;
const st = this.byOpencodeId.get(part.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const e = classifyUpdatedPart(part, st);
if (e) st.activeTurn.onEvent(e);
return;
}
// ─── lifecycle ─────────────────────────────────────────────────────────
case 'session.idle': {
const st = this.byOpencodeId.get(ev.properties.sessionID);
if (!st) return;
if (consumeTerminal(st) === 'swallow') return; // F.1: drop the post-abort orphan
st.activeTurn?.settle({ ok: true });
return;
}
case 'session.error': {
const sid = ev.properties.sessionID;
if (!sid) return;
const st = this.byOpencodeId.get(sid);
if (!st) return;
if (consumeTerminal(st) === 'swallow') return; // F.1: drop the post-abort orphan
st.activeTurn?.settle({ ok: false, error: errToString(ev.properties.error) });
return;
}
default:
return;
}
}
// ─── turn-completion resilience (watchdog + reconnect reconcile) ─────────────
/** Reset the inactivity backstop on any event routed to a session's active turn. */
private bumpActivity(st: SessionState): void {
if (!st.activeTurn) return;
// A live turn is producing → the post-abort orphan-terminal window is over.
noteTurnActivity(st);
if (st.watchdog) clearTimeout(st.watchdog);
st.watchdog = setTimeout(() => {
void this.onTurnStall(st);
}, TURN_INACTIVITY_MS);
st.watchdog.unref?.();
}
/** Watchdog fired: reconcile once; if still-running we can't tell, so fail closed.
* Also mark the agent_sessions row crashed so a stale session isn't resumed. */
private async onTurnStall(st: SessionState): Promise<void> {
const settled = await this.reconcile(st);
if (!settled) {
this.log.warn({ agentSessionId: st.agentSessionId }, 'opencode-server: turn stalled (no activity), failing + marking crashed');
await this.sql`
UPDATE agent_sessions SET status = 'crashed'
WHERE agent_session_id = ${st.agentSessionId}
`.catch(() => {});
st.activeTurn?.settle({ ok: false, error: 'turn timed out (no activity)' });
}
}
/** SSE circuit-breaker fired (reconnect gave up): fail the active turn + mark the
* session crashed so it isn't resumed. The next turn re-creates a fresh session. */
private async onReconnectGiveUp(st: SessionState): Promise<void> {
if (!st.activeTurn) return;
await this.sql`
UPDATE agent_sessions SET status = 'crashed'
WHERE agent_session_id = ${st.agentSessionId}
`.catch(() => {});
st.activeTurn?.settle({ ok: false, error: 'opencode SSE stream lost (reconnect gave up)' });
}
/**
* Ask the server whether this session's turn already finished — recovers a
* session.idle/error lost during an SSE gap. Returns true if it settled the turn.
*/
private async reconcile(st: SessionState): Promise<boolean> {
const turn = st.activeTurn;
const client = this.supervisor.client;
if (!turn || !client) return false;
try {
const res = await client.session.messages({
sessionID: st.agentSessionId,
directory: st.worktreePath,
});
if (res.error || !res.data) return false;
let lastAssistant: AssistantMessage | undefined;
for (let i = res.data.length - 1; i >= 0; i--) {
const info = res.data[i]!.info;
if (info.role === 'assistant') {
lastAssistant = info;
break;
}
}
if (!lastAssistant) return false;
if (lastAssistant.error != null) {
turn.settle({ ok: false, error: errToString(lastAssistant.error) });
return true;
}
if (lastAssistant.time.completed != null) {
turn.settle({ ok: true });
return true;
}
return false; // still running — the live stream will deliver session.idle
} catch {
return false; // inconclusive — watchdog backstop covers it
}
}
// ─── per-step usage persistence (U.6) ────────────────────────────────────────
/**
* Accumulate one `session.next.step.ended`'s normalized usage onto the session's
* agent_sessions row. Running totals for the whole conversation context. Zero-delta
* steps are skipped. Errors are swallowed: usage telemetry must never fail a turn.
*/
private async accumulateUsage(st: SessionState, u: StepUsage): Promise<void> {
if (u.input === 0 && u.output === 0 && u.cost === 0) return;
try {
await this.sql`
UPDATE agent_sessions SET
input_tokens = input_tokens + ${u.input},
output_tokens = output_tokens + ${u.output},
cost = cost + ${u.cost}
WHERE agent_session_id = ${st.agentSessionId}
`;
} catch (err) {
this.log.warn(
{ err: errMsg(err), agentSessionId: st.agentSessionId },
'opencode-server: failed to persist step usage (non-fatal)',
);
}
}
// ─── ensureSession: create-or-resume against agent_sessions (1.5) ────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
// Coalesce concurrent first-turns for the same (chat, agent) so the SELECT…
// create…upsert can't race into two opencode sessions (the second orphaning
// the first). A single (non-concurrent) call is unaffected — the entry is set
// and removed within this call. Defensive: the dispatcher already serializes
// turns per (chat, agent) via its inflight map.
const key = `${opts.chatId}:${opts.agent}`;
const existing = this.ensuring.get(key);
if (existing) return existing;
const p = this.ensureSessionInner(sessionId, opts).finally(() => {
if (this.ensuring.get(key) === p) this.ensuring.delete(key);
});
this.ensuring.set(key, p);
return p;
}
private async ensureSessionInner(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
await this.supervisor.ensureServer();
const client = this.supervisor.client;
if (!client) throw new Error('opencode-server: client not ready after ensureServer');
const configHash = sessionConfigHash(opts.model);
// P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the
// context unit (two tabs in one session = two contexts sharing one worktree).
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
SELECT agent_session_id, status, config_hash FROM agent_sessions
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`;
let agentSessionId = row?.agent_session_id ?? null;
// Don't resume crashed sessions or sessions whose config drifted (model change).
const shouldResume = agentSessionId
&& row!.status !== 'crashed'
&& (row!.config_hash == null || row!.config_hash === configHash);
if (!shouldResume) {
if (agentSessionId) {
this.log.info({ sessionId, oldStatus: row!.status, hashMatch: row!.config_hash === configHash },
'opencode-server: not resuming stale session, creating fresh');
this.byOpencodeId.delete(agentSessionId);
}
const created = await client.session.create({ directory: opts.worktreePath });
if (created.error || !created.data) {
throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`);
}
agentSessionId = created.data.id;
await this.sql`
INSERT INTO agent_sessions
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
VALUES
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.supervisor.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id,
backend = 'opencode_server',
agent_session_id = EXCLUDED.agent_session_id,
server_port = EXCLUDED.server_port,
status = 'active',
last_active_at = clock_timestamp(),
config_hash = EXCLUDED.config_hash
`;
} else {
await this.sql`
UPDATE agent_sessions
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.supervisor.port}, config_hash = ${configHash}
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`;
}
// Both branches above guarantee agentSessionId is non-null.
const ocSessionId = agentSessionId!;
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing
// entry (and any in-flight turn) — just refresh the routing fields.
let state = this.byOpencodeId.get(ocSessionId);
if (state) {
state.boocodeSessionId = sessionId;
state.worktreePath = opts.worktreePath;
} else {
state = this.makeSessionState(sessionId, ocSessionId, opts.worktreePath);
this.byOpencodeId.set(ocSessionId, state);
}
// Start this session's own SSE loop, scoped to its worktree directory. Both
// fresh-create and resume reach here; idempotent.
startSessionEventLoop(state, this.sseDeps());
return {
sessionId,
agent: opts.agent,
backend: 'opencode_server',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: ocSessionId,
serverPort: this.supervisor.port,
};
}
/** Fresh per-(opencode session) demux state. */
private makeSessionState(boocodeSessionId: string, agentSessionId: string, worktreePath: string): SessionState {
return {
boocodeSessionId,
agentSessionId,
worktreePath,
streamedPartKeys: new Set(),
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
sseAbort: null,
swallowNextTerminal: false,
};
}
// ─── prompt: send one turn (1.6) ─────────────────────────────────────────────
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
const client = this.supervisor.client;
if (!client) throw new Error('opencode-server: client not ready');
const oc = handle.agentSessionId;
if (!oc) throw new Error('opencode-server: handle has no agentSessionId');
let state = this.byOpencodeId.get(oc);
if (!state) {
state = this.makeSessionState(handle.sessionId, oc, ctx.worktreePath);
this.byOpencodeId.set(oc, state);
}
const session = state;
// v2.7 busy-assert: one in-flight turn per session. The dispatcher serializes
// turns per (chat, agent), so this never fires in normal dispatch — but if a
// second prompt arrives while one is live it would silently overwrite the slot
// and orphan the first turn, so reject instead.
if (session.activeTurn) {
return { ok: false, error: 'opencode-server: session already has an in-flight turn' };
}
// Authoritative per-turn directory for SDK routing + reconcile.
session.worktreePath = ctx.worktreePath;
// Defensive: ensureSession normally starts the loop, but if prompt is reached
// with a freshly-created state (no loop yet), start it so the turn streams.
startSessionEventLoop(session, this.sseDeps());
return await new Promise<TurnResult>((resolve) => {
let settled = false;
const cleanup = () => {
session.activeTurn = null;
if (session.watchdog) {
clearTimeout(session.watchdog);
session.watchdog = null;
}
session.streamedPartKeys.clear();
session.partTypeById.clear();
ctx.signal.removeEventListener('abort', onAbort);
};
const settle = (r: TurnResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(r);
};
const onAbort = () => {
// Abort the turn only — never the server.
client.session.abort({ sessionID: oc, directory: ctx.worktreePath }).catch(() => {});
// F.1: opencode emits one trailing session.idle/error for the cancelled
// turn — arm the guard so it's swallowed, not used to settle the next turn.
armAbortGuard(session);
settle({ ok: false, error: 'aborted' });
};
const turn: TurnState = { onEvent: ctx.onEvent, settle };
session.activeTurn = turn;
this.bumpActivity(session); // arm the inactivity backstop
if (ctx.signal.aborted) {
onAbort();
return;
}
ctx.signal.addEventListener('abort', onAbort, { once: true });
const model = parseModel(ctx.model);
client.session
.promptAsync({
sessionID: oc,
directory: ctx.worktreePath,
parts: [{ type: 'text', text: input }],
...(model ? { model } : {}),
})
.then((res) => {
// promptAsync is fire-and-forget (204); the turn completes via session.idle.
// Only a submission error settles here.
if (res.error) settle({ ok: false, error: errToString(res.error) });
})
.catch((err) => settle({ ok: false, error: errMsg(err) }));
});
}
// ─── teardown ────────────────────────────────────────────────────────────────
async closeSession(handle: AgentSessionHandle): Promise<void> {
if (handle.agentSessionId) {
// Stop this session's SSE loop before dropping its demux entry.
this.byOpencodeId.get(handle.agentSessionId)?.sseAbort?.abort();
this.byOpencodeId.delete(handle.agentSessionId);
}
await this.sql`
UPDATE agent_sessions SET status = 'closed'
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => {});
}
async dispose(): Promise<void> {
// Abort every per-session SSE loop so none survive the teardown.
for (const st of this.byOpencodeId.values()) st.sseAbort?.abort();
this.byOpencodeId.clear();
await this.supervisor.dispose();
}
}
// ─── helpers ──────────────────────────────────────────────────────────────────
/** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
if (!model || !model.trim()) return undefined;
const trimmed = model.trim();
const idx = trimmed.indexOf('/');
if (idx > 0 && idx < trimmed.length - 1) {
return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) };
}
// No slash but non-empty → infer llama-swap (the only configured provider).
if (idx < 0 && trimmed.length > 0) {
return { providerID: 'llama-swap', modelID: trimmed };
}
return undefined;
}
/** Hash of stable config — detects model changes across sessions without
* invalidating on ephemeral state like the random server port. */
function sessionConfigHash(model: string): string {
return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16);
}