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, }); }