Stop OpenCode helper servers from leaking (#1697)

* Fix OpenCode helper server ownership

Own OpenCode helper generations as soon as spawn returns so startup timeout and shutdown clean up through the manager/reaper path. Collapse the OpenCode runtime wrapper so the provider talks to the server manager directly.

* Protect dedicated OpenCode server startup
This commit is contained in:
Mohamed Boudra
2026-06-24 14:44:43 +08:00
committed by GitHub
parent 42e3f63dec
commit 79be6d8dba
12 changed files with 500 additions and 289 deletions

View File

@@ -42,6 +42,8 @@ Provider session import has its own contract. The picker calls `listImportableSe
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
---

View File

@@ -6,8 +6,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
interface MockOpenCodeClientOptions {
agents?: unknown[];
@@ -15,7 +15,7 @@ interface MockOpenCodeClientOptions {
}
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
openCodeClient.sessionPromptAsyncEvents = options.events ?? [idleEvent()];
@@ -77,7 +77,10 @@ describe("OpenCode auto_accept feature", () => {
],
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]);
@@ -86,7 +89,10 @@ describe("OpenCode auto_accept feature", () => {
test("falls back to default OpenCode modes when discovery returns no modes", async () => {
const { runtime } = mockOpenCodeClient({ agents: [] });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]);
@@ -95,7 +101,10 @@ describe("OpenCode auto_accept feature", () => {
test("lists auto accept as a provider feature", async () => {
const { runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const enabledFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
@@ -121,7 +130,10 @@ describe("OpenCode auto_accept feature", () => {
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -251,7 +263,10 @@ describe("OpenCode auto_accept feature", () => {
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -279,7 +294,10 @@ describe("OpenCode auto_accept feature", () => {
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -322,7 +340,10 @@ describe("OpenCode auto_accept feature", () => {
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",

View File

@@ -4,8 +4,8 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
afterEach(() => {
vi.useRealTimers();
@@ -14,7 +14,7 @@ afterEach(() => {
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListImplementation = () =>
new Promise((resolve) => {
@@ -40,7 +40,10 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
});
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
await vi.advanceTimersByTimeAsync(15_000);
@@ -57,8 +60,8 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
expect(openCodeClient.calls.providerList).toHaveLength(1);
});
test("passes explicit refresh force through server acquisition", async () => {
const runtime = new TestOpenCodeRuntime();
test("uses a new server for explicit catalog refresh", async () => {
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -68,17 +71,20 @@ test("passes explicit refresh force through server acquisition", async () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: true });
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
expect(runtime.acquisitions).toEqual([{ kind: "new", releaseCount: 1 }]);
});
test("includes models from api-source providers not in connected", async () => {
// Providers with source "api" are managed by the OpenCode console/subscription.
// They don't appear in `connected` but are fully usable.
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -100,7 +106,10 @@ test("includes models from api-source providers not in connected", async () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
expect(models).toMatchObject([
@@ -113,7 +122,7 @@ test("includes models from api-source providers not in connected", async () => {
});
test("throws when no providers are accessible (neither connected nor api-source)", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -132,7 +141,10 @@ test("throws when no providers are accessible (neither connected nor api-source)
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
"OpenCode has no connected providers",
@@ -140,7 +152,7 @@ test("throws when no providers are accessible (neither connected nor api-source)
});
test("does not throw when only api-source providers are present with no connected providers", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -159,7 +171,10 @@ test("does not throw when only api-source providers are present with no connecte
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await expect(
client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }),

View File

@@ -5,11 +5,11 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
function mockOpenCodeClient(events: unknown[]) {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionPromptAsyncEvents = events;
runtime.enqueueClient(openCodeClient);
@@ -35,7 +35,10 @@ function toolPermissionEvent(): unknown {
describe("OpenCode permission actions", () => {
test("allow always sends OpenCode's always reply", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -63,7 +66,10 @@ describe("OpenCode permission actions", () => {
test("plain allow keeps the backward-compatible once reply", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",

View File

@@ -5,8 +5,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
function createDeferred<T>(): {
promise: Promise<T>;
@@ -24,11 +24,14 @@ function createDeferred<T>(): {
describe("OpenCodeAgentSession slash command timeout handling", () => {
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.listCommands?.()).resolves.toEqual(
@@ -49,11 +52,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
});
test("executes compact through the OpenCode summarize endpoint", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.run("/compact")).resolves.toMatchObject({
@@ -70,7 +76,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
test("waits for SSE completion when slash commands hit a header timeout", async () => {
const idleEventGate = createDeferred<void>();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
openCodeClient.commandListResponse = {
@@ -85,7 +91,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
})();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");
@@ -101,7 +110,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
});
test("leaves successful slash command turns open until OpenCode emits idle", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandEvents = [];
openCodeClient.commandListResponse = {
@@ -109,7 +118,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");

View File

@@ -13,8 +13,8 @@ import {
import { streamSession } from "./test-utils/session-stream-adapter.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
import type {
AgentSessionConfig,
AgentStreamEvent,
@@ -182,9 +182,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("creates a session with valid id and provider", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient());
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession(buildConfig(cwd));
expect(typeof session.id).toBe("string");
@@ -197,11 +200,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("single turn completes with streaming deltas", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession(buildConfig(cwd));
const iterator = streamSession(session, "Say hello");
@@ -230,11 +236,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("manual compact hides the generated summary text", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionSummarizeEvents = manualCompactEvents();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd,
@@ -264,7 +273,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, 120_000);
test("fetchCatalog returns models with required fields", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -296,7 +305,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
],
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const cwd = os.homedir();
const catalog = await client.fetchCatalog({ cwd, force: false });
@@ -331,7 +343,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, 60_000);
test("limits concurrent OpenCode metadata requests across clients", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
let activeProviderListCalls = 0;
let maxActiveProviderListCalls = 0;
const response = {
@@ -364,7 +376,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
runtime.enqueueClient(openCodeClient);
}
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await Promise.all(
Array.from({ length: 12 }, (_, index) =>
client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }),
@@ -376,9 +391,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("available modes include build and plan", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient());
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes();
@@ -392,7 +410,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("custom agents defined in opencode.json appear in available modes", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = {
data: [
@@ -408,7 +426,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes();
@@ -431,7 +452,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("plan and build modes are sent to OpenCode as distinct runtime agents", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const planOpenCodeClient = new TestOpenCodeClient();
planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" });
const buildOpenCodeClient = new TestOpenCodeClient();
@@ -468,7 +489,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
];
runtime.enqueueClient(planOpenCodeClient);
runtime.enqueueClient(buildOpenCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const planSession = await client.createSession({
...buildConfig(cwd),
@@ -861,11 +885,14 @@ describe("OpenCode adapter context-window normalization", () => {
describe("OpenCode adapter startTurn error handling", () => {
test("dynamically adds injected MCP servers without config-backed connect", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession({
@@ -901,7 +928,7 @@ describe("OpenCode adapter startTurn error handling", () => {
});
test("fails the turn when OpenCode reports MCP add failure in data payload", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.mcpAddResponse = {
data: {
@@ -913,7 +940,10 @@ describe("OpenCode adapter startTurn error handling", () => {
};
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession({
@@ -1665,11 +1695,14 @@ describe("OpenCode adapter startTurn error handling", () => {
describe("OpenCodeAgentClient env", () => {
test("passes launch-context env to env-specific server acquisition", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession(
@@ -1686,7 +1719,7 @@ describe("OpenCodeAgentClient env", () => {
await session.close();
expect(runtime.acquisitions[0]).toMatchObject({
force: false,
kind: "dedicated",
env: {
CHUNK14_PROBE: "expected",
},
@@ -1848,7 +1881,7 @@ describe("OpenCode persisted sessions", () => {
});
test("listImportableSessions returns rows without hydrating session messages", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
const cwd = "/workspace/repo";
const otherCwd = "/workspace/other";
@@ -1945,7 +1978,10 @@ describe("OpenCode persisted sessions", () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const sessions = await client.listImportableSessions({ cwd, limit: 1 });
expect(sessions).toHaveLength(1);
@@ -1965,7 +2001,7 @@ describe("OpenCode persisted sessions", () => {
});
test("importSession reads only the selected OpenCode session without listing", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const metadataClient = new TestOpenCodeClient();
const resumedClient = new TestOpenCodeClient();
const cwd = "/workspace/repo";
@@ -2004,7 +2040,10 @@ describe("OpenCode persisted sessions", () => {
runtime.enqueueClient(metadataClient);
runtime.enqueueClient(resumedClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const imported = await client.importSession(
{ providerHandleId: "ses_selected", cwd },
{
@@ -2041,7 +2080,7 @@ describe("OpenCode persisted sessions", () => {
});
test("listImportableSessions matches Windows cwd paths with forward slashes", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
const requestedCwd = "C:/Users/Administrator/GhostFactory";
const storedCwd = "C:\\Users\\Administrator\\GhostFactory";
@@ -2064,7 +2103,10 @@ describe("OpenCode persisted sessions", () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const sessions = await client.listImportableSessions({ cwd: requestedCwd, limit: 1 });
expect(sessions).toHaveLength(1);

View File

@@ -1,10 +1,12 @@
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput,
type GlobalSession as OpenCodeGlobalSession,
type Message as OpenCodeMessage,
type OpencodeClient,
type OpencodeClientConfig,
type Part as OpenCodePart,
type Session as OpenCodeSession,
type TextPartInput as OpenCodeTextPartInput,
@@ -64,7 +66,10 @@ import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import {
OpenCodeServerManager,
type OpenCodeServerManagerLike,
} from "./opencode/server-manager.js";
import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
@@ -75,11 +80,6 @@ import {
import { runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import {
createSdkOpenCodeClient,
type OpenCodeRuntime,
type OpenCodeServerAcquisition,
} from "./opencode/runtime.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
@@ -1212,31 +1212,15 @@ export const __openCodeInternals = {
};
interface OpenCodeAgentClientDeps {
runtime?: OpenCodeRuntime;
serverManager?: OpenCodeServerManagerLike;
createClient?: OpenCodeClientFactory;
managedProcesses?: ManagedProcessRegistry;
}
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
constructor(private readonly serverManager: OpenCodeServerManager) {}
type OpenCodeClientFactory = (options: { baseUrl: string; directory: string }) => OpencodeClient;
async acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
return this.serverManager.acquire(options);
}
async ensureServerRunning(): Promise<{ port: number; url: string }> {
return this.serverManager.ensureRunning();
}
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
return createSdkOpenCodeClient(options);
}
async shutdown(): Promise<void> {
await this.serverManager.shutdown();
}
function createSdkOpenCodeClient(options: { baseUrl: string; directory: string }): OpencodeClient {
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
}
export class OpenCodeAgentClient implements AgentClient {
@@ -1245,7 +1229,8 @@ export class OpenCodeAgentClient implements AgentClient {
readonly resolveCreateConfig = resolveOpenCodeCreateConfig;
readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended;
private readonly runtime: OpenCodeRuntime;
private readonly serverManager: OpenCodeServerManagerLike;
private readonly createOpenCodeClient: OpenCodeClientFactory;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
@@ -1257,13 +1242,12 @@ export class OpenCodeAgentClient implements AgentClient {
) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.runtime =
deps.runtime ??
new ProductionOpenCodeRuntime(
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
managedProcesses: deps.managedProcesses,
}),
);
this.serverManager =
deps.serverManager ??
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
managedProcesses: deps.managedProcesses,
});
this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient;
}
async createSession(
@@ -1272,12 +1256,11 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({
force: false,
env: launchContext?.env,
});
const acquisition = launchContext?.env
? await this.serverManager.acquireDedicated(launchContext.env)
: await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1334,9 +1317,9 @@ export class OpenCodeAgentClient implements AgentClient {
cwd,
};
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1361,10 +1344,12 @@ export class OpenCodeAgentClient implements AgentClient {
}
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
const acquisition = await this.runtime.acquireServer({ force: options.force });
const acquisition = options.force
? await this.serverManager.acquireNew()
: await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const directory = options.cwd;
const client = this.runtime.createClient({ baseUrl: url, directory });
const client = this.createOpenCodeClient({ baseUrl: url, directory });
try {
const [models, modes] = await Promise.all([
@@ -1379,9 +1364,9 @@ export class OpenCodeAgentClient implements AgentClient {
async listCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1400,9 +1385,9 @@ export class OpenCodeAgentClient implements AgentClient {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: options?.cwd ?? "",
});
@@ -1415,9 +1400,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: input.cwd,
});
@@ -1460,7 +1445,7 @@ export class OpenCodeAgentClient implements AgentClient {
}
async shutdown(): Promise<void> {
await this.runtime.shutdown();
await this.serverManager.shutdown();
}
async getDiagnostic(): Promise<{ diagnostic: string }> {

View File

@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type {
@@ -17,12 +17,16 @@ import {
type OpenCodeServerProcessSpawner,
} from "./opencode/server-manager.js";
afterEach(() => {
vi.useRealTimers();
});
describe("OpenCodeServerManager generations", () => {
test("rotation creates a new current server without killing a referenced old server", async () => {
const { manager, runtime } = createTestManager([4101, 4102]);
const oldAcquisition = await manager.acquire({ force: false });
const newAcquisition = await manager.acquire({ force: true });
const oldAcquisition = await manager.acquireCurrent();
const newAcquisition = await manager.acquireNew();
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
@@ -37,11 +41,11 @@ describe("OpenCodeServerManager generations", () => {
test("new acquisitions after rotation use the new server", async () => {
const { manager, runtime } = createTestManager([4201, 4202]);
const oldAcquisition = await manager.acquire({ force: false });
const rotatedAcquisition = await manager.acquire({ force: true });
const oldAcquisition = await manager.acquireCurrent();
const rotatedAcquisition = await manager.acquireNew();
rotatedAcquisition.release();
const nextAcquisition = await manager.acquire({ force: false });
const nextAcquisition = await manager.acquireCurrent();
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
expect(runtime.terminatedPorts).toEqual([]);
@@ -50,15 +54,15 @@ describe("OpenCodeServerManager generations", () => {
oldAcquisition.release();
});
test("concurrent forced acquisitions share one fresh generation", async () => {
test("concurrent new-server acquisitions share one fresh generation", async () => {
const { manager, runtime } = createTestManager([4251, 4252, 4253]);
const initialAcquisition = await manager.acquire({ force: false });
const initialAcquisition = await manager.acquireCurrent();
initialAcquisition.release();
const [modelsAcquisition, modesAcquisition] = await Promise.all([
manager.acquire({ force: true }),
manager.acquire({ force: true }),
manager.acquireNew(),
manager.acquireNew(),
]);
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
@@ -72,8 +76,8 @@ describe("OpenCodeServerManager generations", () => {
test("release is idempotent", async () => {
const { manager, runtime } = createTestManager([4301, 4302]);
const oldAcquisition = await manager.acquire({ force: false });
const newAcquisition = await manager.acquire({ force: true });
const oldAcquisition = await manager.acquireCurrent();
const newAcquisition = await manager.acquireNew();
newAcquisition.release();
oldAcquisition.release();
@@ -85,8 +89,8 @@ describe("OpenCodeServerManager generations", () => {
test("shutdown kills current and retired servers", async () => {
const { manager, runtime } = createTestManager([4401, 4402]);
await manager.acquire({ force: false });
await manager.acquire({ force: true });
await manager.acquireCurrent();
await manager.acquireNew();
await manager.shutdown();
@@ -96,7 +100,7 @@ describe("OpenCodeServerManager generations", () => {
test("shutdown still signals a process after an earlier kill signal if it has not exited", async () => {
const { manager, runtime } = createTestManager([4451]);
await manager.acquire({ force: false });
await manager.acquireCurrent();
runtime.processForPort(4451).markKillSignalSent();
await manager.shutdown();
@@ -104,13 +108,64 @@ describe("OpenCodeServerManager generations", () => {
expect(runtime.terminatedPorts).toEqual([4451]);
});
test("startup timeout kills the spawned server and removes its managed-process record", async () => {
vi.useFakeTimers();
const { manager, runtime } = createTestManager([4471], { autoAnnounce: false });
const acquisition = manager.acquireCurrent();
const failure = expect(acquisition).rejects.toThrow("OpenCode server startup timeout");
await runtime.settle();
await vi.advanceTimersByTimeAsync(30_000);
await failure;
expect(runtime.terminatedPorts).toEqual([4471]);
expect(await runtime.managedProcesses.list()).toEqual([]);
});
test("shutdown kills a server that is still starting", async () => {
const { manager, runtime } = createTestManager([4472], { autoAnnounce: false });
const acquisition = manager.acquireCurrent();
await runtime.settle();
await manager.shutdown();
await expect(acquisition).rejects.toThrow("OpenCode server exited with code null");
expect(runtime.terminatedPorts).toEqual([4472]);
expect(await runtime.managedProcesses.list()).toEqual([]);
});
test("dedicated server startup is protected from retired cleanup", async () => {
const { manager, runtime } = createTestManager([4473, 4474], { autoAnnounce: false });
const currentStart = manager.acquireCurrent();
await runtime.settle();
runtime.processForPort(4473).announceListening();
const currentAcquisition = await currentStart;
const dedicatedStart = manager.acquireDedicated({ TEST_ENV: "custom" });
await runtime.settle();
currentAcquisition.release();
expect(runtime.terminatedPorts).toEqual([]);
runtime.processForPort(4474).announceListening();
const dedicatedAcquisition = await dedicatedStart;
expect(dedicatedAcquisition.server.url).toBe("http://127.0.0.1:4474");
dedicatedAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4474]);
});
test("repeated rotations leave zero unreferenced retired servers", async () => {
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
const firstAcquisition = await manager.acquire({ force: false });
const secondAcquisition = await manager.acquire({ force: true });
const firstAcquisition = await manager.acquireCurrent();
const secondAcquisition = await manager.acquireNew();
secondAcquisition.release();
const thirdAcquisition = await manager.acquire({ force: true });
const thirdAcquisition = await manager.acquireNew();
thirdAcquisition.release();
firstAcquisition.release();
@@ -122,7 +177,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
test("records helper server starts and removes the record on process exit", async () => {
const { manager, runtime } = createTestManager([4601]);
await manager.acquire({ force: false });
await manager.acquireCurrent();
expect(await runtime.managedProcesses.list()).toEqual([
{
@@ -146,7 +201,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
test("removes helper server records on shutdown", async () => {
const { manager, runtime } = createTestManager([4602]);
await manager.acquire({ force: false });
await manager.acquireCurrent();
await manager.shutdown();
@@ -155,11 +210,16 @@ describe("OpenCodeServerManager managed process ledger", () => {
});
});
function createTestManager(ports: number[]): {
function createTestManager(
ports: number[],
options: { autoAnnounce?: boolean } = {},
): {
manager: OpenCodeServerManager;
runtime: FakeOpenCodeServerRuntime;
} {
const runtime = new FakeOpenCodeServerRuntime(ports);
const runtime = new FakeOpenCodeServerRuntime(ports, {
autoAnnounce: options.autoAnnounce ?? true,
});
return {
manager: new OpenCodeServerManager({
logger: createTestLogger(),
@@ -177,11 +237,13 @@ class FakeOpenCodeServerRuntime {
readonly managedProcesses = new FakeManagedProcesses();
readonly terminatedPorts: number[] = [];
private readonly ports: number[];
private readonly autoAnnounce: boolean;
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
private readonly processesByPort = new Map<number, FakeOpenCodeProcess>();
constructor(ports: number[]) {
constructor(ports: number[], options: { autoAnnounce: boolean }) {
this.ports = [...ports];
this.autoAnnounce = options.autoAnnounce;
}
get launchedPorts(): number[] {
@@ -206,7 +268,9 @@ class FakeOpenCodeServerRuntime {
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
this.processesByChild.set(process.child, process);
this.processesByPort.set(port, process);
queueMicrotask(() => process.announceListening());
if (this.autoAnnounce) {
queueMicrotask(() => process.announceListening());
}
return process.child;
};

View File

@@ -1,27 +0,0 @@
import {
createOpencodeClient,
type OpencodeClient,
type OpencodeClientConfig,
} from "@opencode-ai/sdk/v2/client";
export interface OpenCodeServerAcquisition {
server: { port: number; url: string };
release: () => void;
}
export interface OpenCodeRuntime {
acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition>;
ensureServerRunning(): Promise<{ port: number; url: string }>;
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
shutdown(): Promise<void>;
}
export function createSdkOpenCodeClient(options: {
baseUrl: string;
directory: string;
}): OpencodeClient {
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
}

View File

@@ -23,10 +23,10 @@ export interface OpenCodeServerAcquisition {
export interface OpenCodeServerManagerLike {
ensureRunning(): Promise<{ port: number; url: string }>;
acquire(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition>;
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
acquireNew(): Promise<OpenCodeServerAcquisition>;
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
shutdown(): Promise<void>;
}
export interface OpenCodeServerGeneration {
@@ -35,7 +35,9 @@ export interface OpenCodeServerGeneration {
url: string;
refCount: number;
retired: boolean;
ready: Promise<void>;
managedProcessId?: string;
managedProcessRecord?: Promise<{ id: string } | null>;
}
export type OpenCodePortAllocator = () => Promise<number>;
@@ -62,7 +64,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
private currentServer: OpenCodeServerGeneration | null = null;
private retiredServers = new Set<OpenCodeServerGeneration>();
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
private newServerPromise: Promise<OpenCodeServerGeneration> | null = null;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string;
@@ -127,26 +129,35 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
async ensureRunning(): Promise<{ port: number; url: string }> {
const acquisition = await this.acquire({ force: false });
const acquisition = await this.acquireCurrent();
acquisition.release();
return acquisition.server;
}
async acquire(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
if (options.env) {
const server = await this.startDedicatedServer(options.env);
return this.acquireServer(server);
}
const server = options.force
? await this.getForcedRefreshServer()
: await this.getCurrentServer();
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
const server = await this.getCurrentServer();
return this.acquireServer(server);
}
async acquireNew(): Promise<OpenCodeServerAcquisition> {
const server = await this.getNewServer();
return this.acquireServer(server);
}
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
const server = await this.startServer(env);
server.retired = true;
this.retiredServers.add(server);
const acquisition = this.acquireServer(server);
try {
await server.ready;
return acquisition;
} catch (error) {
acquisition.release();
throw error;
}
}
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
server.refCount += 1;
let released = false;
@@ -163,41 +174,57 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
};
}
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
if (this.forcedRefreshPromise) {
return this.forcedRefreshPromise;
private async getNewServer(): Promise<OpenCodeServerGeneration> {
if (this.newServerPromise) {
return this.newServerPromise;
}
this.forcedRefreshPromise = Promise.resolve()
this.newServerPromise = Promise.resolve()
.then(async () => {
await this.rotateCurrentServer();
return this.getCurrentServer();
const server = await this.startServer();
if (!server.retired) {
this.currentServer = server;
}
await server.ready;
return server;
})
.finally(() => {
this.forcedRefreshPromise = null;
this.newServerPromise = null;
});
return this.forcedRefreshPromise;
return this.newServerPromise;
}
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
if (this.newServerPromise) {
return this.newServerPromise;
}
if (this.startPromise) {
return this.startPromise;
const server = await this.startPromise;
await server.ready;
return server;
}
if (this.currentServer && !this.currentServer.process.killed) {
await this.currentServer.ready;
return this.currentServer;
}
this.startPromise = this.startServer();
try {
const result = await this.startPromise;
if (!result.retired) {
this.currentServer = result;
this.startPromise = this.startServer().then((server) => {
if (!server.retired) {
this.currentServer = server;
}
return result;
} finally {
this.startPromise = null;
}
return server;
});
const currentStart = this.startPromise;
const result = await currentStart.finally(() => {
if (this.startPromise === currentStart) {
this.startPromise = null;
}
});
await result.ready;
return result;
}
private async rotateCurrentServer(): Promise<void> {
@@ -217,15 +244,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
}
private async startDedicatedServer(
env: Record<string, string>,
): Promise<OpenCodeServerGeneration> {
const server = await this.startServer(env);
server.retired = true;
this.retiredServers.add(server);
return server;
}
private async startServer(launchEnv?: Record<string, string>): Promise<OpenCodeServerGeneration> {
const port = await this.portAllocator();
const url = `http://127.0.0.1:${port}`;
@@ -233,69 +251,86 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
const serverArgs = [...launchPrefix.args, "serve", "--port", String(port)];
const serverCwd = os.homedir();
return new Promise((resolve, reject) => {
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
cwd: serverCwd,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
}),
});
const managedProcessRecord = this.recordManagedServerProcess({
process: serverProcess,
command: launchPrefix.command,
args: serverArgs,
port,
});
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
cwd: serverCwd,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
}),
});
const managedProcessRecord = this.recordManagedServerProcess({
process: serverProcess,
command: launchPrefix.command,
args: serverArgs,
port,
});
const server: OpenCodeServerGeneration = {
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
ready: Promise.resolve(),
managedProcessRecord,
};
void managedProcessRecord.then((record) => {
if (record && server.managedProcessRecord === managedProcessRecord) {
server.managedProcessId = record.id;
}
return undefined;
});
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;
let started = false;
let settled = 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 ready = new Promise<void>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout>;
const failStartup = (error: Error) => {
if (settled) {
return;
}
const remaining = STARTUP_BUFFER_CAP - current.length;
return current + chunk.slice(0, remaining);
settled = true;
clearTimeout(timeout);
reject(error);
};
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(() => {
timeout = setTimeout(() => {
if (!started) {
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
failStartup(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) {
if (output.includes("listening on") && !settled) {
started = true;
settled = true;
clearTimeout(timeout);
void (async () => {
const record = await managedProcessRecord;
resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
...(record ? { managedProcessId: record.id } : {}),
});
})();
resolve();
}
});
@@ -306,17 +341,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
});
serverProcess.on("error", (error) => {
clearTimeout(timeout);
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
const headline = error instanceof Error ? error.message : String(error);
reject(new Error(buildStartupErrorMessage(headline)));
failStartup(new Error(buildStartupErrorMessage(headline)));
});
serverProcess.on("exit", (code) => {
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
this.removeManagedServerRecord(server);
if (!started) {
clearTimeout(timeout);
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
failStartup(
new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)),
);
}
if (this.currentServer?.process === serverProcess) {
this.currentServer = null;
@@ -328,6 +362,17 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
});
});
server.ready = ready.catch(async (error) => {
await this.killServer(server);
if (this.currentServer === server) {
this.currentServer = null;
}
this.retiredServers.delete(server);
throw error;
});
return server;
}
async shutdown(): Promise<void> {
@@ -375,6 +420,9 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
if (server.managedProcessId) {
await this.removeManagedProcessId(server.managedProcessId);
server.managedProcessId = undefined;
server.managedProcessRecord = undefined;
} else {
this.removeManagedServerRecord(server);
}
}
@@ -415,6 +463,19 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
});
}
private removeManagedServerRecord(server: OpenCodeServerGeneration): void {
const record = server.managedProcessRecord;
server.managedProcessRecord = undefined;
if (server.managedProcessId) {
void this.removeManagedProcessId(server.managedProcessId);
server.managedProcessId = undefined;
return;
}
if (record) {
this.removeManagedProcessRecordWhenResolved(record);
}
}
private async removeManagedProcessId(id: string): Promise<void> {
try {
await this.managedProcesses?.remove(id);

View File

@@ -1,7 +1,7 @@
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
export interface TestOpenCodeServerAcquisition {
force: boolean;
kind: "current" | "new" | "dedicated";
env?: Record<string, string>;
released: boolean;
}
@@ -16,14 +16,26 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
return this.server;
}
async acquire(options: {
force: boolean;
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
return this.recordAcquisition({ kind: "current" });
}
async acquireNew(): Promise<OpenCodeServerAcquisition> {
return this.recordAcquisition({ kind: "new" });
}
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
return this.recordAcquisition({ kind: "dedicated", env });
}
private recordAcquisition(input: {
kind: TestOpenCodeServerAcquisition["kind"];
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
}): OpenCodeServerAcquisition {
const acquisition: TestOpenCodeServerAcquisition = {
force: options.force,
env: options.env,
kind: input.kind,
released: false,
...(input.env ? { env: input.env } : {}),
};
this.acquisitions.push(acquisition);
return {
@@ -33,6 +45,8 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
},
};
}
async shutdown(): Promise<void> {}
}
export function createTestOpenCodeServerManager(): TestOpenCodeServerManager {

View File

@@ -1,15 +1,15 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "../server-manager.js";
interface OpenCodeResponse {
data?: unknown;
error?: unknown;
}
export class TestOpenCodeRuntime implements OpenCodeRuntime {
export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
readonly acquisitions: Array<{
force: boolean;
kind: "current" | "new" | "dedicated";
env?: Record<string, string>;
releaseCount: number;
}> = [];
@@ -22,11 +22,27 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
this.clients.push(client);
}
async acquireServer(options: {
force: boolean;
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
return this.recordAcquisition({ kind: "current" });
}
async acquireNew(): Promise<OpenCodeServerAcquisition> {
return this.recordAcquisition({ kind: "new" });
}
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
return this.recordAcquisition({ kind: "dedicated", env });
}
private recordAcquisition(input: {
kind: "current" | "new" | "dedicated";
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
const acquisition = { force: options.force, env: options.env, releaseCount: 0 };
}): OpenCodeServerAcquisition {
const acquisition = {
kind: input.kind,
releaseCount: 0,
...(input.env ? { env: input.env } : {}),
};
this.acquisitions.push(acquisition);
return {
server: this.server,
@@ -36,15 +52,15 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
};
}
async ensureServerRunning(): Promise<{ port: number; url: string }> {
async ensureRunning(): Promise<{ port: number; url: string }> {
return this.server;
}
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
readonly createClient = (options: { baseUrl: string; directory: string }): OpencodeClient => {
this.clientCreations.push(options);
const client = this.clients.shift() ?? new TestOpenCodeClient();
return client.asSdkClient();
}
};
async shutdown(): Promise<void> {}
}