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 { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||||
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
|
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
|
||||||
import { isProviderAvailable } from "../daemon-e2e/agent-configs.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_MODEL = "gpt-5.4-mini";
|
||||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||||
|
|||||||
@@ -226,6 +226,9 @@ vi.mock("./providers/opencode-agent.js", () => ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./providers/opencode/server-manager.js", () => ({
|
||||||
OpenCodeServerManager: {
|
OpenCodeServerManager: {
|
||||||
getInstance: vi.fn(() => ({
|
getInstance: vi.fn(() => ({
|
||||||
shutdown: 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 { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||||
import { GenericACPAgentClient } from "./providers/generic-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 { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
|
||||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
|||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import type { AgentStreamEvent } from "../agent-sdk-types.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 {
|
interface MockOpenCodeClientOptions {
|
||||||
agents?: unknown[];
|
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 = {}) {
|
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
|
||||||
const promptAsync = vi.fn().mockResolvedValue({});
|
const promptAsync = vi.fn().mockResolvedValue({});
|
||||||
const permissionReply = 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 () => {
|
test("includes virtual full-access mode with dynamic OpenCode agents", async () => {
|
||||||
mockServerManager();
|
const serverManager = createTestOpenCodeServerManager();
|
||||||
mockOpenCodeClient({
|
mockOpenCodeClient({
|
||||||
agents: [
|
agents: [
|
||||||
{ name: "build", mode: "primary", hidden: false, description: "Build agent" },
|
{ 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 });
|
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
|
||||||
|
|
||||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]);
|
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 () => {
|
test("reports full-access but sends prompts through OpenCode build agent", async () => {
|
||||||
mockServerManager();
|
const serverManager = createTestOpenCodeServerManager();
|
||||||
const { promptAsync } = mockOpenCodeClient();
|
const { promptAsync } = mockOpenCodeClient();
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||||
|
serverManager,
|
||||||
|
});
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
@@ -161,13 +157,15 @@ describe("OpenCode full-access mode", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("auto-approves tool permissions in full-access without surfacing them", async () => {
|
test("auto-approves tool permissions in full-access without surfacing them", async () => {
|
||||||
mockServerManager();
|
const serverManager = createTestOpenCodeServerManager();
|
||||||
const { permissionReply } = mockOpenCodeClient({
|
const { permissionReply } = mockOpenCodeClient({
|
||||||
events: [toolPermissionEvent(), idleEvent()],
|
events: [toolPermissionEvent(), idleEvent()],
|
||||||
});
|
});
|
||||||
const receivedEvents: AgentStreamEvent[] = [];
|
const receivedEvents: AgentStreamEvent[] = [];
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||||
|
serverManager,
|
||||||
|
});
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
@@ -190,13 +188,15 @@ describe("OpenCode full-access mode", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("keeps questions separate from full-access tool auto-approval", async () => {
|
test("keeps questions separate from full-access tool auto-approval", async () => {
|
||||||
mockServerManager();
|
const serverManager = createTestOpenCodeServerManager();
|
||||||
const { permissionReply, questionReply } = mockOpenCodeClient({
|
const { permissionReply, questionReply } = mockOpenCodeClient({
|
||||||
events: [questionEvent(), idleEvent()],
|
events: [questionEvent(), idleEvent()],
|
||||||
});
|
});
|
||||||
const receivedEvents: AgentStreamEvent[] = [];
|
const receivedEvents: AgentStreamEvent[] = [];
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||||
|
serverManager,
|
||||||
|
});
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
|
|||||||
+33
-39
@@ -7,7 +7,8 @@ vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
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(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -17,30 +18,29 @@ afterEach(() => {
|
|||||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
const providerList = vi.fn(
|
async function providerList(): Promise<unknown> {
|
||||||
() =>
|
return new Promise((resolve) => {
|
||||||
new Promise((resolve) => {
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
resolve({
|
||||||
resolve({
|
data: {
|
||||||
data: {
|
connected: ["zai"],
|
||||||
connected: ["zai"],
|
all: [
|
||||||
all: [
|
{
|
||||||
{
|
id: "zai",
|
||||||
id: "zai",
|
name: "Z.AI",
|
||||||
name: "Z.AI",
|
models: {
|
||||||
models: {
|
"glm-5.1": {
|
||||||
"glm-5.1": {
|
name: "GLM 5.1",
|
||||||
name: "GLM 5.1",
|
limit: { context: 128_000 },
|
||||||
limit: { context: 128_000 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
});
|
},
|
||||||
}, 15_000);
|
});
|
||||||
}),
|
}, 15_000);
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||||
provider: {
|
provider: {
|
||||||
@@ -48,14 +48,10 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
const serverManager = createTestOpenCodeServerManager();
|
||||||
acquire: vi.fn().mockResolvedValue({
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
serverManager,
|
||||||
release: vi.fn(),
|
});
|
||||||
}),
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
|
||||||
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(15_000);
|
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 () => {
|
test("passes explicit refresh force through server acquisition", async () => {
|
||||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||||
provider: {
|
provider: {
|
||||||
list: vi.fn().mockResolvedValue({
|
list: async () => ({
|
||||||
data: {
|
data: {
|
||||||
connected: ["openai"],
|
connected: ["openai"],
|
||||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||||
@@ -80,15 +76,13 @@ test("passes explicit refresh force through server acquisition", async () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
const acquire = vi.fn().mockResolvedValue({
|
const serverManager = createTestOpenCodeServerManager();
|
||||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
|
||||||
release: vi.fn(),
|
|
||||||
});
|
|
||||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
|
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||||
|
serverManager,
|
||||||
|
});
|
||||||
|
|
||||||
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
|
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
|
||||||
|
|
||||||
expect(acquire).toHaveBeenCalledWith({ force: true });
|
expect(serverManager.acquisitions).toEqual([{ force: true, released: true }]);
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-25
@@ -7,7 +7,8 @@ vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
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>(): {
|
function createDeferred<T>(): {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
@@ -49,14 +50,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||||
acquire: vi.fn().mockResolvedValue({
|
serverManager: createTestOpenCodeServerManager(),
|
||||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
});
|
||||||
release: vi.fn(),
|
|
||||||
}),
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
|
||||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||||
|
|
||||||
await expect(session.listCommands?.()).resolves.toEqual(
|
await expect(session.listCommands?.()).resolves.toEqual(
|
||||||
@@ -107,14 +103,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||||
acquire: vi.fn().mockResolvedValue({
|
serverManager: createTestOpenCodeServerManager(),
|
||||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
});
|
||||||
release: vi.fn(),
|
|
||||||
}),
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
|
||||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||||
|
|
||||||
await expect(session.run("/compact")).resolves.toMatchObject({
|
await expect(session.run("/compact")).resolves.toMatchObject({
|
||||||
@@ -164,14 +155,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||||
acquire: vi.fn().mockResolvedValue({
|
serverManager: createTestOpenCodeServerManager(),
|
||||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
});
|
||||||
release: vi.fn(),
|
|
||||||
}),
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
|
||||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||||
|
|
||||||
const runPromise = session.run("/help");
|
const runPromise = session.run("/help");
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
|
||||||
import { readdir, readFile } from "node:fs/promises";
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
type Part as OpenCodePart,
|
type Part as OpenCodePart,
|
||||||
type TextPartInput as OpenCodeTextPartInput,
|
type TextPartInput as OpenCodeTextPartInput,
|
||||||
} from "@opencode-ai/sdk/v2/client";
|
} from "@opencode-ai/sdk/v2/client";
|
||||||
import net from "node:net";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -43,17 +42,15 @@ import type {
|
|||||||
ToolCallDetail,
|
ToolCallDetail,
|
||||||
ToolCallTimelineItem,
|
ToolCallTimelineItem,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
createProviderEnvSpec,
|
|
||||||
resolveProviderCommandPrefix,
|
|
||||||
type ProviderRuntimeSettings,
|
|
||||||
} from "../provider-launch-config.js";
|
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
|
||||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
|
||||||
import { withTimeout } from "../../../utils/promise-timeout.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 { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||||
|
import {
|
||||||
|
OpenCodeServerManager,
|
||||||
|
type OpenCodeServerManagerLike,
|
||||||
|
} from "./opencode/server-manager.js";
|
||||||
import {
|
import {
|
||||||
formatDiagnosticStatus,
|
formatDiagnosticStatus,
|
||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
@@ -159,8 +156,6 @@ type OpenCodeMcpConfig =
|
|||||||
|
|
||||||
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
||||||
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
|
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[] = [
|
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||||
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
||||||
{ name: "summarize", 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 {
|
function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig {
|
||||||
if (config.type === "stdio") {
|
if (config.type === "stdio") {
|
||||||
return {
|
return {
|
||||||
@@ -414,22 +399,6 @@ function isAlreadyPresentMcpError(error: unknown): boolean {
|
|||||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
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(
|
function resolvePartDedupeKey(
|
||||||
part: { id: string; messageID: string },
|
part: { id: string; messageID: string },
|
||||||
partType: "text" | "reasoning",
|
partType: "text" | "reasoning",
|
||||||
@@ -928,299 +897,31 @@ export const __openCodeInternals = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export class OpenCodeServerManager {
|
interface OpenCodeAgentClientDeps {
|
||||||
private static instance: OpenCodeServerManager | null = null;
|
serverManager?: OpenCodeServerManagerLike;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeAgentClient implements AgentClient {
|
export class OpenCodeAgentClient implements AgentClient {
|
||||||
readonly provider = "opencode" as const;
|
readonly provider = "opencode" as const;
|
||||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||||
|
|
||||||
private readonly serverManager: OpenCodeServerManager;
|
private readonly serverManager: OpenCodeServerManagerLike;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly modelContextWindows = new Map<string, number>();
|
private readonly modelContextWindows = new Map<string, number>();
|
||||||
private readonly storageRoot: string;
|
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.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||||
this.runtimeSettings = runtimeSettings;
|
this.runtimeSettings = runtimeSettings;
|
||||||
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
|
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
|
||||||
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
|
this.serverManager =
|
||||||
|
deps.serverManager ?? OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
|
|||||||
@@ -2,20 +2,14 @@ import { EventEmitter } from "node:events";
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
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 & {
|
type FakeServerProcess = EventEmitter & {
|
||||||
killed: boolean;
|
killed: boolean;
|
||||||
kill: ReturnType<typeof vi.fn>;
|
kill: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FakeGeneration {
|
type FakeGeneration = OpenCodeServerGeneration & { process: FakeServerProcess };
|
||||||
process: FakeServerProcess;
|
|
||||||
port: number;
|
|
||||||
url: string;
|
|
||||||
refCount: number;
|
|
||||||
retired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("OpenCodeServerManager generations", () => {
|
describe("OpenCodeServerManager generations", () => {
|
||||||
test("rotation creates a new current server without killing a referenced old server", async () => {
|
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