diff --git a/docs/providers.md b/docs/providers.md index b2f1ec0b6..63d6bb36b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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. --- diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 6d8876444..dd4998bcc 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -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", diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index 6c2a08f5e..032e7299a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -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 }), diff --git a/packages/server/src/server/agent/providers/opencode-agent.permission-actions.test.ts b/packages/server/src/server/agent/providers/opencode-agent.permission-actions.test.ts index bf8c98b3f..164e0d83d 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.permission-actions.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.permission-actions.test.ts @@ -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", diff --git a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts index 14757b98c..11465fe83 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts @@ -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(): { promise: Promise; @@ -24,11 +24,14 @@ function createDeferred(): { 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(); - 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"); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 48f79273b..c3999eed6 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -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); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index aec31ce7c..e9e919591 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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; - }): Promise { - 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 { - 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(); @@ -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 { 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 { - 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 { 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 { - 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 { - await this.runtime.shutdown(); + await this.serverManager.shutdown(); } async getDiagnostic(): Promise<{ diagnostic: string }> { diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 7f06d6c47..a8cd8abcf 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -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(); private readonly processesByPort = new Map(); - 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; }; diff --git a/packages/server/src/server/agent/providers/opencode/runtime.ts b/packages/server/src/server/agent/providers/opencode/runtime.ts deleted file mode 100644 index 4c4cb6440..000000000 --- a/packages/server/src/server/agent/providers/opencode/runtime.ts +++ /dev/null @@ -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; - }): Promise; - ensureServerRunning(): Promise<{ port: number; url: string }>; - createClient(options: { baseUrl: string; directory: string }): OpencodeClient; - shutdown(): Promise; -} - -export function createSdkOpenCodeClient(options: { - baseUrl: string; - directory: string; -}): OpencodeClient { - return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string }); -} diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 04c7e6ff5..fe4a14fc7 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -23,10 +23,10 @@ export interface OpenCodeServerAcquisition { export interface OpenCodeServerManagerLike { ensureRunning(): Promise<{ port: number; url: string }>; - acquire(options: { - force: boolean; - env?: Record; - }): Promise; + acquireCurrent(): Promise; + acquireNew(): Promise; + acquireDedicated(env: Record): Promise; + shutdown(): Promise; } export interface OpenCodeServerGeneration { @@ -35,7 +35,9 @@ export interface OpenCodeServerGeneration { url: string; refCount: number; retired: boolean; + ready: Promise; managedProcessId?: string; + managedProcessRecord?: Promise<{ id: string } | null>; } export type OpenCodePortAllocator = () => Promise; @@ -62,7 +64,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { private currentServer: OpenCodeServerGeneration | null = null; private retiredServers = new Set(); private startPromise: Promise | null = null; - private forcedRefreshPromise: Promise | null = null; + private newServerPromise: Promise | 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; - }): Promise { - 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 { + const server = await this.getCurrentServer(); return this.acquireServer(server); } + async acquireNew(): Promise { + const server = await this.getNewServer(); + return this.acquireServer(server); + } + + async acquireDedicated(env: Record): Promise { + 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 { - if (this.forcedRefreshPromise) { - return this.forcedRefreshPromise; + private async getNewServer(): Promise { + 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 { + 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 { @@ -217,15 +244,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } } - private async startDedicatedServer( - env: Record, - ): Promise { - const server = await this.startServer(env); - server.retired = true; - this.retiredServers.add(server); - return server; - } - private async startServer(launchEnv?: Record): Promise { 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((resolve, reject) => { + let timeout: ReturnType; + 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 { @@ -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 { try { await this.managedProcesses?.remove(id); diff --git a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts index cda20879d..d7461f672 100644 --- a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -1,7 +1,7 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js"; export interface TestOpenCodeServerAcquisition { - force: boolean; + kind: "current" | "new" | "dedicated"; env?: Record; released: boolean; } @@ -16,14 +16,26 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { return this.server; } - async acquire(options: { - force: boolean; + async acquireCurrent(): Promise { + return this.recordAcquisition({ kind: "current" }); + } + + async acquireNew(): Promise { + return this.recordAcquisition({ kind: "new" }); + } + + async acquireDedicated(env: Record): Promise { + return this.recordAcquisition({ kind: "dedicated", env }); + } + + private recordAcquisition(input: { + kind: TestOpenCodeServerAcquisition["kind"]; env?: Record; - }): Promise { + }): 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 {} } export function createTestOpenCodeServerManager(): TestOpenCodeServerManager { diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts similarity index 89% rename from packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts rename to packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index e53f7ded5..0410c8abb 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -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; releaseCount: number; }> = []; @@ -22,11 +22,27 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime { this.clients.push(client); } - async acquireServer(options: { - force: boolean; + async acquireCurrent(): Promise { + return this.recordAcquisition({ kind: "current" }); + } + + async acquireNew(): Promise { + return this.recordAcquisition({ kind: "new" }); + } + + async acquireDedicated(env: Record): Promise { + return this.recordAcquisition({ kind: "dedicated", env }); + } + + private recordAcquisition(input: { + kind: "current" | "new" | "dedicated"; env?: Record; - }): Promise { - 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 {} }