mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor(server): extract opencode server manager (#865)
* refactor(server): extract opencode server manager * test(server): replace opencode server manager mocks
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<never> {
|
||||
})();
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -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<unknown> {
|
||||
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 }]);
|
||||
});
|
||||
|
||||
@@ -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<T>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string> {
|
||||
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<number> {
|
||||
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<OpenCodeServerGeneration>();
|
||||
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | 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<OpenCodeServerGeneration> {
|
||||
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<OpenCodeServerGeneration> {
|
||||
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<void> {
|
||||
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<OpenCodeServerGeneration> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, number>();
|
||||
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(
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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<OpenCodeServerAcquisition>;
|
||||
}
|
||||
|
||||
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<OpenCodeServerGeneration>();
|
||||
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | 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<OpenCodeServerAcquisition> {
|
||||
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<OpenCodeServerGeneration> {
|
||||
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<OpenCodeServerGeneration> {
|
||||
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<void> {
|
||||
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<OpenCodeServerGeneration> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<number> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<OpenCodeServerAcquisition> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user