diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 7d22b2e27eac8..63e40e0141321 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -95,7 +95,21 @@ vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({ })); vi.mock("./components/DatabaseStorageNotice", () => ({ DatabaseStorageNotice: () => null })); vi.mock("./components/SettingsSection", () => ({ SettingsSection: Children })); -vi.mock("../../components/ui/ListChoiceRow", () => ({ ListChoiceRow: () => null })); +vi.mock("../../components/ui/ListChoiceRow", () => ({ + ListChoiceRow: ({ + label, + disabled, + onPress, + }: { + label: string; + disabled?: boolean; + onPress: () => void; + }) => ( + + ), +})); vi.mock("./components/SettingsSwitchRow", () => ({ SettingsSwitchRow: ({ label, @@ -158,7 +172,11 @@ function environment(environmentId: string, label: string) { sidebarAutoSettleOnMerge: false, }, environment: { - capabilities: { projectSettingsOverrides: true, threadAutoSettlement: true }, + capabilities: { + projectSettingsOverrides: true, + threadAutoSettlement: true, + providerSessionIdleTimeout: true, + }, }, }, }; @@ -222,6 +240,25 @@ describe("SettingsServerControlsRouteScreen", () => { await act(async () => pending.resolve(AsyncResult.success(undefined))); }); + it.each([false, true])( + "does not send idle timeout changes to an excluded target with idle capability %s", + async (idleCapability) => { + const remote = environment("remote", "Remote"); + remote.connection.phase = "disconnected"; + remote.serverConfig.environment.capabilities.providerSessionIdleTimeout = idleCapability; + mocks.targets = [environment("local", "Local"), remote]; + act(() => root.render()); + const preset = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "5 minutes", + )!; + expect(preset).toBeDefined(); + await act(async () => preset.click()); + expect(mocks.updateSettings.mock.calls).toEqual([ + [{ environmentId: "local", input: { patch: { providerSessionIdleTimeoutMinutes: 5 } } }], + ]); + }, + ); + it("disables auto-settle when a selected environment cannot save settings", () => { const remote = environment("remote", "Remote"); // Auto-settle rows skip a server without auto-settlement, but the page still saves to it. diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 5026a90e13b9a..e02aaab0cbb19 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -149,7 +149,9 @@ function planServerSettingsWrites( const capable = targets.filter((target) => supportsSharedSettingsSync(target.environment)); const others = targets.filter((target) => !supportsSharedSettingsSync(target.environment)); const othersPatch = Object.fromEntries( - Object.entries(patch).filter(([key]) => !AUTO_SETTLE_SETTING_KEYS.has(key)), + Object.entries(patch).filter( + ([key]) => !AUTO_SETTLE_SETTING_KEYS.has(key) && key !== "providerSessionIdleTimeoutMinutes", + ), ) as ServerSettingsPatch; return [ ...planMobileScopedSettingsPatch(capable, projectSelected, patch), diff --git a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx index 41600619b3a47..0657eacd3388c 100644 --- a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx +++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx @@ -13,7 +13,7 @@ import type { ComponentProps } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/ui/AppText"; import { useEnvironments } from "../../state/environments"; @@ -21,6 +21,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "./components/SettingsSection"; +import { ListChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; @@ -101,6 +102,7 @@ export function AutoSettleSettingsRows(props: { patch: Partial & { autoResumeLimitedThreads?: boolean; snoozeLimitedThreads?: boolean; + providerSessionIdleTimeoutMinutes?: number | null; }, ) => props.write(patch); @@ -145,6 +147,31 @@ export function AutoSettleSettingsRows(props: { /> ) : null} + {!projectSelected && + displayTargets.every( + (target) => + target.environment.serverConfig.environment.capabilities.providerSessionIdleTimeout === + true, + ) ? ( + + + Free memory while idle. Conversations resume when you send a message. Active turns and + background work delay release. + + {PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS.map((option, index) => ( + 0} + disabled={disabled} + selected={displayTargets.every( + (target) => target.settings.providerSessionIdleTimeoutMinutes === option.value, + )} + onPress={() => writeToAll({ providerSessionIdleTimeoutMinutes: option.value })} + /> + ))} + + ) : null} ; - readonly idleTimeoutMs: number; + readonly idleTimeoutMs?: number; readonly maxIdlePinMs?: number; readonly failEventStream?: boolean; readonly normalizeThreadLoadModelSelection?: (selection: ModelSelection) => ModelSelection; @@ -727,7 +729,7 @@ function layerTest(input: { layerConfiguredMcpRegistry, McpProviderSessions.layer, ProviderSessionManager.layerWithOptions({ - idleTimeoutMs: input.idleTimeoutMs, + ...(input.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: input.idleTimeoutMs }), ...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }), }).pipe( Layer.provide( @@ -5641,3 +5643,269 @@ it.effect("ProviderSessionManagerV2 gives a delegated child its project's device assert.isTrue(child?.capabilities?.has("device")); }), ); + +it.effect( + "ProviderSessionManagerV2 falls back to 30 minutes when idle settings cannot be read", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const settings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide(ServerSettings.layerTest()), + ); + const settingsLayer = Layer.succeed(ServerSettings.ServerSettingsService, { + ...settings, + getSettings: Effect.fail( + new ServerSettingsError({ + settingsPath: "", + operation: "read-file", + cause: new Error("Settings unavailable"), + }), + ), + }); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("unreadable-idle-settings"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + yield* TestClock.adjust("29 minutes"); + assert.equal((yield* Ref.get(state)).closeCount, 0); + yield* TestClock.adjust("1 minute"); + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }).pipe(Effect.provide(layerTest({ state, serverSettingsLayer: settingsLayer }))); + }), +); + +it.effect.each([ + { timeoutMinutes: null, label: "disabled" }, + { timeoutMinutes: 1, label: "one minute" }, +] as const)( + "ProviderSessionManagerV2 applies configured idle timeout $label", + ({ timeoutMinutes }) => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("configured-idle-timeout"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "saved-conversation", + }); + yield* runtime.resumeThread({ providerThread, threadId, modelSelection, runtimePolicy }); + yield* TestClock.adjust("59 seconds"); + assert.equal((yield* Ref.get(state)).closeCount, 0); + if (timeoutMinutes === null) { + yield* TestClock.adjust("2 hours"); + assert.equal((yield* Ref.get(state)).closeCount, 0); + assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); + } else { + const stopped = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + eventType: "provider-session.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "provider-session.updated" && + stored.event.payload.status === "stopped", + ), + Stream.runHead, + Effect.forkScoped, + ); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(stopped); + assert.equal((yield* Ref.get(state)).closeCount, 1); + const reopened = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const resumed = yield* reopened.resumeThread({ + providerThread, + threadId, + modelSelection, + runtimePolicy, + }); + assert.deepEqual(resumed.nativeThreadRef, providerThread.nativeThreadRef); + assert.equal((yield* Ref.get(state)).openCount, 2); + assert.equal((yield* Ref.get(state)).resumeCount, 2); + } + }).pipe( + Effect.provide( + layerTest({ + state, + serverSettingsLayer: ServerSettings.layerTest({ + providerSessionIdleTimeoutMinutes: timeoutMinutes, + }), + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 updates pending idle waits without resetting elapsed time", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const settings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide(ServerSettings.layerTest({ providerSessionIdleTimeoutMinutes: 1 })), + ); + const changes = yield* PubSub.unbounded(); + const settingsLayer = Layer.succeed(ServerSettings.ServerSettingsService, { + ...settings, + subscribeChanges: PubSub.subscribe(changes).pipe(Effect.map(Stream.fromSubscription)), + }); + const updateTimeout = (value: number | null) => + settings + .updateSettings({ providerSessionIdleTimeoutMinutes: value }) + .pipe(Effect.flatMap((updated) => PubSub.publish(changes, updated))); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("changed-idle-timeout"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + yield* TestClock.adjust("30 seconds"); + yield* updateTimeout(5); + yield* TestClock.adjust("1 minute"); + assert.equal((yield* Ref.get(state)).closeCount, 0); + yield* updateTimeout(null); + yield* TestClock.adjust("2 hours"); + assert.equal((yield* Ref.get(state)).closeCount, 0); + const stopped = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + eventType: "provider-session.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "provider-session.updated" && + stored.event.payload.status === "stopped", + ), + Stream.runHead, + Effect.forkScoped, + ); + yield* updateTimeout(1); + yield* TestClock.adjust("0 millis"); + yield* Fiber.join(stopped); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }).pipe(Effect.provide(layerTest({ state, serverSettingsLayer: settingsLayer }))); + }), +); + +it.effect("ProviderSessionManagerV2 uses the configured timeout for shared thread unloading", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const unloading = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("configured-shared-idle-timeout"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "shared-saved-conversation", + }); + const resume = runtime.resumeThread({ + providerThread, + threadId, + modelSelection, + runtimePolicy, + }); + yield* resume; + const subscription = yield* runtime.subscribeEvents!; + const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(queue); + yield* Queue.offer(queue!, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: providerThread.id, + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: "completed-shared-turn", + }), + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* subscription.events.pipe(Stream.runHead); + yield* TestClock.adjust("59 seconds"); + assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(unloading); + yield* resume; + const observed = yield* Ref.get(state); + assert.equal(observed.closeCount, 0); + assert.deepEqual(observed.unloadedNativeThreadIds, ["shared-saved-conversation"]); + assert.equal(observed.resumeCount, 2); + }).pipe( + Effect.provide( + layerTest({ + state, + hasPendingBackgroundWork: Effect.succeed(true), + beforeUnload: Deferred.succeed(unloading, undefined).pipe(Effect.asVoid), + serverSettingsLayer: ServerSettings.layerTest({ providerSessionIdleTimeoutMinutes: 1 }), + }), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 2e9adb9c8a54c..ce43cfaa97171 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -524,7 +524,48 @@ export const layerWithOptions = ( readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; }) => `${input.providerSessionId}\u0000${input.threadId}`; - const idleTimeoutMs = Math.max(1, options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS); + // Subscribe before reading so a settings change cannot get lost between + // the snapshot and the wait. Keep elapsed idle time when changing policy. + const waitForIdleTimeout = Effect.fnUntraced(function* () { + if (options.idleTimeoutMs !== undefined) { + return yield* Effect.sleep(Duration.millis(Math.max(1, options.idleTimeoutMs))); + } + if (Option.isNone(serverSettings)) { + return yield* Effect.sleep(Duration.millis(DEFAULT_IDLE_TIMEOUT_MS)); + } + const settingsService = serverSettings.value; + return yield* Effect.scoped( + Effect.gen(function* () { + const changes = yield* settingsService.subscribeChanges; + const startedAt = yield* Clock.currentTimeMillis; + while (true) { + const minutes = yield* settingsService.getSettings.pipe( + Effect.map((settings) => settings.providerSessionIdleTimeoutMinutes), + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.idle-settings-unavailable", { + cause, + }).pipe(Effect.as(DEFAULT_IDLE_TIMEOUT_MS / 60_000)), + ), + ); + const elapsed = (yield* Clock.currentTimeMillis) - startedAt; + const expired = + minutes === null + ? Effect.never + : Effect.sleep(Duration.millis(Math.max(0, minutes * 60_000 - elapsed))); + const outcome = yield* Effect.raceFirst( + expired.pipe(Effect.as("expired" as const)), + changes.pipe( + Stream.runHead, + Effect.flatMap((next) => + Option.isNone(next) ? Effect.never : Effect.succeed("changed" as const), + ), + ), + ); + if (outcome === "expired") return; + } + }), + ); + }); const maxIdlePinMs = Math.max(0, options.maxIdlePinMs ?? DEFAULT_MAX_IDLE_PIN_MS); interface PreparedMcpCredential { readonly mcpCredentialId: string | undefined; @@ -1516,7 +1557,7 @@ export const layerWithOptions = ( pinnedForMs: now - pinnedSinceMs, }); // Recheck on this fiber; scheduleIdleReleaseInternal would interrupt it and self-deadlock. - yield* Effect.sleep(Duration.millis(idleTimeoutMs)); + yield* waitForIdleTimeout(); return yield* releaseIfStillIdle(input); } yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", { @@ -1585,7 +1626,7 @@ export const layerWithOptions = ( yield* cancelIdleFiber(entry.idleFiber); const generation = entry.idleGeneration + 1; - const idleFiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe( + const idleFiber = yield* waitForIdleTimeout().pipe( Effect.andThen(releaseIfStillIdle({ providerSessionId, generation })), Effect.forkIn(layerScope), ); @@ -1938,7 +1979,7 @@ export const layerWithOptions = ( if (tracked === undefined) return; const [threadId, pending] = tracked; const generation = pending.generation + 1; - const fiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe( + const fiber = yield* waitForIdleTimeout().pipe( Effect.andThen(unloadIdleThread({ providerSessionId, threadId, generation })), Effect.forkIn(layerScope), ); @@ -2085,7 +2126,7 @@ export const layerWithOptions = ( ); // Wait outside the lock so the next attach is not blocked. if (outcome === "deferred" && input.archiveOnly !== true) { - yield* Effect.sleep(Duration.millis(idleTimeoutMs)); + yield* waitForIdleTimeout(); return yield* unloadIdleThread(input); } }, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 633e18a567b2f..a677920d6ccdd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -8,6 +8,7 @@ import { useRef, useState } from "react"; import { type DesktopUpdateChannel, IDLE_COMPACTION_MODES, + PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS, globalIdleCompaction, managedUpdatesDenial, managedUpdatesMessage, @@ -1726,6 +1727,17 @@ export function ThreadsSettingsPanel() { const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const environmentId = environment?.environmentId ?? null; const hasServerTargets = connectedEnvironments.length > 0; + const mixedIdleTimeout = useScopedSettingsMixed(["providerSessionIdleTimeoutMinutes"]); + const supportsIdleTimeout = + connectedEnvironments.length > 0 && + connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.providerSessionIdleTimeout === true, + ); + const idleTimeoutValue = + settings.providerSessionIdleTimeoutMinutes === null + ? "never" + : String(settings.providerSessionIdleTimeoutMinutes); const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]); const providersByEnvironment = useEnvironmentProviders(); const serverProviders = @@ -1840,6 +1852,65 @@ export function ThreadsSettingsPanel() { + + + updateSettings({ + providerSessionIdleTimeoutMinutes: + DEFAULT_UNIFIED_SETTINGS.providerSessionIdleTimeoutMinutes, + }) + } + /> + ) : null + } + control={ + + } + /> + {!isProjectScope ? ( diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7d7939e7f21f9..0b5fa45aeb48b 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -645,6 +645,13 @@ export const SETTINGS_SEARCH_ITEMS = [ requiresThreadAutoSettlement: true, scope: "project-defaults", }, + { + id: "release-idle-sessions", + title: "Release idle agents", + to: "/settings/threads", + scope: "environment", + searchTerms: ["memory RAM process idle timeout suspend resume inactivity agents"], + }, { id: "compact-idle-threads", title: "Compact idle threads", diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 899640ad23590..65dcf51fbdd80 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -285,3 +285,14 @@ On mobile, use **Settings → Source control** to change selected environment de T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, or local commits. It skips checkouts on another branch or without an upstream. If a checkout has local work, resolve it yourself before automatic pulls can resume. + +## Idle agents and memory + +In web or desktop **Settings → Threads → Release idle agents**, choose how long an idle +agent stays in memory, or **Never** to keep it loaded. On mobile, use **Settings → Threads**. This is an environment setting because agents can share a process across projects. +The default remains 30 minutes. Changes also apply to agents already waiting while idle. + +Release keeps the saved conversation; sending another message resumes it automatically. +Active turns and provider-reported background work delay release. This does not compact +conversation history or extend the provider's prompt cache. Resuming can take longer and use +more input tokens after that cache expires. Configure Claude idle compaction separately. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 603b6c6cc862d..683f763b22c92 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -103,6 +103,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean), threadSettlement: Schema.optionalKey(Schema.Boolean), threadAutoSettlement: Schema.optionalKey(Schema.Boolean), + providerSessionIdleTimeout: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), storageCleanupRun: Schema.optionalKey(Schema.Boolean), databaseStorage: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1db913e38ba3b..0dd6c176b87cb 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -43,6 +43,29 @@ const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); +describe("provider session idle timeout", () => { + it("preserves the existing 30 minute default and accepts disabling or shortening it", () => { + expect(decodeServerSettings({}).providerSessionIdleTimeoutMinutes).toBe(30); + for (const value of [null, 1, 5, 30, 1440]) { + expect(decodeServerSettingsPatch({ providerSessionIdleTimeoutMinutes: value })).toEqual({ + providerSessionIdleTimeoutMinutes: value, + }); + expect( + decodeServerSettings({ providerSessionIdleTimeoutMinutes: value }) + .providerSessionIdleTimeoutMinutes, + ).toBe(value); + } + }); + + it("rejects invalid timeouts rather than overflowing timers", () => { + for (const value of [0, -1, 1.5, 1441, Infinity, "5"]) { + expect(() => + decodeServerSettingsPatch({ providerSessionIdleTimeoutMinutes: value }), + ).toThrow(); + } + }); +}); + it("drops theme defaults from a client's settings patch", () => { expect( decodeClientServerSettingsPatch({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7bbb03595d4bb..7dc0c7a057bb2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -97,6 +97,19 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check( ); export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 7; +export const PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS = [ + { value: null, label: "Never" }, + { value: 1, label: "1 minute" }, + { value: 5, label: "5 minutes" }, + { value: 10, label: "10 minutes" }, + { value: 30, label: "30 minutes" }, + { value: 60, label: "1 hour" }, +] as const; + +/** Null keeps idle runtimes loaded; minutes bound native timer delays. */ +const ProviderSessionIdleTimeoutMinutes = Schema.NullOr( + Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1440 })), +); export const MAX_SNOOZE_PRESET_MINUTES = 30 * 24 * 60; export const MAX_SNOOZE_PRESETS = 8; @@ -1451,6 +1464,9 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(false)), ), idleCompaction: Schema.optionalKey(IdleCompactionMode), + providerSessionIdleTimeoutMinutes: ProviderSessionIdleTimeoutMinutes.pipe( + Schema.withDecodingDefault(Effect.succeed(30)), + ), snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), snoozePresets: SnoozePresetSettings.pipe( @@ -1897,6 +1913,7 @@ export const ServerSettingsPatch = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), compactIdleThreads: Schema.optionalKey(LegacyCompactIdleThreads), idleCompaction: Schema.optionalKey(IdleCompactionMode), + providerSessionIdleTimeoutMinutes: Schema.optionalKey(ProviderSessionIdleTimeoutMinutes), sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean),