From 140ff262046a00d041c0a95cfea4167a21e7f7ef Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 30 May 2026 02:50:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(coder):=20v2.6=20Phase=200=20=E2=80=94=20A?= =?UTF-8?q?gentBackend=20foundations=20(no=20behavior=20change)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema, interface, and service scaffold for v2.6 persistent agent sessions. Nothing in this batch alters runtime behavior. - schema.sql: add session_worktrees (one shared worktree per session, FK sessions(id)) and agent_sessions (one backend session per (session, agent), with backend/status CHECKs); add pending_changes.agent column for DiffPanel attribution. All three statements idempotent (IF NOT EXISTS). - services/agent-backend.ts: AgentBackend interface + AgentSessionHandle, EnsureSessionOpts, PromptCtx, TurnResult, and the normalized transport-agnostic AgentEvent union (text/reasoning/tool_call/tool_update/commands). Types only. - services/agent-pool.ts: lazy get-or-create AgentPool keyed by `${sessionId}:${agent}` + shared `agentPool` singleton. Empty in Phase 0. - index.ts: widen onClose to await dispatcher.stop() then agentPool.dispose() (pool empty, so dispose() is inert). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/coder/src/index.ts | 8 ++- apps/coder/src/schema.sql | 26 ++++++++ apps/coder/src/services/agent-backend.ts | 85 ++++++++++++++++++++++++ apps/coder/src/services/agent-pool.ts | 44 ++++++++++++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 apps/coder/src/services/agent-backend.ts create mode 100644 apps/coder/src/services/agent-pool.ts diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index 4223b6a..8436502 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -33,6 +33,7 @@ import { registerProviderRoutes } from './routes/providers.js'; import { registerWebSocket } from './routes/ws.js'; // Phase 4: dispatcher + agent probe import { createDispatcher } from './services/dispatcher.js'; +import { agentPool } from './services/agent-pool.js'; import { probeAgents } from './services/agent-probe.js'; import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js'; import { setPermissionHooks } from './services/permission-waiter.js'; @@ -178,7 +179,12 @@ async function main() { // Phase 4: dispatcher — polls tasks table and runs inference const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config }); dispatcher.start(); - app.addHook('onClose', () => dispatcher.stop()); + app.addHook('onClose', async () => { + // stop() first so in-flight dispatcher turns settle, then drain the pool. + // Pool is empty in Phase 0 (nothing spawns yet) — dispose() is inert. + await dispatcher.stop(); + await agentPool.dispose(); + }); // Register routes registerMessageRoutes(app, sql, broker, inferenceApi); diff --git a/apps/coder/src/schema.sql b/apps/coder/src/schema.sql index 28a1d5e..9e22c64 100644 --- a/apps/coder/src/schema.sql +++ b/apps/coder/src/schema.sql @@ -76,6 +76,32 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB; +-- v2.6: one shared worktree per session (all agents/panes in the session operate in it). +CREATE TABLE IF NOT EXISTS session_worktrees ( + session_id UUID PRIMARY KEY REFERENCES sessions(id), + worktree_path TEXT NOT NULL, + base_commit TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); + +-- v2.6: one backend session per (session, agent); resumed on switch-back. +CREATE TABLE IF NOT EXISTS agent_sessions ( + session_id UUID NOT NULL REFERENCES sessions(id), + agent TEXT NOT NULL, + backend TEXT NOT NULL, + agent_session_id TEXT, + server_port INTEGER, + status TEXT NOT NULL DEFAULT 'idle', + last_active_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (session_id, agent), + CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server', 'acp_warm')), + CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle', 'active', 'crashed', 'closed')) +); + +-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this). +ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT; + -- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes, -- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same -- transaction, so the dispatcher reacts immediately instead of waiting for the diff --git a/apps/coder/src/services/agent-backend.ts b/apps/coder/src/services/agent-backend.ts new file mode 100644 index 0000000..4884cad --- /dev/null +++ b/apps/coder/src/services/agent-backend.ts @@ -0,0 +1,85 @@ +/** + * v2.6 — AgentBackend abstraction (Phase 0 scaffold; types only, zero runtime logic). + * + * The core abstraction for persistent agent sessions. Two implementations land + * later: `OpenCodeServerBackend` (Phase 1, opencode HTTP server) and + * `WarmAcpBackend` (Phase 2, long-lived ACP process). Backends emit + * transport-agnostic `AgentEvent`s; the dispatcher maps them to WS frames. + * + * Nothing imports this file yet — it must compile standalone. + * Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2. + */ +import type { AcpToolSnapshot } from './acp-tool-snapshot.js'; +import type { AgentCommand } from './provider-types.js'; + +/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */ +export type AgentBackendKind = 'opencode_server' | 'acp_warm'; + +/** + * Normalized, transport-agnostic events a backend emits during a turn (§2). + * Derived from acp-dispatch's session-update handling, but WITHOUT the WS + * envelope (message_id/chat_id) — the dispatcher owns frame mapping. + * + * `tool_call` vs `tool_update` are kept distinct on purpose: acp-dispatch + * currently merges both into one snapshot frame, but opencode's SSE + * distinguishes tool-start from tool-result, so the contract carries both. + * `commands` mirrors the ACP `available_commands_update` path (v2.5.10). + */ +export type AgentEvent = + | { type: 'text'; text: string } + | { type: 'reasoning'; text: string } + | { type: 'tool_call'; toolCall: AcpToolSnapshot } + | { type: 'tool_update'; toolCall: AcpToolSnapshot } + | { type: 'commands'; commands: AgentCommand[] }; + +/** Params to establish (or look up) a backend session (§2). */ +export interface EnsureSessionOpts { + agent: string; + /** Resolved model id. */ + model: string; + /** Shared per-session worktree (one per `sessions.id`, not per pane). */ + worktreePath: string; + projectId: string; +} + +/** Opaque handle to a live backend session, persisted to `agent_sessions` (§2). */ +export interface AgentSessionHandle { + sessionId: string; + agent: string; + backend: AgentBackendKind; + /** Provider's own session id (resume token); null until the backend assigns one. */ + agentSessionId: string | null; + /** opencode HTTP server port; null for ACP backends. */ + serverPort: number | null; +} + +/** Per-turn context passed to `prompt` (§2). */ +export interface PromptCtx { + worktreePath: string; + model: string; + signal: AbortSignal; + onEvent: (e: AgentEvent) => void; +} + +/** Result of a completed turn (§2). Diff/persist happen outside the backend. */ +export interface TurnResult { + ok: boolean; + error?: string; +} + +/** + * The core backend abstraction (§2). Implementations: OpenCodeServerBackend + * (Phase 1), WarmAcpBackend (Phase 2). + */ +export interface AgentBackend { + /** Lazy: spawn server / warm process if not already up for this (session, agent). §2 */ + ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise; + /** Send a prompt; stream events via ctx.onEvent; resolves when the turn completes. §2 */ + prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise; + /** Graceful teardown of one session (session close or idle timeout). §2 */ + closeSession(handle: AgentSessionHandle): Promise; + /** Full teardown — kills all spawned servers/processes. §2 */ + dispose(): Promise; + /** Liveness for health endpoint + dispatcher fallback decision. §2 */ + health(): 'up' | 'down'; +} diff --git a/apps/coder/src/services/agent-pool.ts b/apps/coder/src/services/agent-pool.ts new file mode 100644 index 0000000..d476c86 --- /dev/null +++ b/apps/coder/src/services/agent-pool.ts @@ -0,0 +1,44 @@ +/** + * v2.6 — AgentPool (Phase 0 scaffold). + * + * Lazy get-or-create registry of `AgentBackend` instances keyed by + * `${sessionId}:${agent}`. Phase 0 ships the skeleton only: an in-memory Map, + * lookup / register / health, and clean disposal wired to the server's onClose. + * Spawning lands in Phase 1/2; nothing populates the map yet. + * + * Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2. + */ +import type { AgentBackend } from './agent-backend.js'; + +export class AgentPool { + private readonly backends = new Map(); + + private key(sessionId: string, agent: string): string { + return `${sessionId}:${agent}`; + } + + /** Map lookup only. Spawning is Phase 1/2 — never creates here. */ + get(sessionId: string, agent: string): AgentBackend | undefined { + return this.backends.get(this.key(sessionId, agent)); + } + + /** Store a backend instance for this (session, agent). */ + register(sessionId: string, agent: string, backend: AgentBackend): void { + this.backends.set(this.key(sessionId, agent), backend); + } + + /** Summary for the health endpoint. */ + health(): { size: number } { + return { size: this.backends.size }; + } + + /** Dispose every backend and clear the map. Tolerates throwing backends. */ + async dispose(): Promise { + const entries = [...this.backends.values()]; + this.backends.clear(); + await Promise.allSettled(entries.map((b) => b.dispose())); + } +} + +/** Single shared instance — referenced only by the server's onClose hook in Phase 0. */ +export const agentPool = new AgentPool();