diff --git a/packages/adapters/opencode-local/src/server/execute.test.ts b/packages/adapters/opencode-local/src/server/execute.test.ts index 3b7bcadd23..136cd6ca8a 100644 --- a/packages/adapters/opencode-local/src/server/execute.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.test.ts @@ -1,6 +1,27 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, runAdapterExecutionTargetProcess: vi.fn() }; +}); import { ensureRemoteOpenCodeModelConfiguredAndAvailable } from "./execute.js"; +import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target"; + +const runProcessMock = vi.mocked(runAdapterExecutionTargetProcess); + +function probeResult(overrides: Record) { + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + ...overrides, + } as never; +} describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => { afterEach(() => { @@ -60,3 +81,48 @@ describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => { ).rejects.toThrow(); }); }); + +describe("ensureRemoteOpenCodeModelConfiguredAndAvailable — probe is non-fatal when it cannot run", () => { + const target = { kind: "remote", transport: "ssh" } as never; + const base = { + runId: "run-probe", + executionTarget: target, + command: "opencode", + cwd: "/tmp", + env: {} as Record, + timeoutSec: 30, + graceSec: 5, + }; + + beforeEach(() => { + runProcessMock.mockReset(); + }); + + it("proceeds when the remote probe exits non-zero (e.g. a transient `Unexpected error`)", async () => { + runProcessMock.mockResolvedValueOnce(probeResult({ exitCode: 1, stderr: "Unexpected error" })); + await expect( + ensureRemoteOpenCodeModelConfiguredAndAvailable({ ...base, model: "openai/gpt-5" }), + ).resolves.toBeUndefined(); + }); + + it("proceeds when the remote probe times out", async () => { + runProcessMock.mockResolvedValueOnce(probeResult({ timedOut: true, exitCode: null })); + await expect( + ensureRemoteOpenCodeModelConfiguredAndAvailable({ ...base, model: "openai/gpt-5" }), + ).resolves.toBeUndefined(); + }); + + it("proceeds when the remote probe returns no models", async () => { + runProcessMock.mockResolvedValueOnce(probeResult({ exitCode: 0, stdout: "" })); + await expect( + ensureRemoteOpenCodeModelConfiguredAndAvailable({ ...base, model: "openai/gpt-5" }), + ).resolves.toBeUndefined(); + }); + + it("still rejects when the probe succeeds but the configured model is absent (guard retained)", async () => { + runProcessMock.mockResolvedValueOnce(probeResult({ exitCode: 0, stdout: "openai/gpt-4.1\n" })); + await expect( + ensureRemoteOpenCodeModelConfiguredAndAvailable({ ...base, model: "openai/gpt-5" }), + ).rejects.toThrow("Configured OpenCode model is unavailable on the remote execution target"); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 91f953f2c3..2f17e3dbd6 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -124,24 +124,34 @@ export async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { }, ); + // The remote availability probe is a best-effort pre-flight guard, not a gate. + // If `opencode models` itself cannot run on the target — timeout, transient CLI + // error, provider hiccup — do NOT abort the run. The real invocation is + // authoritative, so a probe that can't execute must never be fatal. (Previously + // these threw and crashed runs mid-flight, losing the agent's work + disposition.) if (probe.timedOut) { - throw new Error(`\`opencode models\` timed out on the remote execution target after ${probeTimeoutSec}s.`); + console.warn( + `[opencode-local] Remote model availability probe for "${model}" timed out after ${probeTimeoutSec}s; proceeding with the configured model.`, + ); + return; } if ((probe.exitCode ?? 1) !== 0) { const detail = firstNonEmptyLine(probe.stderr) || firstNonEmptyLine(probe.stdout); - throw new Error( - detail - ? `\`opencode models\` failed on the remote execution target: ${detail}` - : "`opencode models` failed on the remote execution target.", + console.warn( + `[opencode-local] Remote \`opencode models\` could not run for "${model}"${ + detail ? ` (${detail})` : "" + }; proceeding with the configured model.`, ); + return; } const models = parseOpenCodeModelsOutput(probe.stdout); if (models.length === 0) { - throw new Error( - "OpenCode returned no models on the remote execution target. Run `opencode models` there and verify provider auth.", + console.warn( + `[opencode-local] Remote \`opencode models\` returned no models; proceeding with the configured model "${model}".`, ); + return; } if (!models.some((entry) => entry.id === model)) { diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts index 4a3f775c6b..71748e3792 100644 --- a/packages/adapters/opencode-local/src/server/models.test.ts +++ b/packages/adapters/opencode-local/src/server/models.test.ts @@ -37,13 +37,13 @@ describe("openCode models", () => { ); }); - it("rejects when discovery cannot run for configured model", async () => { + it("proceeds with the configured model when discovery cannot run (probe is best-effort, never fatal)", async () => { process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "openai/gpt-5", }), - ).rejects.toThrow("Failed to start command"); + ).resolves.toEqual([{ id: "openai/gpt-5", label: "openai/gpt-5" }]); }); it("skips the availability check when OPENCODE_ALLOW_ALL_MODELS is set in the run env", async () => { diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index 5838e070ab..172a5ce242 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -199,14 +199,35 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: { return [{ id: model, label: model }]; } - const models = await discoverOpenCodeModelsCached({ - command: input.command, - cwd: input.cwd, - env: input.env, - }); + let models: AdapterModel[]; + try { + models = await discoverOpenCodeModelsCached({ + command: input.command, + cwd: input.cwd, + env: input.env, + }); + } catch (err) { + // The availability probe is a best-effort pre-flight guard, not a gate. If + // `opencode models` itself cannot run — a transient CLI error, a timeout, a + // provider hiccup — do NOT abort the run. The real invocation is + // authoritative, so a probe that can't execute must never be fatal. + // (Previously this threw and crashed runs mid-flight, discarding the agent's + // completed work and its terminal disposition, which then reopened the issue.) + console.warn( + `[opencode-local] Model availability probe could not run for "${model}" (${ + err instanceof Error ? err.message : String(err) + }); proceeding with the configured model.`, + ); + return [{ id: model, label: model }]; + } if (models.length === 0) { - throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth."); + // The probe ran but returned nothing (e.g. a transient provider-auth blip). + // Same reasoning as above: warn, don't block the run. + console.warn( + `[opencode-local] \`opencode models\` returned no models; proceeding with the configured model "${model}".`, + ); + return [{ id: model, label: model }]; } if (!models.some((entry) => entry.id === model)) {