From 715bcd79e75adf27883d15fbd19cdd26e856ebcb Mon Sep 17 00:00:00 2001 From: "johan.thomas" Date: Mon, 9 Nov 2026 09:30:02 +0000 Subject: [PATCH 1/6] fix(server): allow configuring the Codex provider probe timeout --- apps/server/src/provider/CodexProvider.ts | 16 +++++- .../src/provider/ProviderRegistry.test.ts | 51 +++++++++++++++++++ docs/user/providers-codex.md | 21 ++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2d546b36a9b15..74d70b92efc88 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -77,6 +77,14 @@ const CODEX_CLIENT_NAME = "T3 Code"; // Handshake plus account/models/skills requests can exceed the shared 10s probe budget. const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000; const CODEX_DISCOVERY_MARGIN_MS = 250; + +const resolveCodexProbeTimeoutMs = (environment: NodeJS.ProcessEnv): number => { + const timeoutMs = Number(environment.T3CODE_CODEX_PROBE_TIMEOUT_MS); + // Node timers overflow above a signed 32-bit delay and can fire immediately. + return Number.isInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 2_147_483_647 + ? timeoutMs + : CODEX_AUTH_PROBE_TIMEOUT_MS; +}; // `codex-cli 0.159.0`, `codex-cli 0.160.0-alpha.1` or the npm CLI's bare `0.1.4161006`. // Keep a prerelease suffix so compatibility stays unknown for it, as it does for `initialize`. const CODEX_CLI_VERSION_PATTERN = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$)/u; @@ -526,6 +534,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu }); const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { + readonly probeTimeoutMs: number; readonly binaryPath: string; readonly homePath?: string; readonly launchArgs?: string; @@ -550,7 +559,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } // Optional discovery must finish before the outer timeout can discard the account result. - const deadlineMs = spawnedAtMs + CODEX_AUTH_PROBE_TIMEOUT_MS - CODEX_DISCOVERY_MARGIN_MS; + const deadlineMs = spawnedAtMs + input.probeTimeoutMs - CODEX_DISCOVERY_MARGIN_MS; const [skillsResponse, models, serviceTier, rateLimits] = yield* Effect.all( [ withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), @@ -735,6 +744,7 @@ function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnaps export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( codexSettings: CodexSettings, probe: (input: { + readonly probeTimeoutMs: number; readonly binaryPath: string; readonly homePath?: string; readonly launchArgs?: string; @@ -774,7 +784,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); } + const probeTimeoutMs = resolveCodexProbeTimeoutMs(resolvedEnvironment); const probeResult = yield* probe({ + probeTimeoutMs, binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), @@ -783,7 +795,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ...(managedAuth ? { skipNativeUsage: true } : {}), }).pipe( // Process teardown can outlast the work budget without discarding a completed snapshot. - Effect.timeoutOption(Duration.millis(CODEX_AUTH_PROBE_TIMEOUT_MS)), + Effect.timeoutOption(Duration.millis(probeTimeoutMs)), Effect.scoped, Effect.result, ); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 9b1187493967c..c124be12138aa 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1,3 +1,4 @@ +import * as Duration from "effect/Duration"; import * as CodexErrors from "effect-codex-app-server/errors"; import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -415,6 +416,56 @@ it.layer( }), ); + it.effect("allows a slow successful Codex probe with a configured budget", () => + Effect.gen(function* () { + const fiber = yield* checkCodexProviderStatus( + defaultCodexSettings, + () => Effect.sleep("22 seconds").pipe(Effect.as(makeCodexProbeSnapshot())), + { T3CODE_CODEX_PROBE_TIMEOUT_MS: "60000" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("22 seconds"); + const status = yield* Fiber.join(fiber); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }), + ); + + it.effect.each([ + ["60000", 60000], + ["1000", 1000], + [undefined, 20000], + ["", 20000], + ["invalid", 20000], + ["0", 20000], + ["-1", 20000], + ["1.5", 20000], + ["Infinity", 20000], + ["2147483648", 20000], + ] as const)("bounds a stalled probe with timeout setting %s", ([value, timeoutMs]) => + Effect.gen(function* () { + const released = yield* Deferred.make(); + const fiber = yield* checkCodexProviderStatus( + defaultCodexSettings, + () => + Effect.addFinalizer(() => Deferred.succeed(released, undefined)).pipe( + Effect.andThen(Effect.never), + ), + { T3CODE_CODEX_PROBE_TIMEOUT_MS: value }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.millis(timeoutMs - 1)); + assert.isFalse(yield* Deferred.isDone(released)); + yield* TestClock.adjust("1 millis"); + const status = yield* Fiber.join(fiber); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual( + status.message, + "Timed out while checking Codex app-server provider status.", + ); + assert.isTrue(yield* Deferred.isDone(released)); + }), + ); + it.effect("labels the plan the usage read reports over the cached login plan", () => Effect.gen(function* () { const statusWithUsagePlan = (planType: string | null) => diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 3384831aa87b9..8828dc98c0e90 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -33,6 +33,27 @@ Running `codex login` does not change a managed ChatGPT connection. In and complete the wizard. Then select that instance from the thread's model picker. See [Use multiple accounts](#use-multiple-accounts). +## Allow more time for provider checks + +If an authenticated Codex installation reports “Timed out while checking Codex +app-server provider status,” set `T3CODE_CODEX_PROBE_TIMEOUT_MS` in the T3 server's +environment and restart the server. For example, allow 60 seconds: + +```bash +T3CODE_CODEX_PROBE_TIMEOUT_MS=60000 t3 serve +``` + +For a remote environment, set this on the remote machine that runs T3 Code. +Keep it in your launcher or service configuration so it survives application +updates. For systemd, add `Environment=T3CODE_CODEX_PROBE_TIMEOUT_MS=60000` under +`[Service]` in a service override, reload systemd, and restart the service. + +The default is 20000 milliseconds. Values must be positive whole milliseconds +no greater than 2147483647; invalid values use the default. This controls the +overall Codex provider status check, including its skills and model discovery +budget. Usage and config requests keep their shorter individual limits. Longer +budgets also take longer to report a stalled probe. + ## Use multiple accounts Add another ChatGPT account in **Settings → Providers**, then select the account From ff740f4737683a39f346aaf3f301e9ccd6b926ac Mon Sep 17 00:00:00 2001 From: "johan.thomas" Date: Mon, 9 Nov 2026 09:42:59 +0000 Subject: [PATCH 2/6] fix(provider): configure readiness deadlines across providers --- .../src/provider/AntigravityProvider.ts | 14 +++-- apps/server/src/provider/ClaudeProvider.ts | 14 ++++- apps/server/src/provider/CodexProvider.ts | 21 ++++--- .../src/provider/Drivers/AntigravityDriver.ts | 1 + .../src/provider/Drivers/CodexDriver.ts | 3 +- docs/user/install.md | 41 +++++++++++++ docs/user/providers-codex.md | 20 +------ .../provider-acp-registry/src/server/probe.ts | 13 +++-- .../src/server/snapshotProbe.test.ts | 35 +++++++++++ .../provider-core/src/server/snapshotProbe.ts | 12 ++++ .../provider-cursor/src/server/status.test.ts | 52 +++++++++++++++++ packages/provider-cursor/src/server/status.ts | 10 +++- packages/provider-fx/src/server/status.ts | 7 ++- packages/provider-grok/src/server/skills.ts | 5 +- packages/provider-grok/src/server/status.ts | 14 ++++- packages/provider-kiro/src/server/status.ts | 18 ++++-- .../provider-muse/src/server/modelCatalog.ts | 3 +- packages/provider-muse/src/server/status.ts | 10 +++- .../src/server/OpenCodeRuntime.ts | 20 ++++++- .../provider-opencode/src/server/driver.ts | 11 +++- .../provider-opencode/src/server/status.ts | 14 ++++- .../src/server/v2/OpenCode2Server.ts | 9 +-- .../src/server/versionProbe.ts | 22 +++++-- packages/provider-pi/src/server/status.ts | 58 ++++++++++++++----- 24 files changed, 340 insertions(+), 87 deletions(-) diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index ed047331e73ee..b140c7511ca14 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { ANTIGRAVITY_DEFAULT_MODEL, ProviderDriverKind, @@ -40,7 +41,7 @@ import { antigravityThinkingConfig } from "./acp/AntigravityAcpSupport.ts"; const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); const MAX_WORKSPACE_SNAPSHOTS = 32; -const HEALTH_CHECK_TIMEOUT = "90 seconds"; +const HEALTH_CHECK_TIMEOUT_MS = 90_000; const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; const AUTH_UNCHECKED_MESSAGE = "Antigravity is installed. Google account access is not checked yet."; @@ -155,6 +156,7 @@ interface AntigravityProviderState { } interface AntigravityProviderOptions { + readonly environment?: NodeJS.ProcessEnv; readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect; readonly probe: Effect.Effect< EffectAcpSchema.InitializeResponse, @@ -271,10 +273,12 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { if (!settings.enabled) return yield* getSnapshot; const before = yield* SubscriptionRef.get(metadata); - const result = yield* options.probe.pipe( - Effect.timeoutOption(HEALTH_CHECK_TIMEOUT), - Effect.result, + const timeoutMs = resolveProviderProbeTimeoutMs( + "antigravity", + HEALTH_CHECK_TIMEOUT_MS, + options.environment, ); + const result = yield* options.probe.pipe(Effect.timeoutOption(timeoutMs), Effect.result); const initialized = Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined; const version = initialized?.agentInfo?.version; @@ -289,7 +293,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func ? "Antigravity ACP runtime is not installed or its executable could not be found." : failure ? "Antigravity could not complete its local health check." - : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; + : `Antigravity did not respond to its local health check within ${timeoutMs / 1_000} seconds.`; const supportsTextGeneration = initialized !== undefined ? yield* options.supportsTextGeneration : false; const updatedAt = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5ea745622bb34..a308496753238 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { type ClaudeSettings, type ModelCapabilities, @@ -406,7 +407,9 @@ const probeClaudeCapabilities = ( catch: (cause) => new ClaudeCapabilitiesProbeSdkError({ operation: "initialize", cause }), }); }).pipe( - Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.timeout( + resolveProviderProbeTimeoutMs("claude", CAPABILITIES_PROBE_TIMEOUT_MS, environment), + ), Effect.flatMap(({ q, init, graceMs }) => Effect.gen(function* () { // Usage has its own deadline so a slow optional request cannot discard initialization. @@ -554,7 +557,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ["--version"], resolvedEnvironment, cwd, - ).pipe(Effect.timeoutOption(CLAUDE_VERSION_CHECK_TIMEOUT_MS), Effect.result); + ).pipe( + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("claude", CLAUDE_VERSION_CHECK_TIMEOUT_MS, resolvedEnvironment), + ), + Effect.result, + ); if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; @@ -655,7 +663,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "unknown" }, message: probeFailure ? Cause.isTimeoutError(probeFailure) - ? `Timed out after ${CAPABILITIES_PROBE_TIMEOUT_MS / 1_000}s while checking Claude account status.` + ? `Timed out after ${resolveProviderProbeTimeoutMs("claude", CAPABILITIES_PROBE_TIMEOUT_MS, resolvedEnvironment) / 1_000}s while checking Claude account status.` : "Claude Agent CLI failed while checking account status." : "Could not verify Claude authentication status from initialization result.", }, diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 74d70b92efc88..5629126f4c62d 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { pipe } from "effect/Function"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -78,13 +79,6 @@ const CODEX_CLIENT_NAME = "T3 Code"; const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000; const CODEX_DISCOVERY_MARGIN_MS = 250; -const resolveCodexProbeTimeoutMs = (environment: NodeJS.ProcessEnv): number => { - const timeoutMs = Number(environment.T3CODE_CODEX_PROBE_TIMEOUT_MS); - // Node timers overflow above a signed 32-bit delay and can fire immediately. - return Number.isInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 2_147_483_647 - ? timeoutMs - : CODEX_AUTH_PROBE_TIMEOUT_MS; -}; // `codex-cli 0.159.0`, `codex-cli 0.160.0-alpha.1` or the npm CLI's bare `0.1.4161006`. // Keep a prerelease suffix so compatibility stays unknown for it, as it does for `initialize`. const CODEX_CLI_VERSION_PATTERN = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$)/u; @@ -645,7 +639,12 @@ const probeCodexCliVersion = Effect.fn("probeCodexCliVersion")( if (result.code !== 0) return null; return CODEX_CLI_VERSION_PATTERN.exec(result.stdout)?.[1] ?? null; }, - Effect.timeoutOption(Duration.millis(DEFAULT_TIMEOUT_MS)), + (effect, input) => + effect.pipe( + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("codex", DEFAULT_TIMEOUT_MS, input.environment), + ), + ), Effect.map(Option.getOrNull), Effect.catch((cause) => Effect.logDebug("Codex --version fallback failed.", { cause }).pipe(Effect.as(null)), @@ -784,7 +783,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); } - const probeTimeoutMs = resolveCodexProbeTimeoutMs(resolvedEnvironment); + const probeTimeoutMs = resolveProviderProbeTimeoutMs( + "codex", + CODEX_AUTH_PROBE_TIMEOUT_MS, + resolvedEnvironment, + ); const probeResult = yield* probe({ probeTimeoutMs, binaryPath: codexSettings.binaryPath, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 7bb1f25b67ce7..c70dd35036e2b 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -434,6 +434,7 @@ export const AntigravityDriver: ProviderDriver< Effect.provideService(HttpClient.HttpClient, httpClient), ); const provider = yield* makeAntigravityProvider(settings, { + environment: processEnvironment, stampIdentity: classifyModels, probe, probeUsage: usage.probe, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index dee8a04aee9fa..12b66cf4782aa 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -297,7 +298,7 @@ export const CodexDriver: ProviderDriver< environment: processEnv, }).pipe( Effect.scoped, - Effect.timeout("20 seconds"), + Effect.timeout(resolveProviderProbeTimeoutMs("codex", 20_000, processEnv)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ]).pipe( diff --git a/docs/user/install.md b/docs/user/install.md index 48d96a4fef611..cc7b6817d5f10 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -232,6 +232,47 @@ For provider-specific setup and accounts, see [Codex](./providers-codex.md), [Antigravity](./providers-antigravity.md), [Kiro](./providers-kiro.md), [Pi](./providers-pi.md), [Muse Code](./providers-muse.md), and [fx](./providers-fx.md). +### Provider check timeouts + +If a provider works in its CLI but T3 Code reports a timeout while checking it, +allow more time in the T3 server's environment. For example, give Codex and +Claude readiness checks 60 seconds: + +```bash +T3CODE_CODEX_PROBE_TIMEOUT_MS=60000 T3CODE_CLAUDE_PROBE_TIMEOUT_MS=60000 t3 serve +``` + +For a remote environment, set these on the machine running the T3 server. +Keep them in your launcher or service configuration to retain them across +updates, then restart the server. For systemd, add +`Environment=T3CODE_CODEX_PROBE_TIMEOUT_MS=60000` under `[Service]` in a service +override, reload systemd, and restart the service. Provider instances can also +set their own overrides in Settings → Providers. + +Each variable replaces that provider's existing readiness-probe deadlines. +Providers with several phases can take longer than one deadline in total. +Unset or invalid values keep each phase's current default. Values must be +positive whole milliseconds no greater than 2147483647. + +| Provider | Environment variable | +| ------------------- | -------------------------------------- | +| Codex | `T3CODE_CODEX_PROBE_TIMEOUT_MS` | +| Claude | `T3CODE_CLAUDE_PROBE_TIMEOUT_MS` | +| Cursor | `T3CODE_CURSOR_PROBE_TIMEOUT_MS` | +| Antigravity | `T3CODE_ANTIGRAVITY_PROBE_TIMEOUT_MS` | +| Grok | `T3CODE_GROK_PROBE_TIMEOUT_MS` | +| Kiro | `T3CODE_KIRO_PROBE_TIMEOUT_MS` | +| fx | `T3CODE_FX_PROBE_TIMEOUT_MS` | +| Pi | `T3CODE_PI_PROBE_TIMEOUT_MS` | +| Muse Code | `T3CODE_MUSE_PROBE_TIMEOUT_MS` | +| OpenCode | `T3CODE_OPENCODE_PROBE_TIMEOUT_MS` | +| ACP Registry agents | `T3CODE_ACP_REGISTRY_PROBE_TIMEOUT_MS` | + +Longer deadlines also take longer to report a stalled check. These overrides +cover readiness and discovery, including OpenCode server startup. Usage and +config enrichment retain their own shorter limits. Turn execution, package +installation, update checks, and shutdown limits keep their existing behavior. + ## Next steps - [Working with threads](./thread-sidebar.md): start tasks and organize parallel work. diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 8828dc98c0e90..ab36b35fffef3 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -36,23 +36,9 @@ picker. See [Use multiple accounts](#use-multiple-accounts). ## Allow more time for provider checks If an authenticated Codex installation reports “Timed out while checking Codex -app-server provider status,” set `T3CODE_CODEX_PROBE_TIMEOUT_MS` in the T3 server's -environment and restart the server. For example, allow 60 seconds: - -```bash -T3CODE_CODEX_PROBE_TIMEOUT_MS=60000 t3 serve -``` - -For a remote environment, set this on the remote machine that runs T3 Code. -Keep it in your launcher or service configuration so it survives application -updates. For systemd, add `Environment=T3CODE_CODEX_PROBE_TIMEOUT_MS=60000` under -`[Service]` in a service override, reload systemd, and restart the service. - -The default is 20000 milliseconds. Values must be positive whole milliseconds -no greater than 2147483647; invalid values use the default. This controls the -overall Codex provider status check, including its skills and model discovery -budget. Usage and config requests keep their shorter individual limits. Longer -budgets also take longer to report a stalled probe. +app-server provider status,” set `T3CODE_CODEX_PROBE_TIMEOUT_MS=60000` in the +T3 server's environment and restart it. See [Provider check timeouts](./install.md#provider-check-timeouts) +for configuration and other providers. ## Use multiple accounts diff --git a/packages/provider-acp-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index 58b16e337079e..27a772bd3dc63 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { AcpRegistryListProvidersResult, AcpRegistryOperationError, @@ -43,8 +44,7 @@ const MAX_NAME_LENGTH = 160; const MAX_DESCRIPTION_LENGTH = 1_024; const MAX_COMMANDS = 128; const MAX_COMMAND_LINE_LENGTH = 2_048; -const PROBE_TIMEOUT_SECONDS = 60; -const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`; +const PROBE_TIMEOUT_MS = 60_000; const COMMAND_ADVERTISEMENT_GRACE = "500 millis"; const boundedText = (value: string, maximumLength: number): string => @@ -325,6 +325,11 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo | SecureRandom.SecureRandom > { const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; + const probeTimeoutMs = resolveProviderProbeTimeoutMs( + "acp_registry", + PROBE_TIMEOUT_MS, + input.environment, + ); const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const random = yield* SecureRandom.SecureRandom; const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter); @@ -430,12 +435,12 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo }).pipe( Effect.scoped, Effect.timeoutOrElse({ - duration: PROBE_TIMEOUT, + duration: probeTimeoutMs, orElse: () => Effect.fail( new AcpRegistryOperationError({ reason: "probe_failed", - message: `The ACP agent did not resolve and create a test session within ${PROBE_TIMEOUT_SECONDS} seconds. Package installation or agent startup may be slow; this check retries on the next provider refresh.`, + message: `The ACP agent did not resolve and create a test session within ${probeTimeoutMs / 1_000} seconds. Package installation or agent startup may be slow; this check retries on the next provider refresh.`, }), ), }), diff --git a/packages/provider-core/src/server/snapshotProbe.test.ts b/packages/provider-core/src/server/snapshotProbe.test.ts index 58d52e1002c6e..8a82990ba582c 100644 --- a/packages/provider-core/src/server/snapshotProbe.test.ts +++ b/packages/provider-core/src/server/snapshotProbe.test.ts @@ -10,6 +10,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { isCommandMissingCause, + resolveProviderProbeTimeoutMs, parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, @@ -158,3 +159,37 @@ describe("ProviderCommandNotFoundError", () => { }); }); }); + +describe("resolveProviderProbeTimeoutMs", () => { + it.each([ + "codex", + "claude", + "cursor", + "antigravity", + "grok", + "kiro", + "fx", + "pi", + "muse", + "opencode", + "acp_registry", + ])("uses only %s's readiness override", (provider) => { + const key = `T3CODE_${provider.toUpperCase()}_PROBE_TIMEOUT_MS`; + expect(resolveProviderProbeTimeoutMs(provider, 25000, { [key]: "60000" })).toBe(60000); + expect(resolveProviderProbeTimeoutMs(provider, 25000, { [key]: "1000" })).toBe(1000); + expect( + resolveProviderProbeTimeoutMs(provider, 25000, { T3CODE_OTHER_PROBE_TIMEOUT_MS: "60000" }), + ).toBe(25000); + }); + it.each([undefined, "", " ", "bad", "0", "-1", "1.5", "Infinity", "2147483648"])( + "retains the phase default for invalid value %s", + (value) => { + expect( + resolveProviderProbeTimeoutMs("claude", 25000, { T3CODE_CLAUDE_PROBE_TIMEOUT_MS: value }), + ).toBe(25000); + expect( + resolveProviderProbeTimeoutMs("claude", 20000, { T3CODE_CLAUDE_PROBE_TIMEOUT_MS: value }), + ).toBe(20000); + }, + ); +}); diff --git a/packages/provider-core/src/server/snapshotProbe.ts b/packages/provider-core/src/server/snapshotProbe.ts index 3303b9d6ff51a..c031aea48e4af 100644 --- a/packages/provider-core/src/server/snapshotProbe.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -45,6 +45,18 @@ export const isWindowsCommandNotFound = Effect.fn("isWindowsCommandNotFound")(fu export const DEFAULT_TIMEOUT_MS = 4_000; export const AUTH_PROBE_TIMEOUT_MS = 10_000; +export const resolveProviderProbeTimeoutMs = ( + provider: string, + defaultTimeoutMs: number, + environment: NodeJS.ProcessEnv = process.env, +): number => { + const timeoutMs = Number(environment[`T3CODE_${provider.toUpperCase()}_PROBE_TIMEOUT_MS`]); + // Node timers overflow above a signed 32-bit delay and can fire immediately. + return Number.isInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 2_147_483_647 + ? timeoutMs + : defaultTimeoutMs; +}; + export const COMPACT_SLASH_COMMAND = { name: "compact", description: "Summarize the conversation and reduce context usage", diff --git a/packages/provider-cursor/src/server/status.test.ts b/packages/provider-cursor/src/server/status.test.ts index c3dc7deb120ff..4e9a92a010e67 100644 --- a/packages/provider-cursor/src/server/status.test.ts +++ b/packages/provider-cursor/src/server/status.test.ts @@ -1,3 +1,6 @@ +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import * as Deferred from "effect/Deferred"; import type { SDKModel } from "@cursor/sdk"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -195,6 +198,55 @@ describe("checkCursorProviderStatus", () => { }), ); + it.effect("allows slow SDK discovery with a configured readiness timeout", () => + Effect.gen(function* () { + const fiber = yield* checkCursorProviderStatus(baseCursorSettings, { + CURSOR_API_KEY: "test-key", + T3CODE_CURSOR_PROBE_TIMEOUT_MS: "30000", + }).pipe( + Effect.provide( + CursorSdkCatalog.layerTest(() => + Effect.sleep("20 seconds").pipe( + Effect.as({ + user: { + apiKeyName: "test", + userEmail: "cursor@example.com", + createdAt: "2026-01-01", + }, + models: [sdkParameterizedModel], + }), + ), + ), + ), + Effect.forkChild, + ); + yield* TestClock.adjust("20 seconds"); + expect((yield* Fiber.join(fiber)).status).toBe("ready"); + }), + ); + + it.effect("cancels stalled SDK discovery at the configured readiness timeout", () => + Effect.gen(function* () { + const released = yield* Deferred.make(); + const fiber = yield* checkCursorProviderStatus(baseCursorSettings, { + CURSOR_API_KEY: "test-key", + T3CODE_CURSOR_PROBE_TIMEOUT_MS: "1000", + }).pipe( + Effect.provide( + CursorSdkCatalog.layerTest(() => + Effect.never.pipe(Effect.ensuring(Deferred.succeed(released, undefined))), + ), + ), + Effect.forkChild, + ); + yield* TestClock.adjust("1 second"); + expect(yield* Deferred.isDone(released)).toBe(true); + const status = yield* Fiber.join(fiber); + expect(status.status).toBe("error"); + expect(status.message).toContain("1000ms"); + }), + ); + it.effect("surfaces SDK authentication failures", () => Effect.gen(function* () { const provider = yield* checkCursorProviderStatus(baseCursorSettings, { diff --git a/packages/provider-cursor/src/server/status.ts b/packages/provider-cursor/src/server/status.ts index ffeca7a1da0b7..75947fd71088b 100644 --- a/packages/provider-cursor/src/server/status.ts +++ b/packages/provider-cursor/src/server/status.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import type { SDKModel, SDKUser } from "@cursor/sdk"; import type { ModelCapabilities, @@ -286,9 +287,14 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; + const timeoutMs = resolveProviderProbeTimeoutMs( + "cursor", + CURSOR_SDK_CATALOG_TIMEOUT_MS, + environment, + ); const catalogResult = yield* sdkCatalog .read(sdkApiKey) - .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); + .pipe(Effect.timeoutOption(timeoutMs), Effect.result); if (Result.isFailure(catalogResult)) { yield* Effect.logWarning("Cursor SDK catalog probe failed", { @@ -333,7 +339,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( version: null, status: "error", auth: { status: "unknown" }, - message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, + message: `Cursor SDK catalog request timed out after ${timeoutMs}ms.`, }, }); } diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts index c71e909197df2..b1267ccda38d1 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { type ServerProviderAuth, type ServerProviderModel } from "@t3tools/contracts"; import type { FxSettings } from "../settings.ts"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -164,7 +165,9 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function } const versionResult = yield* runFxCli(settings, ["--version"], environment).pipe( - Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("fx", VERSION_PROBE_TIMEOUT_MS, environment), + ), Effect.result, ); if (Result.isFailure(versionResult) || Option.isNone(versionResult.success)) { @@ -191,7 +194,7 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function const read = (args: ReadonlyArray) => runFxCli(settings, args, environment).pipe( - Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.timeoutOption(resolveProviderProbeTimeoutMs("fx", AUTH_PROBE_TIMEOUT_MS, environment)), Effect.map(Option.filter((output) => output.code === 0)), Effect.map(Option.map((output) => output.stdout)), Effect.orElseSucceed(() => Option.none()), diff --git a/packages/provider-grok/src/server/skills.ts b/packages/provider-grok/src/server/skills.ts index f06eaa140ba21..a87545c09b905 100644 --- a/packages/provider-grok/src/server/skills.ts +++ b/packages/provider-grok/src/server/skills.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; // The CLI catalog honors ignored/disabled skills and nested plugin skills that flat scans miss. import type { ServerProviderSkill } from "@t3tools/contracts"; import type { GrokSettings } from "../settings.ts"; @@ -112,7 +113,9 @@ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( cause, }), ), - Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("grok", GROK_SKILLS_PROBE_TIMEOUT_MS, environment), + ), ); if (Option.isNone(inspectResult)) { diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index aa58c8babae7d..686fd42873466 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { type CustomModelSetting, type ModelCapabilities, @@ -313,7 +314,7 @@ const probeGrokModels = Effect.fn("GrokProviderStatus.probeGrokModels")(function environment: NodeJS.ProcessEnv, ) { const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe( - Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.timeoutOption(resolveProviderProbeTimeoutMs("grok", AUTH_PROBE_TIMEOUT_MS, environment)), Effect.result, ); // Only a clean exit is parsed. Failed invocations print help or error text that @@ -429,7 +430,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func grokSettings, ["--version"], probeEnvironment, - ).pipe(Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result); + ).pipe( + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("grok", VERSION_PROBE_TIMEOUT_MS, environment), + ), + Effect.result, + ); if (Result.isFailure(versionResult)) { const error = versionResult.failure; @@ -517,7 +523,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ); const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, probeEnvironment).pipe( - Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("grok", GROK_ACP_INITIALIZE_TIMEOUT_MS, environment), + ), Effect.exit, ); const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined; diff --git a/packages/provider-kiro/src/server/status.ts b/packages/provider-kiro/src/server/status.ts index 83de43df93a8e..9d1c8d9086972 100644 --- a/packages/provider-kiro/src/server/status.ts +++ b/packages/provider-kiro/src/server/status.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { type ModelCapabilities, ProviderDriverKind, @@ -374,7 +375,9 @@ export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(func } const versionResult = yield* runKiroCli(settings, ["--version"], environment).pipe( - Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("kiro", VERSION_PROBE_TIMEOUT_MS, environment), + ), Effect.result, ); if (Result.isFailure(versionResult) || Option.isNone(versionResult.success)) { @@ -405,7 +408,10 @@ export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(func settings, ["whoami", "--format", "json"], environment, - ).pipe(Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.result); + ).pipe( + Effect.timeoutOption(resolveProviderProbeTimeoutMs("kiro", AUTH_PROBE_TIMEOUT_MS, environment)), + Effect.result, + ); const auth = kiroAuthFromWhoami( Result.isSuccess(whoamiResult) ? Option.getOrUndefined(whoamiResult.success) : undefined, environment, @@ -427,12 +433,16 @@ export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(func ? yield* Effect.all( [ runKiroCli(settings, ["chat", "--list-models", "--format", "json"], environment).pipe( - Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("kiro", AUTH_PROBE_TIMEOUT_MS, environment), + ), Effect.map((output) => kiroModelsFromList(Option.getOrUndefined(output))), Effect.orElseSucceed(() => undefined), ), probeKiroAgents(settings, environment).pipe( - Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("kiro", AUTH_PROBE_TIMEOUT_MS, environment), + ), Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), ), diff --git a/packages/provider-muse/src/server/modelCatalog.ts b/packages/provider-muse/src/server/modelCatalog.ts index b8a62297ddf59..d7a8f0743a99b 100644 --- a/packages/provider-muse/src/server/modelCatalog.ts +++ b/packages/provider-muse/src/server/modelCatalog.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import type { SendUserTurnOptions } from "@muse-code/sdk"; import { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; import { MuseSettings } from "../settings.ts"; @@ -111,7 +112,7 @@ export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* ( ...(environment ? { environment } : {}), ...(cwd ? { cwd } : {}), readOnly: true, - startupTimeoutMs: 8_000, + startupTimeoutMs: resolveProviderProbeTimeoutMs("muse", 8_000, environment), }, createHost, ), diff --git a/packages/provider-muse/src/server/status.ts b/packages/provider-muse/src/server/status.ts index bcd910b3573b7..c20cc5a73134e 100644 --- a/packages/provider-muse/src/server/status.ts +++ b/packages/provider-muse/src/server/status.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { ServerProviderModel } from "@t3tools/contracts"; import { MuseSettings } from "../settings.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -81,7 +82,12 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func ...(cwd ? { cwd } : {}), }), ); - }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + }).pipe( + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("muse", DEFAULT_TIMEOUT_MS, museEnvironment), + ), + Effect.result, + ); if (Result.isFailure(versionResult)) { const missing = isCommandMissingCause(versionResult.failure); @@ -118,7 +124,7 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func const catalog = yield* discoverMuseModels(settings, museEnvironment, cwd, createHost).pipe( Effect.scoped, - Effect.timeoutOption(12_000), + Effect.timeoutOption(resolveProviderProbeTimeoutMs("muse", 12_000, museEnvironment)), Effect.result, ); if (Result.isFailure(catalog) || Option.isNone(catalog.success)) { diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index fffda5ff823b2..efeb76d61c469 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision } from "@t3tools/contracts"; @@ -42,7 +43,7 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; export const MINIMUM_OPENCODE_VERSION = "1.14.19"; -const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; +const OPENCODE_HEALTH_TIMEOUT_MS = 5_000; const OpenCodeHealthSchema = Schema.Struct({ healthy: Schema.Literal(true), @@ -162,10 +163,15 @@ export const runOpenCodeSdk = ( export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( client: OpencodeClient, + environment?: NodeJS.ProcessEnv, ) { const healthOption = yield* runOpenCodeSdk("global.health", (signal) => client.global.health({ signal }), - ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); + ).pipe( + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("opencode", OPENCODE_HEALTH_TIMEOUT_MS, environment), + ), + ); if (Option.isNone(healthOption)) { return yield* new OpenCodeRuntimeError({ operation: "global.health", @@ -509,7 +515,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }), ), )); - const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const timeoutMs = + input.timeoutMs ?? + resolveProviderProbeTimeoutMs( + "opencode", + DEFAULT_OPENCODE_SERVER_TIMEOUT_MS, + input.environment, + ); const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; const launch = yield* agentScope.wrap({ command: input.binaryPath, @@ -688,6 +700,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { directory: input.directory, ...(serverPassword !== undefined ? { serverPassword } : {}), }), + input.environment, ) ); @@ -740,6 +753,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { directory: input.directory, ...(serverPassword !== undefined ? { serverPassword } : {}), }), + input.environment, ).pipe( Effect.map((version) => ({ url: serverUrl, diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 0da1a04ff1fd7..762b59a3d14a8 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { ProviderDriverKind, settingsSecretLiteral } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; import * as Digest from "effect/crypto/Digest"; @@ -303,6 +304,7 @@ export const OpenCodeDriver: ProviderDriver< }); const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( openCode2Server.withConnection(({ client }) => loadOpenCode2Catalog(client, host.paths.cwd)), + processEnv, ); const listOpenCode2Workspace = (cwd: string) => openCode2Server.withConnection(({ client, events }) => @@ -334,6 +336,7 @@ export const OpenCodeDriver: ProviderDriver< { concurrency: "unbounded" }, ), Deferred.await(scanned), + processEnv, ); }).pipe(Effect.scoped), ); @@ -394,7 +397,7 @@ export const OpenCodeDriver: ProviderDriver< { skills: openCodeRuntime.loadOpenCodeSkills(client), commandInventory: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( - Effect.timeoutOption("10 seconds"), + Effect.timeoutOption(resolveProviderProbeTimeoutMs("opencode", 10_000, processEnv)), Effect.map((commands) => ({ commands: Option.getOrElse(commands, () => []), slashCommandsPending: Option.isNone(commands), @@ -504,7 +507,7 @@ export const OpenCodeDriver: ProviderDriver< v2: Effect.all([ snapshot.getSnapshot, listOpenCode2Workspace(cwd).pipe( - Effect.timeout("20 seconds"), + Effect.timeout(resolveProviderProbeTimeoutMs("opencode", 20_000, processEnv)), Effect.mapError( (cause) => new ProviderDriverError({ @@ -527,7 +530,9 @@ export const OpenCodeDriver: ProviderDriver< ), v1: Effect.all([ snapshot.getSnapshot, - loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), + loadWorkspaceForCwd(cwd).pipe( + Effect.timeout(resolveProviderProbeTimeoutMs("opencode", 20_000, processEnv)), + ), ]).pipe( Effect.map(([machineSnapshot, { skills, commands, slashCommandsPending }]) => ({ ...machineSnapshot, diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 0afd0dde1633d..b3288f65f9ac1 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { type ModelCapabilities, type ServerProviderModel, @@ -486,6 +487,7 @@ const catalogFingerprint = (models: ReadonlyArray) => /** A cold server can list a partial catalog; two agreeing reads reduce that startup race. */ export const makeOpenCode2ModelLoader = ( list: Effect.Effect, E>, + environment?: NodeJS.ProcessEnv, ) => Effect.sync(() => { let lastLoaded: ReadonlyArray = []; @@ -504,7 +506,7 @@ export const makeOpenCode2ModelLoader = ( schedule: Schedule.spaced("250 millis"), }), Effect.catch((error) => (latest.length > 0 ? Effect.succeed(latest) : Effect.fail(error))), - Effect.timeoutOption("5 seconds"), + Effect.timeoutOption(resolveProviderProbeTimeoutMs("opencode", 5_000, environment)), Effect.map((loaded) => { // Fresh progress may still be partial, but a warm cache must not freeze slow catalogs. if (loaded._tag === "Some") lastLoaded = loaded.value; @@ -535,10 +537,16 @@ export interface OpenCode2Workspace { * the read is what starts the scan. */ export const loadOpenCode2Workspace = Effect.fn("OpencodeProviderStatus.loadOpenCode2Workspace")( - function* (read: Effect.Effect, scanned: Effect.Effect) { + function* ( + read: Effect.Effect, + scanned: Effect.Effect, + environment?: NodeJS.ProcessEnv, + ) { const first = yield* read; if (first.commands.length > 0) return first; - yield* scanned.pipe(Effect.timeoutOption("10 seconds")); + yield* scanned.pipe( + Effect.timeoutOption(resolveProviderProbeTimeoutMs("opencode", 10_000, environment)), + ); return yield* read; }, ); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts index 57ba94d3231aa..2da8804d4ce07 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; // One OpenCode 2 process serves all directories; share it across instance threads. import type { OpenCodeClient } from "@opencode/client/effect"; import * as Context from "effect/Context"; @@ -12,7 +13,7 @@ import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import * as OpenCode2Client from "./OpenCode2Client.ts"; -const INFO_TIMEOUT = "5 seconds"; +const INFO_TIMEOUT_MS = 5_000; export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api { readonly url: string; @@ -75,10 +76,10 @@ const describeInfoFailure = (cause: unknown) => { }; /** Only server info proves readiness; keep credential-bearing failure details out of client text. */ -export const verifyServer = (client: OpenCodeClient) => +export const verifyServer = (client: OpenCodeClient, environment?: NodeJS.ProcessEnv) => client.server.info().pipe( Effect.timeoutOrElse({ - duration: INFO_TIMEOUT, + duration: resolveProviderProbeTimeoutMs("opencode", INFO_TIMEOUT_MS, environment), orElse: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ @@ -123,7 +124,7 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: { external: boolean, ) { const api = yield* opencode.connect({ baseUrl: url, password }); - const version = yield* verifyServer(api.client); + const version = yield* verifyServer(api.client, input.environment); return { ...api, url, version, external } satisfies OpenCode2Connection; }); let latest: OpenCode2Connection | undefined; diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts index ab40142de845a..86995897de3f9 100644 --- a/packages/provider-opencode/src/server/versionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { parseSemver } from "@t3tools/shared/semver"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; @@ -17,8 +18,8 @@ export interface ProbedOpenCode { } // Meant to stop a hung `--version`; wrapped installs can take several seconds to start cold. -const OPENCODE_VERSION_PROBE_TIMEOUT = "10 seconds"; -const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; +const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 10_000; +const OPENCODE_SERVER_PROBE_TIMEOUT_MS = 5_000; // 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out. const decodeApiInfo = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })), @@ -68,12 +69,16 @@ const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( }) .pipe( Effect.timeoutOrElse({ - duration: OPENCODE_VERSION_PROBE_TIMEOUT, + duration: resolveProviderProbeTimeoutMs( + "opencode", + OPENCODE_VERSION_PROBE_TIMEOUT_MS, + environment, + ), orElse: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeBinary", - detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + detail: `OpenCode CLI version probe timed out after ${resolveProviderProbeTimeoutMs("opencode", OPENCODE_VERSION_PROBE_TIMEOUT_MS, environment) / 1_000} seconds.`, }), ), }), @@ -90,6 +95,7 @@ const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( serverUrl: string, serverPassword: string, + environment?: NodeJS.ProcessEnv, ) { const client = yield* HttpClient.HttpClient; const baseUrl = URL.parse(serverUrl.trim()); @@ -132,7 +138,11 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( }), ), Effect.timeoutOrElse({ - duration: OPENCODE_SERVER_PROBE_TIMEOUT, + duration: resolveProviderProbeTimeoutMs( + "opencode", + OPENCODE_SERVER_PROBE_TIMEOUT_MS, + environment, + ), orElse: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ @@ -169,7 +179,7 @@ export const probeOpenCodeRuntime = ( HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime > => settings.serverUrl.trim().length > 0 - ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) + ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword, environment) : probeOpenCodeBinary(settings.binaryPath, environment); // Only 2.x is kept: 1.x can be upgraded in place while T3 runs, and 2.x converts the shared diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index e70bd1ad804ec..61ee49ebab3c6 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -1,3 +1,4 @@ +import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { type CustomModelSetting, type ServerProvider, @@ -170,10 +171,19 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function cwd?: string, ) { const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); - const stateData = yield* connection.request({ type: "get_state" }); - const modelsData = yield* connection.request({ type: "get_available_models" }); + const stateData = yield* connection.request( + { type: "get_state" }, + resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), + ); + const modelsData = yield* connection.request( + { type: "get_available_models" }, + resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), + ); const commandsData = yield* connection - .request({ type: "get_commands" }) + .request( + { type: "get_commands" }, + resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), + ) .pipe(Effect.orElseSucceed(() => undefined)); const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); @@ -209,18 +219,31 @@ export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( cwd, ); // A failed read must not replace a previously usable workspace catalog with an empty one. - const commandsData = yield* connection.request({ type: "get_commands" }); + const commandsData = yield* connection.request( + { type: "get_commands" }, + resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), + ); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); return { slashCommands: withPiBuiltinSlashCommands(slashCommands), skills }; }, Effect.scoped, - Effect.timeoutOrElse({ - duration: PI_RPC_DISCOVERY_TIMEOUT_MS, - orElse: () => - Effect.fail( - new PiRpcTimeoutError({ operation: "discovery", timeoutMs: PI_RPC_DISCOVERY_TIMEOUT_MS }), - ), - }), + (effect, _settings, environment) => + effect.pipe( + Effect.timeoutOrElse({ + duration: resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), + orElse: () => + Effect.fail( + new PiRpcTimeoutError({ + operation: "discovery", + timeoutMs: resolveProviderProbeTimeoutMs( + "pi", + PI_RPC_DISCOVERY_TIMEOUT_MS, + environment, + ), + }), + ), + }), + ), ); const runPiVersionCommand = Effect.fn("PiProviderStatus.runPiVersionCommand")(function* ( @@ -277,7 +300,7 @@ const describeUnavailablePiModel = Effect.fn("PiProviderStatus.describeUnavailab }), ), ), - Effect.timeout(DEFAULT_TIMEOUT_MS), + Effect.timeout(resolveProviderProbeTimeoutMs("pi", DEFAULT_TIMEOUT_MS, environment)), Effect.option, ); const missingCredentials = Option.exists( @@ -350,7 +373,9 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function } const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( - Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("pi", VERSION_PROBE_TIMEOUT_MS, environment), + ), Effect.result, ); @@ -462,7 +487,12 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function environment, resolvedLaunchArgs.args, cwd, - ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); + ).pipe( + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), + ), + Effect.exit, + ); if (Exit.isFailure(discoveryExit)) { yield* Effect.logWarning("Pi RPC discovery failed.", { errorTag: causeErrorTag(discoveryExit.cause), From 1190b5937c1a543eeb4a18aefac48ce35f0c2272 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 12:10:04 +0000 Subject: [PATCH 3/6] refactor(provider): resolve Pi and OpenCode probe timeouts once per call --- .../src/server/versionProbe.ts | 13 ++++--- packages/provider-pi/src/server/status.ts | 38 ++++++------------- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts index 891b13ea57c50..ba08b754688e5 100644 --- a/packages/provider-opencode/src/server/versionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -61,6 +61,11 @@ const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( environment: NodeJS.ProcessEnv | undefined, ) { const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const timeoutMs = resolveProviderProbeTimeoutMs( + "opencode", + OPENCODE_VERSION_PROBE_TIMEOUT_MS, + environment, + ); const { stdout } = yield* runtime .runOpenCodeCommand({ binaryPath, @@ -69,16 +74,12 @@ const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( }) .pipe( Effect.timeoutOrElse({ - duration: resolveProviderProbeTimeoutMs( - "opencode", - OPENCODE_VERSION_PROBE_TIMEOUT_MS, - environment, - ), + duration: timeoutMs, orElse: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeBinary", - detail: `OpenCode CLI version probe timed out after ${resolveProviderProbeTimeoutMs("opencode", OPENCODE_VERSION_PROBE_TIMEOUT_MS, environment) / 1_000} seconds.`, + detail: `OpenCode CLI version probe timed out after ${timeoutMs / 1_000} seconds.`, }), ), }), diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index b55c7118ea088..ef2b6e2f676c1 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -171,20 +171,12 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function launchArgs: ReadonlyArray, cwd?: string, ) { + const timeoutMs = resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment); const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); - const stateData = yield* connection.request( - { type: "get_state" }, - resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), - ); - const modelsData = yield* connection.request( - { type: "get_available_models" }, - resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), - ); + const stateData = yield* connection.request({ type: "get_state" }, timeoutMs); + const modelsData = yield* connection.request({ type: "get_available_models" }, timeoutMs); const commandsData = yield* connection - .request( - { type: "get_commands" }, - resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), - ) + .request({ type: "get_commands" }, timeoutMs) .pipe(Effect.orElseSucceed(() => undefined)); const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); @@ -228,23 +220,15 @@ export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( return { slashCommands: withPiBuiltinSlashCommands(slashCommands), skills }; }, Effect.scoped, - (effect, _settings, environment) => - effect.pipe( + (effect, _settings, environment) => { + const timeoutMs = resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment); + return effect.pipe( Effect.timeoutOrElse({ - duration: resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment), - orElse: () => - Effect.fail( - new PiRpcTimeoutError({ - operation: "discovery", - timeoutMs: resolveProviderProbeTimeoutMs( - "pi", - PI_RPC_DISCOVERY_TIMEOUT_MS, - environment, - ), - }), - ), + duration: timeoutMs, + orElse: () => Effect.fail(new PiRpcTimeoutError({ operation: "discovery", timeoutMs })), }), - ), + ); + }, ); const runPiVersionCommand = Effect.fn("PiProviderStatus.runPiVersionCommand")(function* ( From 700ec55b952bfdcd33b610291c41ba842b2d9e64 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 12:29:17 +0000 Subject: [PATCH 4/6] fix(provider): OpenCode external server status check uses the instance probe timeout --- packages/provider-opencode/src/server/driver.ts | 1 + packages/provider-opencode/src/server/status.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 6c5e12eb64f9d..968628996ee3b 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -391,6 +391,7 @@ export const OpenCodeDriver: ProviderDriver< host.paths.cwd, runtimeProbe.refresh, loadOpenCode2Models, + processEnv, ).pipe( Effect.annotateLogs({ providerInstanceId: instanceId }), Effect.annotateSpans({ providerInstanceId: instanceId }), diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index b3288f65f9ac1..8eac90a5c022f 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -697,6 +697,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ReadonlyArray, OpenCodeRuntime.OpenCodeRuntimeError >, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, never, @@ -813,6 +814,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ...(settingsSecretLiteral(openCodeSettings.serverPassword) ? { serverPassword: settingsSecretLiteral(openCodeSettings.serverPassword) } : {}), + ...(environment !== undefined ? { environment } : {}), }) .pipe(Effect.flatMap(loadInventory), Effect.scoped) : serverOwner.withServer(loadInventory); From bf76606dfe630da3730478d55886a81dc67a09bb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 12:47:44 +0000 Subject: [PATCH 5/6] fix(provider): OpenCode workspace scan keeps time for its fallback read under an override --- packages/provider-opencode/src/server/driver.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 968628996ee3b..02b2d586ef81c 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -539,7 +539,10 @@ export const OpenCodeDriver: ProviderDriver< v2: Effect.all([ snapshot.getSnapshot, listOpenCode2Workspace(cwd).pipe( - Effect.timeout(resolveProviderProbeTimeoutMs("opencode", 20_000, processEnv)), + // Leaves room for the reads around the scan wait, which uses the same override. + Effect.timeout( + 2 * resolveProviderProbeTimeoutMs("opencode", 10_000, processEnv), + ), Effect.mapError( (cause) => new ProviderDriverError({ From eee5f6f8cac19d9c90a45bc82b757c884fa70b55 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:54:30 +0000 Subject: [PATCH 6/6] fix(provider): probe timeout overrides only extend deadlines A shorter override cut nested phases the defaults were sized around: Codex discovery lost its window, ACP package installs were interrupted, and OpenCode session version selection could fall back to v1. Overrides now only extend deadlines, nested Muse and OpenCode v1 workspace limits keep room for their inner fallback, and directory catalog discovery uses the override. AI-assisted; verified with the tests and tools listed above. --- docs/user/install.md | 8 ++++---- packages/provider-core/src/server/snapshotProbe.test.ts | 2 +- packages/provider-core/src/server/snapshotProbe.ts | 5 +++-- packages/provider-muse/src/server/modelCatalog.ts | 5 ++++- packages/provider-muse/src/server/status.ts | 9 +++++++-- packages/provider-opencode/src/server/driver.ts | 6 +++++- packages/provider-opencode/src/server/status.ts | 5 ++++- 7 files changed, 28 insertions(+), 12 deletions(-) diff --git a/docs/user/install.md b/docs/user/install.md index 5d14e0a6eb6e6..b594894efe377 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -264,10 +264,10 @@ updates, then restart the server. For systemd, add override, reload systemd, and restart the service. Provider instances can also set their own overrides in Settings → Providers. -Each variable replaces that provider's existing readiness-probe deadlines. -Providers with several phases can take longer than one deadline in total. -Unset or invalid values keep each phase's current default. Values must be -positive whole milliseconds no greater than 2147483647. +Each variable extends that provider's readiness-probe deadlines; a phase whose +default is already longer keeps its default. Providers with several phases can +take longer than one deadline in total. Values must be whole milliseconds no +greater than 2147483647; unset or invalid values keep the defaults. | Provider | Environment variable | | ------------------- | -------------------------------------- | diff --git a/packages/provider-core/src/server/snapshotProbe.test.ts b/packages/provider-core/src/server/snapshotProbe.test.ts index 0d95d1ea5c466..e2d1e166c2031 100644 --- a/packages/provider-core/src/server/snapshotProbe.test.ts +++ b/packages/provider-core/src/server/snapshotProbe.test.ts @@ -178,7 +178,7 @@ describe("resolveProviderProbeTimeoutMs", () => { ])("uses only %s's readiness override", (provider) => { const key = `T3CODE_${provider.toUpperCase()}_PROBE_TIMEOUT_MS`; expect(resolveProviderProbeTimeoutMs(provider, 25000, { [key]: "60000" })).toBe(60000); - expect(resolveProviderProbeTimeoutMs(provider, 25000, { [key]: "1000" })).toBe(1000); + expect(resolveProviderProbeTimeoutMs(provider, 25000, { [key]: "1000" })).toBe(25000); expect( resolveProviderProbeTimeoutMs(provider, 25000, { T3CODE_OTHER_PROBE_TIMEOUT_MS: "60000" }), ).toBe(25000); diff --git a/packages/provider-core/src/server/snapshotProbe.ts b/packages/provider-core/src/server/snapshotProbe.ts index c1655da0c98d1..d86879091b6d8 100644 --- a/packages/provider-core/src/server/snapshotProbe.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -51,8 +51,9 @@ export const resolveProviderProbeTimeoutMs = ( environment: NodeJS.ProcessEnv = process.env, ): number => { const timeoutMs = Number(environment[`T3CODE_${provider.toUpperCase()}_PROBE_TIMEOUT_MS`]); - // Node timers overflow above a signed 32-bit delay and can fire immediately. - return Number.isInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 2_147_483_647 + // Overrides only extend: callers nest these deadlines and size optional work inside them + // against the defaults. Node timers overflow above a signed 32-bit delay. + return Number.isInteger(timeoutMs) && timeoutMs > defaultTimeoutMs && timeoutMs <= 2_147_483_647 ? timeoutMs : defaultTimeoutMs; }; diff --git a/packages/provider-muse/src/server/modelCatalog.ts b/packages/provider-muse/src/server/modelCatalog.ts index d7a8f0743a99b..a99deca5354db 100644 --- a/packages/provider-muse/src/server/modelCatalog.ts +++ b/packages/provider-muse/src/server/modelCatalog.ts @@ -12,6 +12,9 @@ import { type MuseSdkHost, } from "./sdk.ts"; +export const resolveMuseStartupTimeoutMs = (environment?: NodeJS.ProcessEnv) => + resolveProviderProbeTimeoutMs("muse", 8_000, environment); + type MuseReasoningEffort = NonNullable["reasoningEffort"]>; const REASONING_EFFORT_LABELS = { @@ -112,7 +115,7 @@ export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* ( ...(environment ? { environment } : {}), ...(cwd ? { cwd } : {}), readOnly: true, - startupTimeoutMs: resolveProviderProbeTimeoutMs("muse", 8_000, environment), + startupTimeoutMs: resolveMuseStartupTimeoutMs(environment), }, createHost, ), diff --git a/packages/provider-muse/src/server/status.ts b/packages/provider-muse/src/server/status.ts index d930099294ad3..344ae1d1a684e 100644 --- a/packages/provider-muse/src/server/status.ts +++ b/packages/provider-muse/src/server/status.ts @@ -10,7 +10,11 @@ import * as ChildProcess from "effect/process/ChildProcess"; import { createMuseSdkHost, makeMuseEnvironment } from "./sdk.ts"; import { parseMuseVersion } from "./maintenance.ts"; -import { discoverMuseModels, museModelCapabilities } from "./modelCatalog.ts"; +import { + discoverMuseModels, + museModelCapabilities, + resolveMuseStartupTimeoutMs, +} from "./modelCatalog.ts"; import { buildServerProvider, COMPACT_SLASH_COMMAND, @@ -130,7 +134,8 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func const catalog = yield* discoverMuseModels(settings, museEnvironment, cwd, createHost).pipe( Effect.scoped, - Effect.timeoutOption(resolveProviderProbeTimeoutMs("muse", 12_000, museEnvironment)), + // Startup shares the override, so the catalog keeps its 4 seconds on top of it. + Effect.timeoutOption(resolveMuseStartupTimeoutMs(museEnvironment) + 4_000), Effect.result, ); if (Result.isFailure(catalog) || Option.isNone(catalog.success)) { diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index c46886a68cad2..9b39fed2546b5 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -710,6 +710,7 @@ export const OpenCodeDriver: ProviderDriver< openCode2Server.withConnection(({ client }) => loadOpenCode2DirectoryCatalog(client, cwd), ), + processEnv, ).pipe( Effect.map( Option.map((catalog) => @@ -748,7 +749,10 @@ export const OpenCodeDriver: ProviderDriver< v1: Effect.all([ snapshot.getSnapshot, loadWorkspaceForCwd(cwd).pipe( - Effect.timeout(resolveProviderProbeTimeoutMs("opencode", 20_000, processEnv)), + // Leaves room for connection setup before the command fallback below. + Effect.timeout( + 2 * resolveProviderProbeTimeoutMs("opencode", 10_000, processEnv), + ), ), ]).pipe( Effect.map( diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 83a3b9e6578bb..0f90b9df28940 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -564,9 +564,12 @@ export const makeOpenCode2ModelLoader = ( /** None when the reads fail or never agree, so a partial catalog never passes for a settled one. */ export const readSettledOpenCode2Catalog = ( list: Effect.Effect, E>, + environment?: NodeJS.ProcessEnv, ) => settleOpenCode2Catalog(list).pipe( - Effect.timeoutOption(OPENCODE_2_CATALOG_SETTLE_TIMEOUT_MS), + Effect.timeoutOption( + resolveProviderProbeTimeoutMs("opencode", OPENCODE_2_CATALOG_SETTLE_TIMEOUT_MS, environment), + ), Effect.orElseSucceed(() => Option.none>()), );