diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index e60515694ba5..0b8cd08a381c 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -28,7 +28,10 @@ import { openCodeCommandsToServerProviderSlashCommands, } from "./OpenCodeProvider.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; -import { probeOpenCodeRuntime } from "../opencodeVersionProbe.ts"; +import { + OPENCODE_VERSION_PROBE_TIMEOUT_ENV, + probeOpenCodeRuntime, +} from "../opencodeVersionProbe.ts"; import { OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES, @@ -170,6 +173,7 @@ const runtimeMock = { state: { runVersionError: null as Error | null, runVersionPending: false, + runVersionDelayMs: null as number | null, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, connectionError: null as Error | null, @@ -189,6 +193,7 @@ const runtimeMock = { reset() { this.state.runVersionError = null; this.state.runVersionPending = false; + this.state.runVersionDelayMs = null; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; this.state.connectionError = null; @@ -261,7 +266,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: runtimeMock.state.runVersionError, }), ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + : runtimeMock.state.runVersionDelayMs === null + ? Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }) + : Effect.sleep(runtimeMock.state.runVersionDelayMs).pipe( + Effect.as({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + ), createOpenCodeSdkClient: (input) => { runtimeMock.state.sdkClientInputs.push(input); return {} as unknown as ReturnType< @@ -387,7 +396,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("times out a hanging local CLI version probe", () => Effect.gen(function* () { runtimeMock.state.runVersionPending = true; - const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {}).pipe( + Effect.forkChild, + ); yield* Effect.yieldNow; yield* TestClock.adjust("4 seconds"); @@ -402,6 +413,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("keeps the 4 second default when a cold start needs 5 seconds", () => + Effect.gen(function* () { + runtimeMock.state.runVersionDelayMs = 5_000; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {}).pipe( + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("waits for a slow version probe when the environment timeout allows it", () => + Effect.gen(function* () { + runtimeMock.state.runVersionDelayMs = 5_000; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "8s", + }).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("5 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "1.14.19"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("clamps an oversized version probe timeout to 60 seconds", () => + Effect.gen(function* () { + runtimeMock.state.runVersionPending = true; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "2 hours", + }).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("59 seconds"); + yield* Effect.yieldNow; + NodeAssert.equal(probeFiber.pollUnsafe(), undefined); + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 60 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("still reports a missing binary when the version probe timeout is raised", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const snapshot = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "60 seconds", + }); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.equal(snapshot.version, null); + NodeAssert.equal( + snapshot.message, + "OpenCode CLI (`opencode`) is not installed or not on PATH.", + ); + }), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 332a5e931d29..6ffd3753c329 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -913,6 +913,198 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test ); }); + it("keeps a ready OpenCode snapshot through a version-probe timeout", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], + } as const satisfies ServerProvider; + const timedOutProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + } satisfies ServerProvider; + + const merged = mergeProviderSnapshot(previousProvider, timedOutProvider); + assert.equal(merged.status, "ready"); + assert.equal(merged.version, previousProvider.version); + assert.deepStrictEqual(merged.auth, previousProvider.auth); + assert.deepStrictEqual(merged.models, [...previousProvider.models]); + assert.deepStrictEqual(merged.slashCommands, previousProvider.slashCommands); + assert.deepStrictEqual(merged.skills, previousProvider.skills); + assert.equal(merged.message, timedOutProvider.message); + assert.equal(merged.checkedAt, timedOutProvider.checkedAt); + + for (const message of [ + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1 second.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 60 seconds.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1500 millis.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1235 millis.", + ]) { + const variant = mergeProviderSnapshot(previousProvider, { ...timedOutProvider, message }); + assert.equal(variant.status, "ready", message); + assert.equal(variant.version, previousProvider.version, message); + assert.deepStrictEqual(variant.auth, previousProvider.auth, message); + } + }); + + it("does not treat a launch failure that only contains the timeout phrase as a version-probe timeout", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const binaryPath = "/opt/version probe timed out/opencode"; + const messages = [ + `Failed to execute OpenCode CLI health check: Failed to execute '${binaryPath} --version': spawn ${binaryPath} ENOENT`, + "version probe timed out", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds. See the binary path.", + "Note: Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds", + ]; + + for (const message of messages) { + const failedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message, + } satisfies ServerProvider; + const merged = mergeProviderSnapshot(previousProvider, failedProvider); + assert.equal(merged.status, "error", message); + assert.equal(merged.version, null, message); + assert.deepStrictEqual(merged.auth, { status: "unknown" }, message); + assert.equal(merged.message, message); + } + }); + + it("does not keep last-known-good OpenCode state without a ready versioned snapshot", () => { + const pendingProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: "OpenCode provider status has not been checked in this session yet.", + } as const satisfies ServerProvider; + const timedOutProvider = { + ...pendingProvider, + status: "error", + installed: true, + checkedAt: "2026-07-17T00:01:00.000Z", + message: + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + } satisfies ServerProvider; + + const merged = mergeProviderSnapshot(pendingProvider, timedOutProvider); + assert.equal(merged.status, "error"); + assert.equal(merged.version, null); + assert.equal(merged.message, timedOutProvider.message); + }); + + it("does not keep a ready OpenCode snapshot for a missing binary or other probe error", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const otherError = { + ...missingProvider, + installed: true, + message: "Failed to execute OpenCode CLI health check: spawn opencode ENOENT", + } satisfies ServerProvider; + + const missing = mergeProviderSnapshot(previousProvider, missingProvider); + assert.equal(missing.status, "error"); + assert.equal(missing.installed, false); + assert.equal(missing.version, null); + assert.deepStrictEqual(missing.auth, { status: "unknown" }); + + const failed = mergeProviderSnapshot(previousProvider, otherError); + assert.equal(failed.status, "error"); + assert.equal(failed.version, null); + assert.deepStrictEqual(failed.auth, { status: "unknown" }); + }); + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index de476ca0299a..877335949f5b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -42,6 +42,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ModelManifest from "../ModelManifest.ts"; import { applyProviderCompatibility } from "../providerCompatibility.ts"; +import { isOpenCodeVersionProbeTimeoutMessage } from "../opencodeVersionProbe.ts"; import * as ServerConfig from "../../config.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -218,6 +219,42 @@ const carrySavedAntigravityAccount = ( return { auth: previousProvider.auth, status }; }; +/** + * A slow OpenCode `--version` must not mark a provider Unavailable after it + * has already reported a version. Only an exact timeout against a previous + * ready, versioned snapshot keeps status, version, and auth. + */ +function carryLastKnownOpenCodeOnVersionProbeTimeout( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): Pick | undefined { + if ( + !isOpenCodeVersionProbeTimeoutMessage(nextProvider.message) || + nextProvider.driver !== ProviderDriverKind.make("opencode") || + !nextProvider.installed || + nextProvider.status !== "error" || + nextProvider.version !== null || + previousProvider.driver !== ProviderDriverKind.make("opencode") || + previousProvider.status !== "ready" || + previousProvider.version === null || + previousProvider.version.length === 0 + ) { + return undefined; + } + + return { + auth: previousProvider.auth, + status: previousProvider.status, + version: previousProvider.version, + }; +} + +/** + * Folds a fresh probe into the previous snapshot. Carries a saved Antigravity + * account forward, and keeps a ready OpenCode version, status, and auth across + * an exact `--version` timeout. Models, workspace snapshots, and empty OpenCode + * skills or slash commands stay when the new probe did not replace them. + */ export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, @@ -226,12 +263,17 @@ export const mergeProviderSnapshot = ( return nextProvider; } const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); + const lastKnownOpenCode = carryLastKnownOpenCodeOnVersionProbeTimeout( + previousProvider, + nextProvider, + ); // "Google account access is not checked yet" describes the probe, not the // account; it must not outlive the state it explained. const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; return { ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), ...savedAccount, + ...lastKnownOpenCode, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), ...(nextProvider.workspaceSnapshots !== undefined ? { workspaceSnapshots: nextProvider.workspaceSnapshots } diff --git a/apps/server/src/provider/opencodeVersionProbe.test.ts b/apps/server/src/provider/opencodeVersionProbe.test.ts index 17b2ba201b27..dbea1c050490 100644 --- a/apps/server/src/provider/opencodeVersionProbe.test.ts +++ b/apps/server/src/provider/opencodeVersionProbe.test.ts @@ -1,12 +1,17 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import { HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"; import * as OpenCodeRuntime from "./opencodeRuntime.ts"; import { classifyOpenCodeCliVersion, + isOpenCodeVersionProbeTimeoutMessage, makeOpenCodeRuntimeProbe, + OPENCODE_VERSION_PROBE_TIMEOUT_ENV, + openCodeVersionProbeTimeoutDetail, probeOpenCodeRuntime, + resolveOpenCodeVersionProbeTimeout, } from "./opencodeVersionProbe.ts"; import { OPENCODE_1_RESPONSES, @@ -26,6 +31,58 @@ const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient const SERVER_URL = "http://127.0.0.1:4096/"; describe("OpenCode version probe", () => { + it("resolves the version probe timeout without changing the 4 second default", () => { + const millis = (environment: NodeJS.ProcessEnv) => + Duration.toMillis(resolveOpenCodeVersionProbeTimeout(environment)); + + assert.equal(millis({}), Duration.toMillis(Duration.seconds(4))); + assert.equal( + millis({ [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "8 seconds" }), + Duration.toMillis(Duration.seconds(8)), + ); + assert.equal( + millis({ [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "8s" }), + Duration.toMillis(Duration.seconds(8)), + ); + assert.equal( + millis({ [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "nope" }), + Duration.toMillis(Duration.seconds(4)), + ); + assert.equal( + millis({ [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "0 seconds" }), + Duration.toMillis(Duration.seconds(4)), + ); + assert.equal( + millis({ [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "1 ms" }), + Duration.toMillis(Duration.seconds(1)), + ); + assert.equal( + millis({ [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "Infinity" }), + Duration.toMillis(Duration.seconds(60)), + ); + assert.equal( + millis({ [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "2 hours" }), + Duration.toMillis(Duration.seconds(60)), + ); + assert.equal(millis({ [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "1.2345s" }), 1235); + const fractionalDetail = openCodeVersionProbeTimeoutDetail( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "1.2345s", + }), + ); + assert.equal(fractionalDetail, "OpenCode CLI version probe timed out after 1235 millis."); + assert.isTrue( + isOpenCodeVersionProbeTimeoutMessage( + `Failed to execute OpenCode CLI health check: ${fractionalDetail}`, + ), + ); + assert.isFalse( + isOpenCodeVersionProbeTimeoutMessage( + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds. See the binary path.", + ), + ); + }); + it("classifies the recorded `opencode --version` output of both versions", () => { assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), { generation: "v1", diff --git a/apps/server/src/provider/opencodeVersionProbe.ts b/apps/server/src/provider/opencodeVersionProbe.ts index f31a79b63b47..80353269098f 100644 --- a/apps/server/src/provider/opencodeVersionProbe.ts +++ b/apps/server/src/provider/opencodeVersionProbe.ts @@ -16,7 +16,6 @@ export interface ProbedOpenCode { readonly version: string; } -const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; // 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out. const decodeApiInfo = Schema.decodeUnknownOption( @@ -26,6 +25,122 @@ const decodeGlobalHealth = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), ); +/** + * Product default from #8750. It stays 4 seconds so a hung `--version` is + * still killed quickly. Slow hosts opt in with + * `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` instead of changing it. + */ +const DEFAULT_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(4); +const MIN_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(1); +const MAX_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(60); +/** Environment variable that overrides the 4 second `--version` cap. */ +export const OPENCODE_VERSION_PROBE_TIMEOUT_ENV = "T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT"; +const OPENCODE_VERSION_PROBE_TIMEOUT_SHORTHAND = /^(?\d+(?:\.\d+)?)\s*(?ms|s|m|h)$/i; +const OPENCODE_VERSION_PROBE_TIMEOUT_UNITS = { + ms: "millis", + s: "seconds", + m: "minutes", + h: "hours", +} as const; +const OPENCODE_CLI_HEALTH_CHECK_FAILURE = "Failed to execute OpenCode CLI health check"; +const OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX = `${OPENCODE_CLI_HEALTH_CHECK_FAILURE}: OpenCode CLI version probe timed out after `; +// Labels `formatOpenCodeVersionProbeTimeout` emits: "1 second", "N seconds", or integer millis. +const OPENCODE_VERSION_PROBE_TIMEOUT_LABEL = /^(?:1 second|[1-9]\d* seconds|[1-9]\d* millis)$/; + +/** + * Duration label in the `--version` timeout message. Whole seconds render as + * "1 second" or "N seconds"; every other duration renders as integer millis. + */ +function formatOpenCodeVersionProbeTimeout(duration: Duration.Duration): string { + const millis = Math.round(Duration.toMillis(duration)); + if (millis % 1000 === 0) { + const seconds = millis / 1000; + return seconds === 1 ? "1 second" : `${seconds} seconds`; + } + return `${millis} millis`; +} + +/** + * Detail text for a `--version` probe that reached its cap. Snapshot merging + * recognizes this only after the health-check prefix is added. + */ +export function openCodeVersionProbeTimeoutDetail(duration: Duration.Duration): string { + return `OpenCode CLI version probe timed out after ${formatOpenCodeVersionProbeTimeout(duration)}.`; +} + +/** + * True when `message` is the exact provider text for a local `--version` + * timeout. A launch error that only contains that phrase does not match. + */ +export function isOpenCodeVersionProbeTimeoutMessage(message: string | undefined): boolean { + if ( + message === undefined || + !message.startsWith(OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX) || + !message.endsWith(".") + ) { + return false; + } + const label = message.slice(OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX.length, -1); + if (!OPENCODE_VERSION_PROBE_TIMEOUT_LABEL.test(label)) { + return false; + } + return message === `${OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX}${label}.`; +} + +/** + * Parses `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT`. Empty or unparseable text + * returns undefined so the 4 second default stands. `ms`, `s`, `m`, and `h` + * shorthand is accepted. Finite values clamp to 1-60 seconds; non-positive + * values are ignored and an infinite value uses the 60 second cap. + */ +function parseOpenCodeVersionProbeTimeout(raw: string | undefined): Duration.Duration | undefined { + const trimmed = raw?.trim() ?? ""; + if (trimmed.length === 0) { + return undefined; + } + + const shorthand = OPENCODE_VERSION_PROBE_TIMEOUT_SHORTHAND.exec(trimmed); + const shorthandAmount = shorthand?.groups?.amount; + const shorthandUnit = shorthand?.groups?.unit?.toLowerCase(); + const normalized = + shorthandAmount !== undefined && + shorthandUnit !== undefined && + shorthandUnit in OPENCODE_VERSION_PROBE_TIMEOUT_UNITS + ? `${shorthandAmount} ${OPENCODE_VERSION_PROBE_TIMEOUT_UNITS[shorthandUnit as keyof typeof OPENCODE_VERSION_PROBE_TIMEOUT_UNITS]}` + : trimmed; + const decoded = Duration.fromInput(normalized as Duration.Input); + if (Option.isNone(decoded)) { + return undefined; + } + + const millis = Duration.toMillis(decoded.value); + if (!Number.isFinite(millis) || millis <= 0) { + return millis > 0 ? MAX_OPENCODE_VERSION_PROBE_TIMEOUT : undefined; + } + if (millis < Duration.toMillis(MIN_OPENCODE_VERSION_PROBE_TIMEOUT)) { + return MIN_OPENCODE_VERSION_PROBE_TIMEOUT; + } + if (millis > Duration.toMillis(MAX_OPENCODE_VERSION_PROBE_TIMEOUT)) { + return MAX_OPENCODE_VERSION_PROBE_TIMEOUT; + } + return decoded.value; +} + +/** + * `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` overrides the 4 second default. + * Invalid text is ignored. Accepted values are clamped to 1-60 seconds and + * rounded to whole milliseconds so the timeout message stays exact. + */ +export function resolveOpenCodeVersionProbeTimeout( + environment?: NodeJS.ProcessEnv, +): Duration.Duration { + const parsed = + parseOpenCodeVersionProbeTimeout(environment?.[OPENCODE_VERSION_PROBE_TIMEOUT_ENV]) ?? + DEFAULT_OPENCODE_VERSION_PROBE_TIMEOUT; + return Duration.millis(Math.round(Duration.toMillis(parsed))); +} + +/** Returns the OpenCode generation for a parsed CLI or server version. */ function probed(version: string | null | undefined): ProbedOpenCode | undefined { const major = parseSemver(version ?? "")?.major; if (!version || major === undefined) return undefined; @@ -59,36 +174,49 @@ function classifyOpenCodeProbeResponse( return probed(Option.getOrUndefined(version)); } -const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( - binaryPath: string, - environment: NodeJS.ProcessEnv | undefined, -) { - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; - const { stdout } = yield* runtime - .runOpenCodeCommand({ - binaryPath, - args: ["--version"], - ...(environment === undefined ? {} : { environment }), - }) - .pipe( - Effect.timeoutOrElse({ - duration: OPENCODE_VERSION_PROBE_TIMEOUT, - orElse: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeBinary", - detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, - }), - ), - }), - ); - const result = classifyOpenCodeCliVersion(stdout); - if (result) return result; - return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "probeOpenCodeBinary", - detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, - }); -}); +/** + * Local `opencode --version`. The 4 second default is unchanged; + * `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` can raise the cap. + */ +const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")( + /** + * Local `opencode --version`. The 4 second default is unchanged; + * `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` can raise the cap. + */ + function* (binaryPath: string, environment: NodeJS.ProcessEnv | undefined) { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const timeout = resolveOpenCodeVersionProbeTimeout(environment ?? process.env); + /** + * Fails this `--version` probe with the exact timeout detail for its cap. + */ + function failTimedOutProbe() { + return Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeBinary", + detail: openCodeVersionProbeTimeoutDetail(timeout), + }), + ); + } + const { stdout } = yield* runtime + .runOpenCodeCommand({ + binaryPath, + args: ["--version"], + ...(environment === undefined ? {} : { environment }), + }) + .pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: failTimedOutProbe, + }), + ); + const result = classifyOpenCodeCliVersion(stdout); + if (result) return result; + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeBinary", + detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, + }); + }, +); // Server failures reach clients through the provider status, so their details are fixed text: // the underlying error can carry the configured URL, its credentials, or the password header. diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 9ecee2a0439c..1dfe8466b9c3 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -37,6 +37,15 @@ fail, check the URL, credentials, and OpenCode version, then refresh provider st After a lost connection, send another prompt to reconnect to the same OpenCode session. +## Slow version checks + +If OpenCode is installed but a refresh leaves it unavailable, the version check +may be timing out. Set `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` on the machine +running T3 Code, for example `10 seconds`. The default wait is 4 seconds, and +any value is limited to 1-60 seconds. Once OpenCode has already been ready, a +slow check keeps that status. A missing `opencode` binary still reports as not +installed. + ## Approvals OpenCode follows the shared [permission modes](./permission-modes.md). **Auto** has