From c5295a4ab35b6c3b77efee489234f684b1b67792 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Wed, 15 Apr 2026 12:52:15 +1000 Subject: [PATCH] fix(server): archive OpenCode sessions on close (#408) Reconcile OpenCode provider state when Paseo closes or archives an agent so upstream sessions do not remain active after local teardown. Keep the close path idempotent by attempting an abort first and then marking the upstream session archived even when the run is already idle or missing. Fixes #400 Co-authored-by: OpenCode --- .../agent/providers/opencode-agent.test.ts | 59 +++++++++++++++ .../server/agent/providers/opencode-agent.ts | 74 +++++++++++++++++++ 2 files changed, 133 insertions(+) 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 e6e38f560..e478a2ea2 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -315,6 +315,65 @@ const hasOpenCode = isBinaryInstalled("opencode"); }); describe("OpenCode adapter context-window normalization", () => { + test("close reconciliation aborts then archives upstream session", async () => { + const abort = vi.fn().mockResolvedValue({ data: true, error: undefined }); + const update = vi.fn().mockResolvedValue({ + data: { id: "session-1", time: { archived: Date.now() } }, + error: undefined, + }); + + await __openCodeInternals.reconcileOpenCodeSessionClose({ + client: { + session: { + abort, + update, + }, + } as never, + sessionId: "session-1", + directory: "/tmp/project", + logger: createTestLogger(), + }); + + expect(abort).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/tmp/project", + }); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/tmp/project", + time: { + archived: expect.any(Number), + }, + }); + }); + + test("close reconciliation still archives when abort returns an error", async () => { + const abort = vi.fn().mockResolvedValue({ + data: undefined, + error: { data: {}, errors: [], success: false }, + }); + const update = vi.fn().mockResolvedValue({ + data: { id: "session-1", time: { archived: Date.now() } }, + error: undefined, + }); + + await __openCodeInternals.reconcileOpenCodeSessionClose({ + client: { + session: { + abort, + update, + }, + } as never, + sessionId: "session-1", + directory: "/tmp/project", + logger: createTestLogger(), + }); + + expect(abort).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + test("builds OpenCode file parts for image prompt blocks", () => { expect( __openCodeInternals.buildOpenCodePromptParts([ diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index cc3ca96e8..3e036bd8c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -231,6 +231,73 @@ function normalizeTurnFailureError(error: unknown): string { return normalized.length > 0 ? normalized : "Unknown error"; } +function isOpenCodeNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "NotFoundError" + ); +} + +async function reconcileOpenCodeSessionClose(params: { + client: Pick; + sessionId: string; + directory: string; + logger: Logger; +}): Promise { + const { client, sessionId, directory, logger } = params; + + try { + const response = await client.session.abort({ + sessionID: sessionId, + directory, + }); + if (response.error && !isOpenCodeNotFoundError(response.error)) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(response.error), + }, + "Failed to abort OpenCode session during close", + ); + } + } catch (error) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(error), + }, + "Failed to abort OpenCode session during close", + ); + } + + try { + const response = await client.session.update({ + sessionID: sessionId, + directory, + time: { archived: Date.now() }, + }); + if (response.error && !isOpenCodeNotFoundError(response.error)) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(response.error), + }, + "Failed to archive OpenCode session during close", + ); + } + } catch (error) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(error), + }, + "Failed to archive OpenCode session during close", + ); + } +} + function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean { const normalized = typeof message === "string" ? message.trim().toLowerCase() : ""; if (!normalized) { @@ -570,6 +637,7 @@ export const __openCodeInternals = { hasNormalizedOpenCodeUsage, mergeOpenCodeStepFinishUsage, parseOpenCodeModelLookupKey, + reconcileOpenCodeSessionClose, resolveOpenCodeModelLookupKeyFromAssistantMessage, resolveOpenCodeSelectedModelContextWindow, }; @@ -1932,6 +2000,12 @@ class OpenCodeAgentSession implements AgentSession { async close(): Promise { this.abortController?.abort(); + await reconcileOpenCodeSessionClose({ + client: this.client, + sessionId: this.sessionId, + directory: this.config.cwd, + logger: this.logger, + }); this.subscribers.clear(); this.activeForegroundTurnId = null; }