From 669050e62f11558727fe89ec563f5015372abf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Mon, 26 Oct 2026 17:50:52 -0300 Subject: [PATCH 1/4] feat(server): make idle agent release configurable --- .../settings/SettingsThreadsRouteScreen.tsx | 29 ++- .../src/environment/ServerEnvironment.ts | 1 + .../ProviderSessionManager.test.ts | 230 +++++++++++++++++- .../ProviderSessionManager.ts | 51 +++- .../components/settings/SettingsPanels.tsx | 64 +++++ .../src/components/settings/settingsSearch.ts | 7 + docs/user/project-settings.md | 12 + packages/contracts/src/environment.ts | 1 + packages/contracts/src/settings.test.ts | 23 ++ packages/contracts/src/settings.ts | 18 ++ 10 files changed, 428 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index c8f3c4c7d170b..98d2fc61efd64 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -9,12 +9,13 @@ import { useRef, useState } 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 { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; +import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { @@ -90,6 +91,7 @@ function AutoSettleSettingsRows() { patch: Partial & { autoResumeLimitedThreads?: boolean; snoozeLimitedThreads?: boolean; + providerSessionIdleTimeoutMinutes?: number | null; }, ) => { if ( @@ -193,6 +195,31 @@ function AutoSettleSettingsRows() { /> ) : null} + {!projectSelected && + syncTargets.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 capabilities?: OrchestrationV2ProviderCapabilities; @@ -546,7 +547,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( @@ -3542,3 +3543,228 @@ it.effect( assert.isFalse(denied?.capabilities?.has("device")); }), ); + +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 00b42d510bd91..7480d4bf58329 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -384,7 +384,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(null)), + ), + ); + 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; @@ -1120,7 +1161,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", { @@ -1177,7 +1218,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), ); @@ -1520,7 +1561,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), ); @@ -1605,7 +1646,7 @@ export const layerWithOptions = ( ); // Wait outside the lock so the next attach is not blocked. if (outcome === "deferred") { - 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 246df2723a7ec..477d0b68fe89e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -7,6 +7,7 @@ import { useRef, useState } from "react"; import { type DesktopUpdateChannel, IDLE_COMPACTION_MODES, + PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS, globalIdleCompaction, ProviderDriverKind, type ProviderInstanceId, @@ -791,6 +792,17 @@ export function GeneralSettingsPanel() { const environmentId = environment?.environmentId ?? null; const isEnvironmentScope = scope.environmentIds.length === 1 && 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 lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), @@ -969,6 +981,58 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + providerSessionIdleTimeoutMinutes: + DEFAULT_UNIFIED_SETTINGS.providerSessionIdleTimeoutMinutes, + }) + } + /> + ) : null + } + control={ + + } + /> + { + 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(); + } + }); +}); + describe("storage cleanup settings", () => { it("keeps cleanup disabled for existing installations", () => { expect(decodeServerSettings({}).worktreeCleanup).toBeNull(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 88460d06bcdf6..d00bb0d80b5b3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -92,6 +92,20 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check( ); export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; +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 MIN_GLASS_OPACITY = 40; export const MAX_GLASS_OPACITY = 100; export const GlassOpacity = Schema.Int.check( @@ -1164,6 +1178,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))), /** Beta: guest links for this machine's threads. Off refuses new links and pauses existing ones. */ @@ -1508,6 +1525,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), From b7bd5b81b72b7c2da125355e6dadbd1fa588f955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Tue, 27 Oct 2026 06:49:01 -0300 Subject: [PATCH 2/4] fix(server): retain idle release on settings read failure --- .../ProviderSessionManager.test.ts | 42 +++++++++++++++++++ .../ProviderSessionManager.ts | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index d48c27ef234c4..162768c4e6256 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -12,6 +12,7 @@ import { type OrchestrationV2ProviderThread, type Project, ProjectId, + ServerSettingsError, ProviderDriverKind, ProviderInstanceId, type ProviderSessionId, @@ -3544,6 +3545,47 @@ it.effect( }), ); +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" }, diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 7480d4bf58329..95f968b272293 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -404,7 +404,7 @@ export const layerWithOptions = ( Effect.catchCause((cause) => Effect.logWarning("orchestration-v2.driver-session.idle-settings-unavailable", { cause, - }).pipe(Effect.as(null)), + }).pipe(Effect.as(DEFAULT_IDLE_TIMEOUT_MS / 60_000)), ), ); const elapsed = (yield* Clock.currentTimeMillis) - startedAt; From 8045aa058ce37bc4863e97162cacf5aea8695f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Tue, 27 Oct 2026 09:47:46 -0300 Subject: [PATCH 3/4] fix(mobile): exclude unsupported targets from idle timeout writes --- ...SettingsServerControlsRouteScreen.test.tsx | 41 ++++++++++++++++++- .../SettingsServerControlsRouteScreen.tsx | 4 +- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 179eceba6c6fd..39cb3295e1958 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -85,7 +85,21 @@ vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({ })); vi.mock("./components/DatabaseStorageNotice", () => ({ DatabaseStorageNotice: () => null })); vi.mock("./components/SettingsSection", () => ({ SettingsSection: Children })); -vi.mock("./components/SettingsChoiceRow", () => ({ SettingsChoiceRow: () => null })); +vi.mock("./components/SettingsChoiceRow", () => ({ + SettingsChoiceRow: ({ + label, + disabled, + onPress, + }: { + label: string; + disabled?: boolean; + onPress: () => void; + }) => ( + + ), +})); vi.mock("./components/SettingsSwitchRow", () => ({ SettingsSwitchRow: ({ label, @@ -148,7 +162,11 @@ function environment(environmentId: string, label: string) { sidebarAutoSettleOnMerge: false, }, environment: { - capabilities: { projectSettingsOverrides: true, threadAutoSettlement: true }, + capabilities: { + projectSettingsOverrides: true, + threadAutoSettlement: true, + providerSessionIdleTimeout: true, + }, }, }, }; @@ -212,6 +230,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.serverConfig.environment.capabilities.threadAutoSettlement = false; + 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 94a54db718db5..d1bc3e9bf9204 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -145,7 +145,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), From 93eead1acdddb8f69b35b8a3e70698a063fb98b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Wed, 28 Oct 2026 00:50:40 -0300 Subject: [PATCH 4/4] fix(web): show idle timeout preset labels consistently --- apps/web/src/components/settings/SettingsPanels.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f54ab31993483..43bd7715267e9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1633,9 +1633,14 @@ export function ThreadsSettingsPanel() { > - {(value: string | null) => - value === null ? "Mixed" : value === "never" ? "Never" : `${value} minutes` - } + {(value: string | null) => { + if (value === null) return "Mixed"; + const preset = PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS.find( + (option) => + (option.value === null ? "never" : String(option.value)) === value, + ); + return preset?.label ?? `${value} minutes`; + }}