diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2e56eede84cd6..9dd1e947cc264 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, @@ -54,9 +59,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 = | { @@ -71,17 +76,19 @@ 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; // `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; +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", showInteractionModeToggle: true, @@ -421,6 +428,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), @@ -435,7 +443,7 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio }), ), input.deadlineMs, - CODEX_CATALOG_REFRESH_TIMEOUT_MS, + input.catalogRefreshMs, ); if (refreshed === undefined) { yield* Effect.logWarning( @@ -533,7 +541,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; readonly skipNativeUsage?: boolean; + readonly timeouts?: CodexProbeTimeouts; }) { + const timeouts = input.timeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS; const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now); const { client, initialize } = yield* withCodexAppServerClient(input); @@ -551,7 +561,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), @@ -561,13 +571,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))), @@ -579,7 +590,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 @@ -644,20 +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; -}) { - 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); -}); +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); + }, + (effect, input) => + effect.pipe( + Effect.timeout(Duration.millis((input.timeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS).skills)), + ), +); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => appendCustomCodexModels([], codexSettings.customModels); @@ -745,6 +763,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, @@ -752,11 +771,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 ?? DEFAULT_CODEX_PROBE_TIMEOUTS; const resolvedEnvironment = providerProbeEnvironment(environment ?? process.env); const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); @@ -785,9 +806,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 dee8a04aee9fa..c4ef29d21dee4 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 Digest from "effect/crypto/Digest"; +import * as Duration from "effect/Duration"; import * as SecureRandom from "effect/crypto/SecureRandom"; import type * as Signature from "effect/crypto/Signature"; import * as Effect from "effect/Effect"; @@ -26,6 +27,7 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, + readCodexProbeTimeouts, withCodexAppServerClient, } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; @@ -147,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; @@ -234,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 }, @@ -289,15 +297,18 @@ 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.timeout("20 seconds"), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ]).pipe( @@ -321,18 +332,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* probeTimeouts; + 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/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/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 a37bf1581990e..70885c0a4cccd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -104,6 +104,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"; @@ -2028,6 +2029,10 @@ export function MaintenanceSettingsPanel() { + + + + diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f7492035874da..42fd2da2bf2fd 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -758,6 +758,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 3384831aa87b9..a582c85a1dae3 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -84,6 +84,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 cd112d84bf9f4..b8b660a6e0b56 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -733,6 +733,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 = [ @@ -1525,6 +1562,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({})), @@ -1909,6 +1947,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 4a28655ab4292..9f3e703db7912 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, @@ -722,6 +723,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),