From 3cf17bc5ba6667438c492649ab176c9de5b6f5e2 Mon Sep 17 00:00:00 2001 From: gyuriX Date: Wed, 4 Nov 2026 19:05:25 +0100 Subject: [PATCH 1/2] fix(server): make Codex probe timeouts configurable Add environment-wide server settings for the Codex app-server startup, skills, model catalog, config, rate-limit, and reset-credit timeouts. Probes read the settings per run, and the existing All environments scope applies one value across a T3 Connect network. Defaults are 60s for startup and skills, 3s for discovery, and 20s for reset credit. --- apps/server/src/provider/CodexProvider.ts | 49 ++-- .../src/provider/Drivers/CodexDriver.ts | 30 ++- .../src/provider/ProviderRegistry.test.ts | 19 ++ .../settings/CodexProbeTimeoutsSettings.tsx | 236 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 5 + .../src/components/settings/settingsSearch.ts | 9 + docs/user/providers-codex.md | 9 + packages/contracts/src/settings.ts | 48 ++++ packages/shared/src/serverSettings.test.ts | 14 ++ 9 files changed, 392 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/settings/CodexProbeTimeoutsSettings.tsx diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2b2d5501881b4..f1a85ef7490c7 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -18,6 +18,7 @@ import * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; import type { + CodexProbeTimeouts, CodexSettings, CustomModelSetting, ServerProvider, @@ -27,7 +28,11 @@ import type { ServerProviderModel, ServerProviderSkill, } from "@t3tools/contracts"; -import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; +import { + DEFAULT_CODEX_PROBE_TIMEOUTS, + PREFERRED_DEFAULT_CODEX_MODELS, + ServerSettingsError, +} from "@t3tools/contracts"; import { codexModelFamily, @@ -51,9 +56,9 @@ import { type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); -const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; type CodexRateLimitsProbe = | { @@ -68,14 +73,18 @@ type CodexRateLimitsProbe = | { readonly failure: string }; const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; -const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000; -const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000; const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const; 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; +/** Server settings are the source of truth; tests and non-server callers get the defaults. */ +export const readCodexProbeTimeouts = Effect.fn("readCodexProbeTimeouts")(function* () { + const host = yield* Effect.serviceOption(ProviderHost.ProviderHost); + if (Option.isNone(host)) return DEFAULT_CODEX_PROBE_TIMEOUTS; + const settings = yield* host.value.settings.get.pipe(Effect.orElseSucceed(() => undefined)); + return settings?.codexProbeTimeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS; +}); + const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, @@ -410,6 +419,7 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio readonly codexHome: string; readonly spawnedAtMs: number; readonly deadlineMs: number; + readonly catalogRefreshMs: number; }) { const models = yield* withCodexDiscoveryDeadline( requestAllCodexModels(input.client), @@ -424,7 +434,7 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio }), ), input.deadlineMs, - CODEX_CATALOG_REFRESH_TIMEOUT_MS, + input.catalogRefreshMs, ); if (refreshed === undefined) { yield* Effect.logWarning( @@ -515,7 +525,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; readonly skipNativeUsage?: boolean; + readonly timeouts?: CodexProbeTimeouts; }) { + const timeouts = input.timeouts ?? (yield* readCodexProbeTimeouts()); const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now); const { client, initialize } = yield* withCodexAppServerClient(input); @@ -533,7 +545,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 + timeouts.startup - CODEX_DISCOVERY_MARGIN_MS; const [skillsResponse, models, serviceTier, rateLimits] = yield* Effect.all( [ withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), @@ -543,13 +555,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun codexHome: initialize.codexHome, spawnedAtMs, deadlineMs, + catalogRefreshMs: timeouts.catalogRefresh, }) : withCodexDiscoveryDeadline(requestAllCodexModels(client), deadlineMs), // The shared catalog must exclude config from the server's launch project. withCodexDiscoveryDeadline( client.request("config/read", { includeLayers: false }), deadlineMs, - CODEX_CONFIG_PROBE_TIMEOUT_MS, + timeouts.config, ).pipe( Effect.map((response) => response?.config.service_tier ?? undefined), Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))), @@ -562,7 +575,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun : withCodexDiscoveryDeadline( client.request("account/rateLimits/read", null), deadlineMs, - RATE_LIMITS_PROBE_TIMEOUT_MS, + timeouts.rateLimits, ).pipe( Effect.map((response): CodexRateLimitsProbe => response @@ -600,10 +613,14 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi readonly launchArgs?: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; + readonly timeouts?: CodexProbeTimeouts; }) { - const { client } = yield* withCodexAppServerClient(input); - const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); - return parseCodexSkillsListResponse(skillsResponse, input.cwd); + const timeouts = input.timeouts ?? (yield* readCodexProbeTimeouts()); + return yield* Effect.gen(function* () { + const { client } = yield* withCodexAppServerClient(input); + const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + return parseCodexSkillsListResponse(skillsResponse, input.cwd); + }).pipe(Effect.timeout(Duration.millis(timeouts.skills))); }); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => @@ -689,6 +706,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; readonly skipNativeUsage?: boolean; + readonly timeouts?: CodexProbeTimeouts; }) => Effect.Effect< CodexAppServerProviderSnapshot, CodexErrors.CodexAppServerError, @@ -696,11 +714,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, managedAuth?: ServerProvider["auth"], + timeouts?: CodexProbeTimeouts, ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { + const resolvedTimeouts = timeouts ?? (yield* readCodexProbeTimeouts()); const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); @@ -729,9 +749,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu cwd: process.cwd(), environment: resolvedEnvironment, ...(managedAuth ? { skipNativeUsage: true } : {}), + timeouts: resolvedTimeouts, }).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(resolvedTimeouts.startup)), Effect.scoped, Effect.result, ); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index bbd910cf5d4a9..dc5cf49f893ed 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,5 +1,6 @@ import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -24,6 +25,7 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, + readCodexProbeTimeouts, withCodexAppServerClient, } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; @@ -280,7 +282,6 @@ export const CodexDriver: ProviderDriver< environment: processEnv, }).pipe( Effect.scoped, - Effect.timeout("20 seconds"), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ]).pipe( @@ -304,18 +305,21 @@ export const CodexDriver: ProviderDriver< resetCreditCoordinator .redeem(accountKey, (idempotencyKey) => Effect.gen(function* () { - const { client } = yield* withCodexAppServerClient({ - binaryPath: effectiveConfig.binaryPath, - homePath: effectiveConfig.homePath, - launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), - cwd: process.cwd(), - environment: processEnv, - }); - const response = yield* client.request("account/rateLimitResetCredit/consume", { - idempotencyKey, - }); - return response.outcome; - }).pipe(Effect.scoped, Effect.timeout("20 seconds")), + const timeouts = yield* readCodexProbeTimeouts(); + return yield* Effect.gen(function* () { + const { client } = yield* withCodexAppServerClient({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd: process.cwd(), + environment: processEnv, + }); + const response = yield* client.request("account/rateLimitResetCredit/consume", { + idempotencyKey, + }); + return response.outcome; + }).pipe(Effect.scoped, Effect.timeout(Duration.millis(timeouts.resetCredit))); + }), ) .pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5e356c84516a8..6317440caa11a 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -22,6 +22,7 @@ import { EnvironmentId, ClaudeSettings, CodexSettings, + DEFAULT_CODEX_PROBE_TIMEOUTS, DEFAULT_SERVER_SETTINGS, PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, ProviderDriverKind, @@ -384,6 +385,24 @@ it.layer( }), ); + it.effect("passes configured probe timeouts to the Codex probe", () => { + let seen: unknown; + return Effect.gen(function* () { + yield* checkCodexProviderStatus( + defaultCodexSettings, + (input) => { + seen = input.timeouts; + return Effect.succeed(makeCodexProbeSnapshot()); + }, + undefined, + undefined, + { ...DEFAULT_CODEX_PROBE_TIMEOUTS, startup: 90_000 }, + ); + + assert.deepStrictEqual(seen, { ...DEFAULT_CODEX_PROBE_TIMEOUTS, startup: 90_000 }); + }); + }); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => diff --git a/apps/web/src/components/settings/CodexProbeTimeoutsSettings.tsx b/apps/web/src/components/settings/CodexProbeTimeoutsSettings.tsx new file mode 100644 index 0000000000000..7f46676298877 --- /dev/null +++ b/apps/web/src/components/settings/CodexProbeTimeoutsSettings.tsx @@ -0,0 +1,236 @@ +import { + CODEX_PROBE_TIMEOUT_MAX_MS, + CODEX_PROBE_TIMEOUT_MIN_MS, + DEFAULT_CODEX_PROBE_TIMEOUTS, + type CodexProbeTimeouts, +} from "@t3tools/contracts"; +import { useState } from "react"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { PolicyTooltip, SettingResetButton, SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { + useScopedSettings, + useScopedSettingsMixed, + useScopedSettingsWriteAllowed, + useUpdateScopedSettings, +} from "./useScopedSettings"; + +const TIMEOUT_FIELDS = [ + { + key: "startup", + title: "App-server startup", + description: "Handshake, account lookup, and model and skill discovery.", + }, + { + key: "skills", + title: "Skills scan", + description: "Skills discovery for a thread's folder before it appears in the composer.", + }, + { + key: "catalogRefresh", + title: "Model catalog refresh", + description: "Wait for the app-server to refresh its shared model catalog.", + }, + { + key: "config", + title: "Config read", + description: "Read the service tier from the app-server configuration.", + }, + { + key: "rateLimits", + title: "Rate-limit read", + description: "Read the account's current usage windows.", + }, + { + key: "resetCredit", + title: "Reset-credit redemption", + description: "Redeem a usage reset credit on the selected account.", + }, +] as const satisfies ReadonlyArray<{ + readonly key: keyof CodexProbeTimeouts; + readonly title: string; + readonly description: string; +}>; + +const MIN_SECONDS = CODEX_PROBE_TIMEOUT_MIN_MS / 1_000; +const MAX_SECONDS = CODEX_PROBE_TIMEOUT_MAX_MS / 1_000; + +const seconds = (milliseconds: number) => Math.round(milliseconds / 1_000); +const milliseconds = (value: number) => + Math.min( + CODEX_PROBE_TIMEOUT_MAX_MS, + Math.max(CODEX_PROBE_TIMEOUT_MIN_MS, Math.round(value) * 1_000), + ); + +function useTimeoutFieldMixed() { + const { targets } = useSettingsScope(); + const first = targets[0]?.settings.codexProbeTimeouts; + return (key: keyof CodexProbeTimeouts) => + first === undefined + ? false + : targets.some((target) => target.settings.codexProbeTimeouts[key] !== first[key]); +} + +function CodexProbeTimeoutsDialog({ + open, + onOpenChange, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const canWriteSettings = useScopedSettingsWriteAllowed(); + const fieldMixed = useTimeoutFieldMixed(); + const timeouts = settings.codexProbeTimeouts; + + return ( + + + + Codex probe timeouts + + How long T3 Code waits for the Codex app-server before treating a probe as failed. + Longer values tolerate slow account services; shorter values surface a stalled + app-server sooner. + + + +
+ {TIMEOUT_FIELDS.map((field, index) => { + const mixed = fieldMixed(field.key); + return ( +
0 ? "border-t" : "" + }`} + > +
+
{field.title}
+

+ {field.description} +

+
+
+ { + if (value === null) return; + updateSettings({ + codexProbeTimeouts: { [field.key]: milliseconds(value) }, + }); + }} + > + + + + + + + seconds +
+
+ ); + })} +
+
+ + + + +
+
+ ); +} + +export function CodexProbeTimeoutsSettingsRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const canWriteSettings = useScopedSettingsWriteAllowed(); + const mixed = useScopedSettingsMixed(["codexProbeTimeouts"]); + const [dialogOpen, setDialogOpen] = useState(false); + const timeouts = settings.codexProbeTimeouts; + const changed = TIMEOUT_FIELDS.some( + ({ key }) => timeouts[key] !== DEFAULT_CODEX_PROBE_TIMEOUTS[key], + ); + const summary = mixed + ? "Mixed across the selected environments" + : `Startup ${seconds(timeouts.startup)}s · Skills ${seconds(timeouts.skills)}s · Discovery ${seconds(timeouts.catalogRefresh)}s`; + + return ( + <> + + {summary} + + These are environment settings. Choose all environments to apply one value across a T3 + Connect network. + + + } + resetAction={ + changed ? ( + + updateSettings({ codexProbeTimeouts: DEFAULT_CODEX_PROBE_TIMEOUTS })} + /> + + ) : null + } + control={ + + } + /> + + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c4e3d27a07fa7..297b4b732cb0e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -94,6 +94,7 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { BackgroundActivitySettingsRow } from "./BackgroundActivitySettings"; +import { CodexProbeTimeoutsSettingsRow } from "./CodexProbeTimeoutsSettings"; import { AdvancedSettings } from "./AdvancedSettings"; import { PullRequestLinksOpenInAppSetting } from "./IntegrationsSettings"; import { StorageCleanupSections } from "./StorageSettings"; @@ -1957,6 +1958,10 @@ export function MaintenanceSettingsPanel() { + + + + diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aabfd0300eda5..880cf8f213378 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -615,6 +615,15 @@ export const SETTINGS_SEARCH_ITEMS = [ "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy", ], }, + { + id: "codex-probe-timeouts", + title: "Codex probe timeouts", + to: "/settings/maintenance", + scope: "environment-defaults", + searchTerms: [ + "codex app-server startup skills probe timeout slow auth catalog rate limits reset credit", + ], + }, { id: "new-threads", title: "New threads", diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index bdc87902a63b2..8799cf9642958 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -76,6 +76,15 @@ reported accounts, refresh provider status, and confirm the second instance has its own shadow path and login. A shadow-home conflict usually means the directory contains a copied Codex setup. Use a fresh shadow directory and sign in again. +## Codex is slow to start + +If Codex briefly shows as unavailable while its app-server starts, raise +**Settings → Maintenance → Codex probe timeouts**. Startup and skills scans +default to 60 seconds; catalog, config, and rate-limit reads default to 3 +seconds. The setting is stored on each environment, so select **All +environments** to apply one value across your T3 Connect machines. Lower the +values if you want a stalled app-server reported sooner. + ## Answer questions while Codex works Codex can ask a question and keep working. Answer it in the thread's question diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 643d27296f34b..0acf4c55d8ccc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -681,6 +681,43 @@ export const CodexSettings = makeProviderSettingsSchema( ); export type CodexSettings = typeof CodexSettings.Type; +export const CODEX_PROBE_TIMEOUT_MIN_MS = 1_000; +export const CODEX_PROBE_TIMEOUT_MAX_MS = 10 * 60_000; + +const CODEX_PROBE_TIMEOUT_DEFAULTS = { + startup: 60_000, + skills: 60_000, + catalogRefresh: 3_000, + config: 3_000, + rateLimits: 3_000, + resetCredit: 20_000, +} as const; + +const CodexProbeTimeoutMs = Schema.Int.check( + Schema.isBetween({ + minimum: CODEX_PROBE_TIMEOUT_MIN_MS, + maximum: CODEX_PROBE_TIMEOUT_MAX_MS, + }), +); + +const codexProbeTimeout = (defaultMs: number) => + CodexProbeTimeoutMs.pipe(Schema.withDecodingDefault(Effect.succeed(defaultMs))); + +/** + * Environment-wide timeouts for the Codex app-server probes. They are server settings so the + * existing "All environments" scope can apply one value across a T3 Connect network. + */ +export const CodexProbeTimeouts = Schema.Struct({ + startup: codexProbeTimeout(CODEX_PROBE_TIMEOUT_DEFAULTS.startup), + skills: codexProbeTimeout(CODEX_PROBE_TIMEOUT_DEFAULTS.skills), + catalogRefresh: codexProbeTimeout(CODEX_PROBE_TIMEOUT_DEFAULTS.catalogRefresh), + config: codexProbeTimeout(CODEX_PROBE_TIMEOUT_DEFAULTS.config), + rateLimits: codexProbeTimeout(CODEX_PROBE_TIMEOUT_DEFAULTS.rateLimits), + resetCredit: codexProbeTimeout(CODEX_PROBE_TIMEOUT_DEFAULTS.resetCredit), +}).pipe(Schema.withDecodingDefault(Effect.succeed(CODEX_PROBE_TIMEOUT_DEFAULTS))); +export type CodexProbeTimeouts = typeof CodexProbeTimeouts.Type; +export const DEFAULT_CODEX_PROBE_TIMEOUTS: CodexProbeTimeouts = CODEX_PROBE_TIMEOUT_DEFAULTS; + const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/; export const IDLE_COMPACTION_MODES = [ @@ -1337,6 +1374,7 @@ export const ServerSettings = Schema.Struct({ providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + codexProbeTimeouts: CodexProbeTimeouts, observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), sourceControlHosts: Schema.Record(Schema.String, SourceControlHostSettings).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1660,6 +1698,16 @@ export const ServerSettingsPatch = Schema.Struct({ sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), // Replace whole maps to avoid half-merged driver configs. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), + codexProbeTimeouts: Schema.optionalKey( + Schema.Struct({ + startup: Schema.optionalKey(CodexProbeTimeoutMs), + skills: Schema.optionalKey(CodexProbeTimeoutMs), + catalogRefresh: Schema.optionalKey(CodexProbeTimeoutMs), + config: Schema.optionalKey(CodexProbeTimeoutMs), + rateLimits: Schema.optionalKey(CodexProbeTimeoutMs), + resetCredit: Schema.optionalKey(CodexProbeTimeoutMs), + }), + ), // Merge per source to avoid racing other edits; null removes an entry. usageLimitSources: Schema.optionalKey( Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 26cbfb3df9d59..cee829e7e5cc1 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_CODEX_PROBE_TIMEOUTS, DEFAULT_SERVER_SETTINGS, ProjectId, ProviderDriverKind, @@ -648,6 +649,19 @@ describe("serverSettings helpers", () => { ); }); + it("merges partial Codex probe timeout patches without losing other timeouts", () => { + expect(DEFAULT_SERVER_SETTINGS.codexProbeTimeouts).toEqual(DEFAULT_CODEX_PROBE_TIMEOUTS); + + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + codexProbeTimeouts: { startup: 90_000 }, + }); + + expect(next.codexProbeTimeouts).toEqual({ + ...DEFAULT_CODEX_PROBE_TIMEOUTS, + startup: 90_000, + }); + }); + it("turns legacy interval patches into custom background activity overrides", () => { const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { automaticGitFetchInterval: Duration.seconds(15), From 77f4e03da374065a2ce6b131208a83a044102529 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:25:06 +0000 Subject: [PATCH 2/2] fix(server): read Codex probe timeouts from the driver's host Workspace skills scans and reset-credit redemption run outside the driver's construction context, so an ambient ProviderHost lookup could silently fall back to the defaults. Drivers now read the setting from their host and pass it in. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/provider/CodexProvider.ts | 43 ++++++++++--------- .../src/provider/Drivers/CodexDriver.ts | 28 ++++++++---- .../provider/Drivers/CodexManagedProvider.ts | 20 ++++++--- .../src/provider/ProviderRegistry.test.ts | 19 -------- 4 files changed, 54 insertions(+), 56 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2b6ced0c09df0..9dd1e947cc264 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -83,13 +83,11 @@ const CODEX_DISCOVERY_MARGIN_MS = 250; // 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; -/** Server settings are the source of truth; tests and non-server callers get the defaults. */ -export const readCodexProbeTimeouts = Effect.fn("readCodexProbeTimeouts")(function* () { - const host = yield* Effect.serviceOption(ProviderHost.ProviderHost); - if (Option.isNone(host)) return DEFAULT_CODEX_PROBE_TIMEOUTS; - const settings = yield* host.value.settings.get.pipe(Effect.orElseSucceed(() => undefined)); - return settings?.codexProbeTimeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS; -}); +export const readCodexProbeTimeouts = (host: ProviderHost.ProviderHost["Service"]) => + host.settings.get.pipe( + Effect.map((settings) => settings.codexProbeTimeouts), + Effect.orElseSucceed(() => DEFAULT_CODEX_PROBE_TIMEOUTS), + ); const CODEX_PRESENTATION = { displayName: "Codex", @@ -545,7 +543,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly skipNativeUsage?: boolean; readonly timeouts?: CodexProbeTimeouts; }) { - const timeouts = input.timeouts ?? (yield* readCodexProbeTimeouts()); + const timeouts = input.timeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS; const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now); const { client, initialize } = yield* withCodexAppServerClient(input); @@ -657,24 +655,27 @@ const probeCodexCliVersion = Effect.fn("probeCodexCliVersion")( ), ); -export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly launchArgs?: string; - readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; - readonly timeouts?: CodexProbeTimeouts; -}) { - const timeouts = input.timeouts ?? (yield* readCodexProbeTimeouts()); - return yield* Effect.gen(function* () { +export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")( + function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + readonly timeouts?: CodexProbeTimeouts; + }) { const { client } = yield* withCodexAppServerClient({ ...input, environment: providerProbeEnvironment(input.environment ?? process.env), }); const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); return parseCodexSkillsListResponse(skillsResponse, input.cwd); - }).pipe(Effect.timeout(Duration.millis(timeouts.skills))); -}); + }, + (effect, input) => + effect.pipe( + Effect.timeout(Duration.millis((input.timeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS).skills)), + ), +); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => appendCustomCodexModels([], codexSettings.customModels); @@ -776,7 +777,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { - const resolvedTimeouts = timeouts ?? (yield* readCodexProbeTimeouts()); + const resolvedTimeouts = timeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS; const resolvedEnvironment = providerProbeEnvironment(environment ?? process.env); const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 181bf91535058..c4ef29d21dee4 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -149,6 +149,8 @@ export const CodexDriver: ProviderDriver< config, }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const providerHost = yield* ProviderHost.ProviderHost; + const probeTimeouts = readCodexProbeTimeouts(providerHost); const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; @@ -236,7 +238,11 @@ export const CodexDriver: ProviderDriver< const checkProvider = modelCatalog.refreshInBackground.pipe( Effect.andThen( Effect.zipWith( - checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + probeTimeouts.pipe( + Effect.flatMap((timeouts) => + checkCodexProviderStatus(effectiveConfig, undefined, processEnv, undefined, timeouts), + ), + ), currentCatalog, (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), { concurrent: true }, @@ -291,13 +297,17 @@ export const CodexDriver: ProviderDriver< ? snapshot.getSnapshot : Effect.all([ snapshot.getSnapshot, - probeCodexSkillsForCwd({ - binaryPath: effectiveConfig.binaryPath, - homePath: effectiveConfig.homePath, - launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), - cwd, - environment: processEnv, - }).pipe( + probeTimeouts.pipe( + Effect.flatMap((timeouts) => + probeCodexSkillsForCwd({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd, + environment: processEnv, + timeouts, + }), + ), Effect.scoped, Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), @@ -322,7 +332,7 @@ export const CodexDriver: ProviderDriver< resetCreditCoordinator .redeem(accountKey, (idempotencyKey) => Effect.gen(function* () { - const timeouts = yield* readCodexProbeTimeouts(); + const timeouts = yield* probeTimeouts; return yield* Effect.gen(function* () { const { client } = yield* withCodexAppServerClient({ binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index ab5e7add556a9..5f99d9f5d7434 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -20,6 +20,7 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, + readCodexProbeTimeouts, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -132,6 +133,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu undefined, effective.environment, managedAuth, + yield* readCodexProbeTimeouts(host), ); const credentials = yield* runtime.auth.access; // Another server can replace the saved profile while the native probe runs. @@ -314,13 +316,17 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu ), ), onSuccess: (effective) => - probeCodexSkillsForCwd({ - binaryPath: effective.config.binaryPath, - homePath: effective.config.homePath, - launchArgs: effective.config.launchArgs, - cwd, - environment: effective.environment, - }).pipe( + readCodexProbeTimeouts(host).pipe( + Effect.flatMap((timeouts) => + probeCodexSkillsForCwd({ + binaryPath: effective.config.binaryPath, + homePath: effective.config.homePath, + launchArgs: effective.config.launchArgs, + cwd, + environment: effective.environment, + timeouts, + }), + ), Effect.flatMap((skills) => snapshot.getSnapshot.pipe(Effect.map((draft) => ({ ...draft, skills }))), ), diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 8737b1a71f585..5c53f76237b6a 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -27,7 +27,6 @@ import { EnvironmentId, ClaudeSettings, CodexSettings, - DEFAULT_CODEX_PROBE_TIMEOUTS, DEFAULT_SERVER_SETTINGS, PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, ProviderDriverKind, @@ -416,24 +415,6 @@ it.layer( }), ); - it.effect("passes configured probe timeouts to the Codex probe", () => { - let seen: unknown; - return Effect.gen(function* () { - yield* checkCodexProviderStatus( - defaultCodexSettings, - (input) => { - seen = input.timeouts; - return Effect.succeed(makeCodexProbeSnapshot()); - }, - undefined, - undefined, - { ...DEFAULT_CODEX_PROBE_TIMEOUTS, startup: 90_000 }, - ); - - assert.deepStrictEqual(seen, { ...DEFAULT_CODEX_PROBE_TIMEOUTS, startup: 90_000 }); - }); - }); - it.effect("labels the plan the usage read reports over the cached login plan", () => Effect.gen(function* () { const statusWithUsagePlan = (planType: string | null) =>