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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude committed 2026-11-10 11:32:14 -06:00
1 parent b7f6243d60
commit 9a74116082
7 files changed
+43 -13

No files matched your search

@@ -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. */
@@ -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",
@@ -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,
@@ -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 },
@@ -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));
@@ -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<string, unknown> }, "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<string, unknown> };
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 () => {
@@ -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,
});
}