diff --git a/packages/server/src/server/agent/agent-metadata-generator.real.e2e.test.ts b/packages/server/src/server/agent/agent-metadata-generator.real.e2e.test.ts index 7407c0168..6b4524b8a 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.real.e2e.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.real.e2e.test.ts @@ -10,7 +10,7 @@ import { AgentStorage } from "./agent-storage.js"; import { createAllClients, shutdownProviders } from "./provider-registry.js"; import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js"; import { isProviderAvailable } from "../daemon-e2e/agent-configs.js"; -import { OpenCodeServerManager } from "./providers/opencode-agent.js"; +import { OpenCodeServerManager } from "./providers/opencode/server-manager.js"; const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 43a02e12a..49df98ac5 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -226,6 +226,9 @@ vi.mock("./providers/opencode-agent.js", () => ({ return true; } }, +})); + +vi.mock("./providers/opencode/server-manager.js", () => ({ OpenCodeServerManager: { getInstance: vi.fn(() => ({ shutdown: vi.fn(), diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 85f215f4e..51370a130 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -25,7 +25,8 @@ import { ClaudeAgentClient } from "./providers/claude/agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; -import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; +import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; +import { OpenCodeServerManager } from "./providers/opencode/server-manager.js"; import { PiDirectAgentClient } from "./providers/pi-direct-agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 10570b179..e2a320e9c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -8,7 +8,8 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; -import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js"; interface MockOpenCodeClientOptions { agents?: unknown[]; @@ -23,15 +24,6 @@ function createEventStream(events: unknown[]): AsyncGenerator { })(); } -function mockServerManager(): void { - vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ - acquire: vi.fn().mockResolvedValue({ - server: { port: 1234, url: "http://127.0.0.1:1234" }, - release: vi.fn(), - }), - } as never); -} - function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) { const promptAsync = vi.fn().mockResolvedValue({}); const permissionReply = vi.fn().mockResolvedValue({}); @@ -121,7 +113,7 @@ describe("OpenCode full-access mode", () => { }); test("includes virtual full-access mode with dynamic OpenCode agents", async () => { - mockServerManager(); + const serverManager = createTestOpenCodeServerManager(); mockOpenCodeClient({ agents: [ { name: "build", mode: "primary", hidden: false, description: "Build agent" }, @@ -129,7 +121,9 @@ describe("OpenCode full-access mode", () => { ], }); - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager, + }); const modes = await client.listModes({ cwd: "/tmp/project", force: false }); expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]); @@ -140,10 +134,12 @@ describe("OpenCode full-access mode", () => { }); test("reports full-access but sends prompts through OpenCode build agent", async () => { - mockServerManager(); + const serverManager = createTestOpenCodeServerManager(); const { promptAsync } = mockOpenCodeClient(); - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager, + }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project", @@ -161,13 +157,15 @@ describe("OpenCode full-access mode", () => { }); test("auto-approves tool permissions in full-access without surfacing them", async () => { - mockServerManager(); + const serverManager = createTestOpenCodeServerManager(); const { permissionReply } = mockOpenCodeClient({ events: [toolPermissionEvent(), idleEvent()], }); const receivedEvents: AgentStreamEvent[] = []; - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager, + }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project", @@ -190,13 +188,15 @@ describe("OpenCode full-access mode", () => { }); test("keeps questions separate from full-access tool auto-approval", async () => { - mockServerManager(); + const serverManager = createTestOpenCodeServerManager(); const { permissionReply, questionReply } = mockOpenCodeClient({ events: [questionEvent(), idleEvent()], }); const receivedEvents: AgentStreamEvent[] = []; - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager, + }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project", diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index 49fc3d19f..a8bc747c7 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -7,7 +7,8 @@ vi.mock("@opencode-ai/sdk/v2/client", () => ({ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js"; afterEach(() => { vi.useRealTimers(); @@ -17,30 +18,29 @@ afterEach(() => { test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => { vi.useFakeTimers(); - const providerList = vi.fn( - () => - new Promise((resolve) => { - setTimeout(() => { - resolve({ - data: { - connected: ["zai"], - all: [ - { - id: "zai", - name: "Z.AI", - models: { - "glm-5.1": { - name: "GLM 5.1", - limit: { context: 128_000 }, - }, + async function providerList(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + data: { + connected: ["zai"], + all: [ + { + id: "zai", + name: "Z.AI", + models: { + "glm-5.1": { + name: "GLM 5.1", + limit: { context: 128_000 }, }, }, - ], - }, - }); - }, 15_000); - }), - ); + }, + ], + }, + }); + }, 15_000); + }); + } vi.mocked(createOpencodeClient).mockReturnValue({ provider: { @@ -48,14 +48,10 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se }, } as never); - vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ - acquire: vi.fn().mockResolvedValue({ - server: { port: 1234, url: "http://127.0.0.1:1234" }, - release: vi.fn(), - }), - } as never); - - const client = new OpenCodeAgentClient(createTestLogger()); + const serverManager = createTestOpenCodeServerManager(); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager, + }); const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false }); await vi.advanceTimersByTimeAsync(15_000); @@ -72,7 +68,7 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se test("passes explicit refresh force through server acquisition", async () => { vi.mocked(createOpencodeClient).mockReturnValue({ provider: { - list: vi.fn().mockResolvedValue({ + list: async () => ({ data: { connected: ["openai"], all: [{ id: "openai", name: "OpenAI", models: {} }], @@ -80,15 +76,13 @@ test("passes explicit refresh force through server acquisition", async () => { }), }, } as never); - const acquire = vi.fn().mockResolvedValue({ - server: { port: 1234, url: "http://127.0.0.1:1234" }, - release: vi.fn(), - }); - vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never); + const serverManager = createTestOpenCodeServerManager(); - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager, + }); await client.listModels({ cwd: "/tmp/opencode-models", force: true }); - expect(acquire).toHaveBeenCalledWith({ force: true }); + expect(serverManager.acquisitions).toEqual([{ force: true, released: true }]); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts index 05cb7bf6b..155b8ed1b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts @@ -7,7 +7,8 @@ vi.mock("@opencode-ai/sdk/v2/client", () => ({ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js"; function createDeferred(): { promise: Promise; @@ -49,14 +50,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => { }, } as never); - vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ - acquire: vi.fn().mockResolvedValue({ - server: { port: 1234, url: "http://127.0.0.1:1234" }, - release: vi.fn(), - }), - } as never); - - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager: createTestOpenCodeServerManager(), + }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); await expect(session.listCommands?.()).resolves.toEqual( @@ -107,14 +103,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => { }, } as never); - vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ - acquire: vi.fn().mockResolvedValue({ - server: { port: 1234, url: "http://127.0.0.1:1234" }, - release: vi.fn(), - }), - } as never); - - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager: createTestOpenCodeServerManager(), + }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); await expect(session.run("/compact")).resolves.toMatchObject({ @@ -164,14 +155,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => { }, } as never); - vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ - acquire: vi.fn().mockResolvedValue({ - server: { port: 1234, url: "http://127.0.0.1:1234" }, - release: vi.fn(), - }), - } as never); - - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { + serverManager: createTestOpenCodeServerManager(), + }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); const runPromise = session.run("/help"); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d2b6f1284..e19a63508 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,4 +1,3 @@ -import type { ChildProcess } from "node:child_process"; import { readdir, readFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; @@ -11,7 +10,7 @@ import { type Part as OpenCodePart, type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; -import net from "node:net"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import type { Logger } from "pino"; import { z } from "zod"; @@ -43,17 +42,15 @@ import type { ToolCallDetail, ToolCallTimelineItem, } from "../agent-sdk-types.js"; -import { - createProviderEnvSpec, - resolveProviderCommandPrefix, - type ProviderRuntimeSettings, -} from "../provider-launch-config.js"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; -import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; +import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; -import { execCommand, spawnProcess } from "../../../utils/spawn.js"; +import { execCommand } from "../../../utils/spawn.js"; import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; +import { + OpenCodeServerManager, + type OpenCodeServerManagerLike, +} from "./opencode/server-manager.js"; import { formatDiagnosticStatus, formatProviderDiagnostic, @@ -159,8 +156,6 @@ type OpenCodeMcpConfig = const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const; const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000; -const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; -const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ { name: "compact", description: "Compact the current session", argumentHint: "" }, { name: "summarize", description: "Compact the current session", argumentHint: "" }, @@ -251,16 +246,6 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch }), ); -async function resolveOpenCodeBinary(): Promise { - const found = await findExecutable("opencode"); - if (found) { - return found; - } - throw new Error( - "OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.", - ); -} - function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig { if (config.type === "stdio") { return { @@ -414,22 +399,6 @@ function isAlreadyPresentMcpError(error: unknown): boolean { return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token)); } -async function findAvailablePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, () => { - const address = server.address(); - if (address && typeof address === "object") { - const port = address.port; - server.close(() => resolve(port)); - } else { - server.close(() => reject(new Error("Failed to get port"))); - } - }); - server.on("error", reject); - }); -} - function resolvePartDedupeKey( part: { id: string; messageID: string }, partType: "text" | "reasoning", @@ -928,299 +897,31 @@ export const __openCodeInternals = { }, }; -export class OpenCodeServerManager { - private static instance: OpenCodeServerManager | null = null; - private static exitHandlerRegistered = false; - private currentServer: OpenCodeServerGeneration | null = null; - private retiredServers = new Set(); - private startPromise: Promise | null = null; - private forcedRefreshPromise: Promise | null = null; - private readonly logger: Logger; - private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly runtimeSettingsKey: string; - - private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) { - this.logger = logger; - this.runtimeSettings = runtimeSettings; - this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {}); - } - - static getInstance( - logger: Logger, - runtimeSettings?: ProviderRuntimeSettings, - ): OpenCodeServerManager { - const nextSettingsKey = JSON.stringify(runtimeSettings ?? {}); - if (!OpenCodeServerManager.instance) { - OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings); - OpenCodeServerManager.registerExitHandler(); - } else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) { - logger.warn( - { - existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey, - requestedRuntimeSettings: nextSettingsKey, - }, - "OpenCode server manager already initialized with different runtime settings", - ); - } - return OpenCodeServerManager.instance; - } - - private static registerExitHandler(): void { - if (OpenCodeServerManager.exitHandlerRegistered) { - return; - } - OpenCodeServerManager.exitHandlerRegistered = true; - - const cleanup = () => { - const instance = OpenCodeServerManager.instance; - void instance?.shutdown(); - }; - - process.on("exit", cleanup); - process.on("SIGTERM", cleanup); - process.on("SIGINT", cleanup); - } - - async ensureRunning(): Promise<{ port: number; url: string }> { - const acquisition = await this.acquire({ force: false }); - acquisition.release(); - return acquisition.server; - } - - async acquire(options: { force: boolean }): Promise<{ - server: { port: number; url: string }; - release: () => void; - }> { - const server = options.force - ? await this.getForcedRefreshServer() - : await this.getCurrentServer(); - server.refCount += 1; - let released = false; - return { - server: { port: server.port, url: server.url }, - release: () => { - if (released) { - return; - } - released = true; - server.refCount -= 1; - this.cleanupRetiredServers(); - }, - }; - } - - private async getForcedRefreshServer(): Promise { - if (this.forcedRefreshPromise) { - return this.forcedRefreshPromise; - } - - this.forcedRefreshPromise = Promise.resolve() - .then(async () => { - await this.rotateCurrentServer(); - return this.getCurrentServer(); - }) - .finally(() => { - this.forcedRefreshPromise = null; - }); - return this.forcedRefreshPromise; - } - - private async getCurrentServer(): Promise { - if (this.startPromise) { - return this.startPromise; - } - - if (this.currentServer && !this.currentServer.process.killed) { - return this.currentServer; - } - - this.startPromise = this.startServer(); - try { - const result = await this.startPromise; - if (!result.retired) { - this.currentServer = result; - } - return result; - } finally { - this.startPromise = null; - } - } - - private async rotateCurrentServer(): Promise { - const existing = this.currentServer; - if (existing) { - existing.retired = true; - this.retiredServers.add(existing); - this.currentServer = null; - this.cleanupRetiredServers(); - } - if (this.startPromise) { - const pending = await this.startPromise; - pending.retired = true; - this.retiredServers.add(pending); - this.currentServer = null; - this.cleanupRetiredServers(); - } - } - - private async startServer(): Promise { - const port = await findAvailablePort(); - const url = `http://127.0.0.1:${port}`; - const launchPrefix = await resolveProviderCommandPrefix( - this.runtimeSettings?.command, - resolveOpenCodeBinary, - ); - - return new Promise((resolve, reject) => { - const serverProcess = spawnProcess( - launchPrefix.command, - [...launchPrefix.args, "serve", "--port", String(port)], - { - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }), - }, - ); - - let started = false; - let stderrBuffer = ""; - let stdoutBuffer = ""; - const STARTUP_BUFFER_CAP = 8192; - const appendCapped = (current: string, chunk: string): string => { - if (current.length >= STARTUP_BUFFER_CAP) { - return current; - } - const remaining = STARTUP_BUFFER_CAP - current.length; - return current + chunk.slice(0, remaining); - }; - const buildStartupErrorMessage = (headline: string): string => { - const sections = [headline]; - const stderrTrimmed = stderrBuffer.trim(); - if (stderrTrimmed.length > 0) { - sections.push(`stderr: ${stderrTrimmed}`); - } - const stdoutTrimmed = stdoutBuffer.trim(); - if (stdoutTrimmed.length > 0) { - sections.push(`stdout: ${stdoutTrimmed}`); - } - return sections.join("\n"); - }; - const timeout = setTimeout(() => { - if (!started) { - reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout"))); - } - }, 30_000); - - serverProcess.stdout?.on("data", (data: Buffer) => { - const output = data.toString(); - stdoutBuffer = appendCapped(stdoutBuffer, output); - if (output.includes("listening on") && !started) { - started = true; - clearTimeout(timeout); - resolve({ - process: serverProcess, - port, - url, - refCount: 0, - retired: false, - }); - } - }); - - serverProcess.stderr?.on("data", (data: Buffer) => { - const output = data.toString(); - stderrBuffer = appendCapped(stderrBuffer, output); - this.logger.error({ stderr: output.trim() }, "OpenCode server stderr"); - }); - - serverProcess.on("error", (error) => { - clearTimeout(timeout); - const headline = error instanceof Error ? error.message : String(error); - reject(new Error(buildStartupErrorMessage(headline))); - }); - - serverProcess.on("exit", (code) => { - if (!started) { - clearTimeout(timeout); - reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`))); - } - if (this.currentServer?.process === serverProcess) { - this.currentServer = null; - } - for (const retired of Array.from(this.retiredServers)) { - if (retired.process === serverProcess) { - this.retiredServers.delete(retired); - } - } - }); - }); - } - - async shutdown(): Promise { - const servers = [ - ...(this.currentServer ? [this.currentServer] : []), - ...Array.from(this.retiredServers), - ]; - await Promise.all(servers.map((server) => this.killServer(server))); - this.currentServer = null; - this.retiredServers.clear(); - } - - private cleanupRetiredServers(): void { - for (const server of Array.from(this.retiredServers)) { - if (server.refCount === 0) { - this.retiredServers.delete(server); - void this.killServer(server); - } - } - } - - private async killServer(server: OpenCodeServerGeneration): Promise { - if (server.process.killed) { - return; - } - const result = await terminateWithTreeKill(server.process, { - gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS, - forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS, - onForceSignal: () => { - this.logger.warn( - { timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS }, - "OpenCode server did not exit after SIGTERM; sending SIGKILL", - ); - }, - }); - if (result === "kill-timeout") { - this.logger.warn( - { timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS }, - "OpenCode server did not report exit after SIGKILL", - ); - } - } -} - -interface OpenCodeServerGeneration { - process: ChildProcess; - port: number; - url: string; - refCount: number; - retired: boolean; +interface OpenCodeAgentClientDeps { + serverManager?: OpenCodeServerManagerLike; } export class OpenCodeAgentClient implements AgentClient { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; - private readonly serverManager: OpenCodeServerManager; + private readonly serverManager: OpenCodeServerManagerLike; private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly modelContextWindows = new Map(); private readonly storageRoot: string; - constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings, storageRoot?: string) { + constructor( + logger: Logger, + runtimeSettings?: ProviderRuntimeSettings, + storageRoot?: string, + deps: OpenCodeAgentClientDeps = {}, + ) { this.logger = logger.child({ module: "agent", provider: "opencode" }); this.runtimeSettings = runtimeSettings; this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot(); - this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings); + this.serverManager = + deps.serverManager ?? OpenCodeServerManager.getInstance(this.logger, runtimeSettings); } async createSession( diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 29e6efa8c..26177a082 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -2,20 +2,14 @@ import { EventEmitter } from "node:events"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { OpenCodeServerManager } from "./opencode-agent.js"; +import { OpenCodeServerManager, type OpenCodeServerGeneration } from "./opencode/server-manager.js"; type FakeServerProcess = EventEmitter & { killed: boolean; kill: ReturnType; }; -interface FakeGeneration { - process: FakeServerProcess; - port: number; - url: string; - refCount: number; - retired: boolean; -} +type FakeGeneration = OpenCodeServerGeneration & { process: FakeServerProcess }; describe("OpenCodeServerManager generations", () => { test("rotation creates a new current server without killing a referenced old server", async () => { diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts new file mode 100644 index 000000000..97d365c7e --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -0,0 +1,327 @@ +import type { ChildProcess } from "node:child_process"; +import net from "node:net"; +import type { Logger } from "pino"; + +import { findExecutable } from "../../../../utils/executable.js"; +import { spawnProcess } from "../../../../utils/spawn.js"; +import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { + createProviderEnvSpec, + resolveProviderCommandPrefix, + type ProviderRuntimeSettings, +} from "../../provider-launch-config.js"; + +const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; +const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; + +export interface OpenCodeServerAcquisition { + server: { port: number; url: string }; + release: () => void; +} + +export interface OpenCodeServerManagerLike { + ensureRunning(): Promise<{ port: number; url: string }>; + acquire(options: { force: boolean }): Promise; +} + +export interface OpenCodeServerGeneration { + process: ChildProcess; + port: number; + url: string; + refCount: number; + retired: boolean; +} + +export class OpenCodeServerManager implements OpenCodeServerManagerLike { + private static instance: OpenCodeServerManager | null = null; + private static exitHandlerRegistered = false; + private currentServer: OpenCodeServerGeneration | null = null; + private retiredServers = new Set(); + private startPromise: Promise | null = null; + private forcedRefreshPromise: Promise | null = null; + private readonly logger: Logger; + private readonly runtimeSettings?: ProviderRuntimeSettings; + private readonly runtimeSettingsKey: string; + + private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) { + this.logger = logger; + this.runtimeSettings = runtimeSettings; + this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {}); + } + + static getInstance( + logger: Logger, + runtimeSettings?: ProviderRuntimeSettings, + ): OpenCodeServerManager { + const nextSettingsKey = JSON.stringify(runtimeSettings ?? {}); + if (!OpenCodeServerManager.instance) { + OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings); + OpenCodeServerManager.registerExitHandler(); + } else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) { + logger.warn( + { + existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey, + requestedRuntimeSettings: nextSettingsKey, + }, + "OpenCode server manager already initialized with different runtime settings", + ); + } + return OpenCodeServerManager.instance; + } + + private static registerExitHandler(): void { + if (OpenCodeServerManager.exitHandlerRegistered) { + return; + } + OpenCodeServerManager.exitHandlerRegistered = true; + + const cleanup = () => { + const instance = OpenCodeServerManager.instance; + void instance?.shutdown(); + }; + + process.on("exit", cleanup); + process.on("SIGTERM", cleanup); + process.on("SIGINT", cleanup); + } + + async ensureRunning(): Promise<{ port: number; url: string }> { + const acquisition = await this.acquire({ force: false }); + acquisition.release(); + return acquisition.server; + } + + async acquire(options: { force: boolean }): Promise { + const server = options.force + ? await this.getForcedRefreshServer() + : await this.getCurrentServer(); + server.refCount += 1; + let released = false; + return { + server: { port: server.port, url: server.url }, + release: () => { + if (released) { + return; + } + released = true; + server.refCount -= 1; + this.cleanupRetiredServers(); + }, + }; + } + + private async getForcedRefreshServer(): Promise { + if (this.forcedRefreshPromise) { + return this.forcedRefreshPromise; + } + + this.forcedRefreshPromise = Promise.resolve() + .then(async () => { + await this.rotateCurrentServer(); + return this.getCurrentServer(); + }) + .finally(() => { + this.forcedRefreshPromise = null; + }); + return this.forcedRefreshPromise; + } + + private async getCurrentServer(): Promise { + if (this.startPromise) { + return this.startPromise; + } + + if (this.currentServer && !this.currentServer.process.killed) { + return this.currentServer; + } + + this.startPromise = this.startServer(); + try { + const result = await this.startPromise; + if (!result.retired) { + this.currentServer = result; + } + return result; + } finally { + this.startPromise = null; + } + } + + private async rotateCurrentServer(): Promise { + const existing = this.currentServer; + if (existing) { + existing.retired = true; + this.retiredServers.add(existing); + this.currentServer = null; + this.cleanupRetiredServers(); + } + if (this.startPromise) { + const pending = await this.startPromise; + pending.retired = true; + this.retiredServers.add(pending); + this.currentServer = null; + this.cleanupRetiredServers(); + } + } + + private async startServer(): Promise { + const port = await findAvailablePort(); + const url = `http://127.0.0.1:${port}`; + const launchPrefix = await resolveProviderCommandPrefix( + this.runtimeSettings?.command, + resolveOpenCodeBinary, + ); + + return new Promise((resolve, reject) => { + const serverProcess = spawnProcess( + launchPrefix.command, + [...launchPrefix.args, "serve", "--port", String(port)], + { + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }), + }, + ); + + let started = false; + let stderrBuffer = ""; + let stdoutBuffer = ""; + const STARTUP_BUFFER_CAP = 8192; + const appendCapped = (current: string, chunk: string): string => { + if (current.length >= STARTUP_BUFFER_CAP) { + return current; + } + const remaining = STARTUP_BUFFER_CAP - current.length; + return current + chunk.slice(0, remaining); + }; + const buildStartupErrorMessage = (headline: string): string => { + const sections = [headline]; + const stderrTrimmed = stderrBuffer.trim(); + if (stderrTrimmed.length > 0) { + sections.push(`stderr: ${stderrTrimmed}`); + } + const stdoutTrimmed = stdoutBuffer.trim(); + if (stdoutTrimmed.length > 0) { + sections.push(`stdout: ${stdoutTrimmed}`); + } + return sections.join("\n"); + }; + const timeout = setTimeout(() => { + if (!started) { + reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout"))); + } + }, 30_000); + + serverProcess.stdout?.on("data", (data: Buffer) => { + const output = data.toString(); + stdoutBuffer = appendCapped(stdoutBuffer, output); + if (output.includes("listening on") && !started) { + started = true; + clearTimeout(timeout); + resolve({ + process: serverProcess, + port, + url, + refCount: 0, + retired: false, + }); + } + }); + + serverProcess.stderr?.on("data", (data: Buffer) => { + const output = data.toString(); + stderrBuffer = appendCapped(stderrBuffer, output); + this.logger.error({ stderr: output.trim() }, "OpenCode server stderr"); + }); + + serverProcess.on("error", (error) => { + clearTimeout(timeout); + const headline = error instanceof Error ? error.message : String(error); + reject(new Error(buildStartupErrorMessage(headline))); + }); + + serverProcess.on("exit", (code) => { + if (!started) { + clearTimeout(timeout); + reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`))); + } + if (this.currentServer?.process === serverProcess) { + this.currentServer = null; + } + for (const retired of Array.from(this.retiredServers)) { + if (retired.process === serverProcess) { + this.retiredServers.delete(retired); + } + } + }); + }); + } + + async shutdown(): Promise { + const servers = [ + ...(this.currentServer ? [this.currentServer] : []), + ...Array.from(this.retiredServers), + ]; + await Promise.all(servers.map((server) => this.killServer(server))); + this.currentServer = null; + this.retiredServers.clear(); + } + + private cleanupRetiredServers(): void { + for (const server of Array.from(this.retiredServers)) { + if (server.refCount === 0) { + this.retiredServers.delete(server); + void this.killServer(server); + } + } + } + + private async killServer(server: OpenCodeServerGeneration): Promise { + if (server.process.killed) { + return; + } + const result = await terminateWithTreeKill(server.process, { + gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS, + forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS, + onForceSignal: () => { + this.logger.warn( + { timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS }, + "OpenCode server did not exit after SIGTERM; sending SIGKILL", + ); + }, + }); + if (result === "kill-timeout") { + this.logger.warn( + { timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS }, + "OpenCode server did not report exit after SIGKILL", + ); + } + } +} + +async function resolveOpenCodeBinary(): Promise { + const found = await findExecutable("opencode"); + if (found) { + return found; + } + throw new Error( + "OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.", + ); +} + +function findAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (typeof address === "object" && address) { + resolve(address.port); + } else { + reject(new Error("Failed to allocate port")); + } + }); + }); + server.on("error", reject); + }); +} diff --git a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts new file mode 100644 index 000000000..baab73f9b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -0,0 +1,35 @@ +import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js"; + +export interface TestOpenCodeServerAcquisition { + force: boolean; + released: boolean; +} + +export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { + readonly acquisitions: TestOpenCodeServerAcquisition[] = []; + readonly server = { port: 1234, url: "http://127.0.0.1:1234" }; + ensureRunningCount = 0; + + async ensureRunning(): Promise<{ port: number; url: string }> { + this.ensureRunningCount += 1; + return this.server; + } + + async acquire(options: { force: boolean }): Promise { + const acquisition: TestOpenCodeServerAcquisition = { + force: options.force, + released: false, + }; + this.acquisitions.push(acquisition); + return { + server: this.server, + release: () => { + acquisition.released = true; + }, + }; + } +} + +export function createTestOpenCodeServerManager(): TestOpenCodeServerManager { + return new TestOpenCodeServerManager(); +}