Compare commits

..

1 Commits

Author SHA1 Message Date
140ff26204 feat(coder): v2.6 Phase 0 — AgentBackend foundations (no behavior change)
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) <noreply@anthropic.com>
2026-05-30 02:50:17 +00:00
4 changed files with 162 additions and 1 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -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<AgentSessionHandle>;
/** Send a prompt; stream events via ctx.onEvent; resolves when the turn completes. §2 */
prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult>;
/** Graceful teardown of one session (session close or idle timeout). §2 */
closeSession(handle: AgentSessionHandle): Promise<void>;
/** Full teardown — kills all spawned servers/processes. §2 */
dispose(): Promise<void>;
/** Liveness for health endpoint + dispatcher fallback decision. §2 */
health(): 'up' | 'down';
}

View File

@@ -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<string, AgentBackend>();
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<void> {
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();