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:
2026-05-30 20:37:11 +00:00
parent 140ff26204
commit e30a9e8b23
8 changed files with 1171 additions and 41 deletions

View File

@@ -6,6 +6,7 @@
* After the agent completes, we diff the worktree against HEAD and
* queue the diff into pending_changes.
*/
import type { Sql } from '../db.js';
import { hostExec } from './host-exec.js';
const WORKTREE_BASE = '/tmp/booworktrees';
@@ -45,7 +46,7 @@ export async function createWorktree(
export async function diffWorktree(
worktreePath: string,
projectPath: string,
opts?: { signal?: AbortSignal },
opts?: { signal?: AbortSignal; baseRef?: string },
): Promise<string> {
// First, commit any uncommitted changes in the worktree so we can diff branches
// Stage all changes
@@ -74,9 +75,13 @@ export async function diffWorktree(
{ signal: opts?.signal, timeoutMs: 15_000 },
);
// Diff the worktree branch against the parent commit (HEAD of main tree)
// Diff the worktree branch against the baseline. Per-task callers default to the
// main tree's current HEAD; the session-worktree (opencode) path passes the
// captured base_commit so the accumulated diff is stable across turns even if
// project HEAD advances.
const baseRef = opts?.baseRef ?? 'HEAD';
const diffResult = await hostExec(
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
`git -C ${shellEscape(projectPath)} diff ${shellEscape(baseRef)}...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
{ signal: opts?.signal, timeoutMs: 60_000 },
);
@@ -111,6 +116,72 @@ export async function cleanupWorktree(
).catch(() => {});
}
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
export interface SessionWorktree {
worktreePath: string;
baseCommit: string | null;
}
/**
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
* per-task `createWorktree`, this persists — it is NOT torn down per turn
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
* so the accumulating diff has a stable baseline across turns.
*
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
* collides with the per-task worktrees that arena/new_task/MCP still use.
*/
export async function ensureSessionWorktree(
sql: Sql,
projectPath: string,
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<SessionWorktree> {
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
`;
if (existing) {
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit };
}
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
const branchName = `session-${sessionId}`;
await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
// Capture the baseline commit BEFORE branching, so the diff is stable even if
// project HEAD later advances.
const headResult = await hostExec(
`git -C ${shellEscape(projectPath)} rev-parse HEAD`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
const baseCommit = headResult.exitCode === 0 ? headResult.stdout.trim() || null : null;
const result = await hostExec(
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (result.exitCode !== 0) {
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
}
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create.
await sql`
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
ON CONFLICT (session_id) DO NOTHING
`;
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
`;
return {
worktreePath: row?.worktree_path ?? worktreePath,
baseCommit: row?.base_commit ?? baseCommit,
};
}
/** Minimal shell escape for paths (single-quote wrapping). */
function shellEscape(s: string): string {
// Replace single quotes with escaped version, wrap in single quotes