From 9a741160827a91616370c9be345068dc6f45a83b Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Nov 2026 11:32:14 -0600 Subject: [PATCH] fix(native): give a defaulted warm runner the environment's idle window The sandbox execution target now carries an inheriting environment's runnerIdleTimeoutMs, and the remote warm default resolves its window as the environment view does (environment, then agent), so the host's warm runner and the provider auto-stop use the same window. An explicit warm choice is unchanged. Co-Authored-By: Claude --- packages/adapter-utils/src/execution-target.ts | 6 ++++++ ...ronment-execution-target-capabilities.test.ts | 10 ++++++++++ .../src/services/environment-execution-target.ts | 1 + .../services/heartbeat/native-lifecycle.test.ts | 5 +++++ .../src/services/heartbeat/native-lifecycle.ts | 16 ++++++++++------ .../services/heartbeat/runtime-selection.test.ts | 16 ++++++++++------ .../src/services/heartbeat/runtime-selection.ts | 2 +- 7 files changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 08a942542a0..478a058e55e 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -209,6 +209,12 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor | { mode: "per_turn"; idleTimeoutMs: null } | { mode: "warm"; idleTimeoutMs: number } | null; + /** + * The environment's configured warm idle window, present even when the + * environment inherits the lifecycle. A defaulted warm runner uses it first, + * as the provider's auto-stop does, so both keep the same window. + */ + readonly runnerIdleTimeoutMs?: number; /** Whether this environment is configured to reuse its provider lease. */ readonly reusableLeaseConfigured?: boolean; /** Host-observed provenance for this exact sandbox acquisition. */ diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index 677223491a5..23677af4f40 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -218,6 +218,16 @@ describe("resolveEnvironmentExecutionTarget effective capability snapshot", () = expect(target.reusableLeaseConfigured).toBe(true); }); + it("carries an inheriting environment's idle window for the remote warm default", async () => { + const { target } = await buildSandboxTarget({ + snapshot: FULL_GRANT, + supportsSync: false, + config: { reuseLease: true, runnerIdleTimeoutMs: 900_000 }, + }); + expect(target.runnerLifecyclePolicy).toBeNull(); + expect(target.runnerIdleTimeoutMs).toBe(900_000); + }); + it("carries host-owned sandbox acquisition provenance without persisting provider ids in metadata", async () => { mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ driver: "sandbox", diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 51313404fd8..6d78e7f00a9 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -416,6 +416,7 @@ export async function resolveEnvironmentExecutionTarget(input: { : parsed.config.runnerLifecycleMode === "per_turn" ? { mode: "per_turn", idleTimeoutMs: null } : null, + ...(typeof parsed.config.runnerIdleTimeoutMs === "number" ? { runnerIdleTimeoutMs: parsed.config.runnerIdleTimeoutMs } : {}), reusableLeaseConfigured: parsed.config.reuseLease === true, sandboxLeaseAcquisition: sandboxLeaseAcquisitionFromMetadata( input.lease?.metadata?.sandboxLeaseAcquisition, diff --git a/server/src/services/heartbeat/native-lifecycle.test.ts b/server/src/services/heartbeat/native-lifecycle.test.ts index 1a6e5540d49..86bc6ca244d 100644 --- a/server/src/services/heartbeat/native-lifecycle.test.ts +++ b/server/src/services/heartbeat/native-lifecycle.test.ts @@ -57,6 +57,11 @@ describe("default lifecycle for an agent that never chose one", () => { // Rule 2: explicit configuration wins. { name: "a remote-sandbox explicit per-turn choice is honored", change: { localTarget: false, remoteSandboxTarget: reusable, agentMode: "per_turn" }, expected: perTurn }, { name: "a remote-sandbox default honors the agent's idle window", change: { localTarget: false, remoteSandboxTarget: reusable, agentIdleTimeoutMs: 900_000 }, expected: warm }, + // The same window the environment view forwards to the provider: the environment's, then the agent's. + { name: "a remote-sandbox default honors an inheriting environment's idle window", change: { localTarget: false, + remoteSandboxTarget: { reusableLease: true, idleTimeoutMs: 900_000 }, agentIdleTimeoutMs: 600_000 }, expected: warm }, + { name: "an explicit warm choice keeps the agent's idle window", change: { localTarget: false, agentMode: "warm", + remoteSandboxTarget: { reusableLease: true, idleTimeoutMs: 600_000 }, agentIdleTimeoutMs: 900_000 }, expected: warm }, { name: "a remote-sandbox environment that pins per-turn wins", change: { localTarget: false, remoteSandboxTarget: reusable, environmentPolicy: perTurn }, expected: perTurn }, { name: "a remote-sandbox session admitted per-turn stays per-turn", change: { localTarget: false, remoteSandboxTarget: reusable, persistedPolicy: perTurn }, expected: perTurn }, { name: "remote-sandbox managed Claude stays per-turn", change: { localTarget: false, remoteSandboxTarget: reusable, backend: "acpx_runtime", acpxAgent: "claude", managedAiSelected: true }, expected: perTurn }, diff --git a/server/src/services/heartbeat/native-lifecycle.ts b/server/src/services/heartbeat/native-lifecycle.ts index b23365a6884..21789901d25 100644 --- a/server/src/services/heartbeat/native-lifecycle.ts +++ b/server/src/services/heartbeat/native-lifecycle.ts @@ -22,9 +22,11 @@ export function resolveHeartbeatNativeLifecyclePolicy(input: { /** * The run executes in a remote sandbox (not a plugin task). `reusableLease` * is true only when the acquired lease is reusable: configured, and - * supported by the sandbox provider. + * supported by the sandbox provider. `idleTimeoutMs` is the environment's + * configured window, which wins over the agent's for the default, as in the + * window the environment view forwards to the provider. */ - remoteSandboxTarget?: { reusableLease: boolean }; + remoteSandboxTarget?: { reusableLease: boolean; idleTimeoutMs?: number }; }): NativeLifecyclePolicy { // An explicit choice wins. With none, a measured harness keeps its runner and // provider session between turns: on a local target, and on a remote sandbox @@ -39,11 +41,13 @@ export function resolveHeartbeatNativeLifecyclePolicy(input: { }); const agentMode = explicit ? input.agentMode : input.adapterType === "paperclip_runner" && (localDefault || remoteDefault) ? "warm" : "per_turn"; - // An explicit warm choice and the remote default honor the agent's idle - // window, the same window the environment view forwards to the provider. + // An explicit warm choice honors the agent's idle window. The remote default + // uses the window the environment view forwards to the provider: the + // environment's, then the agent's. const agentPolicy: NativeLifecyclePolicy = agentMode === "warm" - ? { mode: "warm", idleTimeoutMs: explicit || remoteDefault - ? resolvePaperclipRunnerIdleTimeoutMs(input.agentIdleTimeoutMs) : PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS } + ? { mode: "warm", idleTimeoutMs: explicit ? resolvePaperclipRunnerIdleTimeoutMs(input.agentIdleTimeoutMs) + : remoteDefault ? resolvePaperclipRunnerIdleTimeoutMs(input.remoteSandboxTarget?.idleTimeoutMs ?? input.agentIdleTimeoutMs) + : PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS } : { mode: "per_turn", idleTimeoutMs: null }; const supportsManagedWarm = input.adapterType === "paperclip_runner" && (input.taskOwned || supportsManagedNativeWarmSession(profile, input.managedAiAttribution)); diff --git a/server/src/services/heartbeat/runtime-selection.test.ts b/server/src/services/heartbeat/runtime-selection.test.ts index ea595c7fc55..0d8239399ec 100644 --- a/server/src/services/heartbeat/runtime-selection.test.ts +++ b/server/src/services/heartbeat/runtime-selection.test.ts @@ -26,7 +26,7 @@ import { resolveHeartbeatNativeRuntimeMode } from "../native-runtime/runtime-mod import { claimNativeRestartRecoveries } from "../native-runtime/native-restart-recovery.js"; import { buildNativeModelEnvelope, canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js"; import { prepareAndSelectHeartbeatRuntime, selectHeartbeatRuntime, type HeartbeatRuntimeSelectionInput } from "./runtime-selection.js"; -import { resolveRunnerEnvironmentForRun } from "../runner-environment-lifecycle.js"; +import { resolveRunnerEnvironmentForRun, resolveWarmRunnerIdleTimeoutMsForRun } from "../runner-environment-lifecycle.js"; import { reserveRunBudget } from "../budget-reservations.js"; import { legacyControllerClaim } from "../legacy-controller-lease.js"; import { agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js"; @@ -796,17 +796,21 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () = const { input, run, agent, issue } = await fixture(); input.agent.adapterConfig = adapterConfig; input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: run, enabled: true, runtimeConfig: {}, adapterConfig, agent, issue, target: null, workspaceId: null }); - const view = resolveRunnerEnvironmentForRun({ driver: "sandbox" as const, config: { provider: "daytona" } as Record }, "paperclip_runner", adapterConfig, - undefined, undefined, { reusableLeaseSupported: true }); + // An inheriting environment with its own idle window: the provider and the runner must use the same one. + const environment = { driver: "sandbox" as const, config: { provider: "daytona", runnerIdleTimeoutMs: 900_000 } as Record }; + const view = resolveRunnerEnvironmentForRun(environment, "paperclip_runner", adapterConfig, undefined, undefined, { reusableLeaseSupported: true }); expect(view.config.reuseLease === true).toBe(mode === "warm"); - input.workspace.executionTarget = { ...sandbox, reusableLeaseConfigured: view.config.reuseLease === true, effectiveCapabilities: { reusableLeases: true } } as never; + const forwardedIdleMs = resolveWarmRunnerIdleTimeoutMsForRun(environment, "paperclip_runner", adapterConfig, undefined, { reusableLeaseSupported: true }); + input.workspace.executionTarget = { ...sandbox, reusableLeaseConfigured: view.config.reuseLease === true, effectiveCapabilities: { reusableLeases: true }, + runnerIdleTimeoutMs: 900_000 } as never; const stop = new Error("policy captured"); const policies: unknown[] = []; await expect(prepareAndSelectHeartbeatRuntime(db, input, { measure: async (_phase, work) => work(), - prepareInstructions: async (policy) => { policies.push(policy?.mode); throw stop; }, + prepareInstructions: async (policy) => { policies.push(policy); throw stop; }, })).rejects.toBe(stop); - expect(policies).toEqual([mode]); + expect(policies).toEqual([mode === "warm" ? { mode, idleTimeoutMs: forwardedIdleMs } : { mode, idleTimeoutMs: null }]); + expect(forwardedIdleMs).toBe(mode === "warm" ? 900_000 : undefined); }); it("keeps a plugin task's sandbox target out of the remote default", async () => { diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 8926a17b694..3e6655b2077 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -288,7 +288,7 @@ function resolveRuntimeLifecyclePolicy(input: HeartbeatRuntimeSelectionInput): N taskOwned: Boolean(sandboxTarget?.runnerTask), localTarget: target?.kind !== "remote", remoteSandboxTarget: sandboxTarget && !sandboxTarget.runnerTask - ? { reusableLease: sandboxTargetHasReusableLease(sandboxTarget) } : undefined, + ? { reusableLease: sandboxTargetHasReusableLease(sandboxTarget), idleTimeoutMs: sandboxTarget.runnerIdleTimeoutMs } : undefined, }); }