Compare commits
1 Commits
a97293b5d9
...
v2.6.0-pha
| Author | SHA1 | Date | |
|---|---|---|---|
| 140ff26204 |
@@ -33,6 +33,7 @@ import { registerProviderRoutes } from './routes/providers.js';
|
|||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
// Phase 4: dispatcher + agent probe
|
// Phase 4: dispatcher + agent probe
|
||||||
import { createDispatcher } from './services/dispatcher.js';
|
import { createDispatcher } from './services/dispatcher.js';
|
||||||
|
import { agentPool } from './services/agent-pool.js';
|
||||||
import { probeAgents } from './services/agent-probe.js';
|
import { probeAgents } from './services/agent-probe.js';
|
||||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||||
@@ -178,7 +179,12 @@ async function main() {
|
|||||||
// Phase 4: dispatcher — polls tasks table and runs inference
|
// Phase 4: dispatcher — polls tasks table and runs inference
|
||||||
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
||||||
dispatcher.start();
|
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
|
// Register routes
|
||||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||||
|
|||||||
@@ -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 thinking_option_id TEXT;
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
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,
|
-- 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
|
-- 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
|
-- transaction, so the dispatcher reacts immediately instead of waiting for the
|
||||||
|
|||||||
85
apps/coder/src/services/agent-backend.ts
Normal file
85
apps/coder/src/services/agent-backend.ts
Normal 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';
|
||||||
|
}
|
||||||
44
apps/coder/src/services/agent-pool.ts
Normal file
44
apps/coder/src/services/agent-pool.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user