fix(native): apply the remote warm default only on providers with reusable leases

One rule (defaultsRemoteSandboxRunToWarm) now decides the remote-sandbox
warm default for both the run's environment view and the native session
policy. It applies only when no lifecycle was chosen anywhere, the harness
is measured, a selected managed AI account supports a warm session, and the
sandbox provider keeps reusable leases. The environment view probes the
provider before acquisition (only for eligible runs); the native policy
reads the acquired lease, so a run that works per-turn on master is never
refused as warm. An omitted provider resolves as the runtime resolves it,
and the remote default honors the agent's configured idle window.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DottaandClaude committed 2026-11-10 10:30:57 -06:00
1 parent 061c5706d7
commit 436bdde3ca
11 files changed
+368 -81

No files matched your search

@@ -851,6 +851,34 @@ describe("native runner lifecycle changes before lease acquisition", () => {
expect(coldAgain.lease.leasePolicy).toBe("ephemeral");
});
// The remote-sandbox warm default asks the provider before it requests a
// reusable lease, and asks only for a run whose lifecycle nobody chose.
it.each([
["a provider with reusable leases", {}, true, true, true],
["a provider without reusable leases (E2B, Modal, fake)", {}, false, true, false],
["an explicit per-turn agent", { lifecycleMode: "per_turn" }, true, false, false],
["a managed Claude account", { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6", managed: true }, true, false, false],
] as const)("defaults an inheriting sandbox run to warm only on %s", async (_name, agent, supported, probed, warm) => {
const environment = { ...makeEnvironment("sandbox"), config: { provider: "daytona", reuseLease: false } };
mockGetEnvironment.mockResolvedValue(environment);
const acquireRunLease = vi.fn(async (input) => ({ environment: input.environment, lease: makeLease(), leaseContext: { executionWorkspaceId: null, executionWorkspaceMode: null } }));
const providerSupportsReusableLeases = vi.fn(async () => supported);
const orchestrator = environmentRunOrchestrator({} as never, {
environmentRuntime: { ...makeMockRuntime({ acquireRunLease }), providerSupportsReusableLeases } as never,
});
const { managed, ...adapterConfig } = { managed: false, ...agent };
await orchestrator.acquireForRun({
companyId: "company-1", selectedEnvironmentId: "env-1", localEnvironmentId: "local", adapterType: "paperclip_runner",
adapterConfig, managedAiCredentialIdentity: managed ? "account:generation" : undefined,
issueId: "task-1", heartbeatRunId: "run-1", agentId: "agent-1",
persistedExecutionWorkspace: null, executionWorkspaceSettings: null,
});
expect(providerSupportsReusableLeases).toHaveBeenCalledTimes(probed ? 1 : 0);
expect(acquireRunLease.mock.calls[0][0].environment.config.reuseLease).toBe(warm);
expect(acquireRunLease.mock.calls[0][0].warmRunnerIdleTimeoutMs).toBe(warm ? 300_000 : undefined);
expect(environment.config.reuseLease).toBe(false);
});
it.each([
["paperclip_runner", undefined, undefined, false],
["paperclip_runner", "inherit", "per_turn", false],
@@ -37,6 +37,7 @@ import type {
import {
resolveRunnerEnvironmentForRun,
resolveRunnerReconnectGraceMsForRun,
runnerLifecycleDefaultNeedsLeaseProbe,
resolveWarmRunnerIdleTimeoutMsForRun,
} from "./runner-environment-lifecycle.js";
import { environmentService } from "./environments.js";
@@ -313,8 +314,17 @@ export function environmentRunOrchestrator(
localEnvironmentId: input.localEnvironmentId,
});
// The remote-sandbox warm default needs a provider that keeps reusable
// leases. Probe it only for a run the default can apply to; every other
// run resolves without it, exactly as before.
const reusableLeaseSupported = runnerLifecycleDefaultNeedsLeaseProbe(
selectedEnvironment, input.adapterType, input.adapterConfig, input.admittedLifecycleMode, input.managedAiCredentialIdentity,
) && await Promise.resolve(environmentRuntime.providerSupportsReusableLeases?.(selectedEnvironment)).then(
(supported) => supported === true, () => false);
const lifecycleDefaultContext = { reusableLeaseSupported, managedAiCredentialIdentity: input.managedAiCredentialIdentity };
const environment = resolveRunnerEnvironmentForRun(
selectedEnvironment, input.adapterType, input.adapterConfig, input.admittedLifecycleMode, input.managedAiCredentialIdentity,
lifecycleDefaultContext,
);
if (input.recoverPluginTask && environment.driver === "plugin" && input.adapterType === "paperclip_runner") {
@@ -333,7 +343,7 @@ export function environmentRunOrchestrator(
// Step 2: Acquire lease
const warmRunnerIdleTimeoutMs = resolveWarmRunnerIdleTimeoutMsForRun(
selectedEnvironment, input.adapterType, input.adapterConfig, input.admittedLifecycleMode,
selectedEnvironment, input.adapterType, input.adapterConfig, input.admittedLifecycleMode, lifecycleDefaultContext,
);
const runnerReconnectGraceMs = input.adapterType
? resolveRunnerReconnectGraceMsForRun(selectedEnvironment, input.adapterType)
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveByKey = vi.hoisted(() => vi.fn());
vi.mock("./plugin-environment-driver.js", async (importOriginal) => ({
...await importOriginal<typeof import("./plugin-environment-driver.js")>(),
resolvePluginSandboxProviderDriverByKey: resolveByKey,
}));
const { environmentRuntimeService } = await import("./environment-runtime.js");
const { REUSABLE_LEASE_WORKER_METHODS } = await import("./plugin-environment-driver.js");
function workerManager(supportedMethods: readonly string[]) {
return { getWorker: () => ({ supportedMethods }), isRunning: () => true } as never;
}
const sandbox = (provider: string) => ({ driver: "sandbox" as const, config: { provider } });
const plugin = (driver: Record<string, unknown>) => ({ plugin: { id: "plugin-1", status: "ready" }, driver: { driverKey: "daytona", kind: "sandbox_provider", ...driver } });
// The remote-sandbox warm default asks this before acquisition. It must match
// the acquisition rule, so the default never requests a lease it cannot reuse.
describe("sandbox provider reusable-lease probe", () => {
beforeEach(() => resolveByKey.mockReset());
it("reads false for the built-in fake provider, which has no reusable leases", async () => {
const runtime = environmentRuntimeService({} as never, { pluginWorkerManager: workerManager([]) });
await expect(runtime.providerSupportsReusableLeases(sandbox("fake"))).resolves.toBe(false);
expect(resolveByKey).not.toHaveBeenCalled();
});
it("reads true only for a running plugin that declares and verifies reusable leases", async () => {
const verified = environmentRuntimeService({} as never, { pluginWorkerManager: workerManager(REUSABLE_LEASE_WORKER_METHODS) });
resolveByKey.mockResolvedValue(plugin({ sandboxCapabilities: { reusableLeases: true } }));
await expect(verified.providerSupportsReusableLeases(sandbox("daytona"))).resolves.toBe(true);
expect(resolveByKey).toHaveBeenCalledWith(expect.objectContaining({ driverKey: "daytona", requireRunning: true }));
// A provider that does not declare reusable leases (for example E2B or Modal).
resolveByKey.mockResolvedValue(plugin({}));
await expect(verified.providerSupportsReusableLeases(sandbox("e2b"))).resolves.toBe(false);
// Declared, but the worker does not verify the reuse methods: acquisition would use an ephemeral lease.
const unverified = environmentRuntimeService({} as never, { pluginWorkerManager: workerManager(["environmentExecute"]) });
resolveByKey.mockResolvedValue(plugin({ sandboxCapabilities: { reusableLeases: true } }));
await expect(unverified.providerSupportsReusableLeases(sandbox("daytona"))).resolves.toBe(false);
// No running worker yet: no default, and no wait for one.
resolveByKey.mockResolvedValue(null);
await expect(verified.providerSupportsReusableLeases(sandbox("daytona"))).resolves.toBe(false);
});
it("reads false for a non-sandbox environment", async () => {
const runtime = environmentRuntimeService({} as never, { pluginWorkerManager: workerManager(REUSABLE_LEASE_WORKER_METHODS) });
await expect(runtime.providerSupportsReusableLeases({ driver: "local", config: {} })).resolves.toBe(false);
});
});
@@ -674,6 +674,13 @@ export interface EnvironmentRuntimeDriver {
): Promise<RunnerIngressEndpoint>;
/** True when the lease's plugin worker advertises both sync verbs. */
supportsSync?(input: EnvironmentDriverLeaseInput): boolean;
/**
* True when a new lease for this environment can be reusable: the provider
* declares reusable leases and, for a plugin, its running worker verifies the
* reuse methods. It uses the acquisition path's rule without waiting for a
* worker; a worker that is not running yet reads false.
*/
providerSupportsReusableLeases?(environment: Pick<Environment, "driver" | "config">): Promise<boolean>;
/**
* Resolve the effective eight-field capability snapshot for this driver and
* lease. Every driver implements this general method, so a caller never
@@ -2706,6 +2713,31 @@ function createSandboxEnvironmentDriver(
throw new Error("Sandbox driver does not support direct command execution for built-in providers.");
},
async providerSupportsReusableLeases(environment) {
let parsed: ReturnType<typeof parseEnvironmentDriverConfig>;
try {
parsed = parseEnvironmentDriverConfig(environment as Environment);
} catch {
return false; // Acquisition reports the invalid config; the default stays off.
}
if (parsed.driver !== "sandbox") return false;
const provider = parsed.config.provider;
if (isBuiltinSandboxProvider(provider)) {
return resolveDeclaredSandboxCapabilities({
supportsReusableLeases: getBuiltinSandboxProvider(provider)?.supportsReusableLeases,
}).reusableLeases === true;
}
if (!pluginWorkerManager) return false;
// Same rule as acquisition, without its worker-ready wait.
const running = await resolvePluginSandboxProviderDriverByKey({
db, driverKey: provider, workerManager: pluginWorkerManager, requireRunning: true,
});
if (!running) return false;
return resolveDeclaredSandboxCapabilities(running.driver).reusableLeases === true
&& capabilityIsVerified("reusableLeases",
new Set(pluginWorkerManager.getWorker(running.plugin.id)?.supportedMethods ?? []));
},
supportsSync(input) {
if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager) return false;
const pluginId = readString(input.lease.metadata?.pluginId);
@@ -4478,6 +4510,11 @@ export function environmentRuntimeService(
return driver?.supportsSync?.(input) ?? false;
},
/** See {@link EnvironmentRuntimeDriver.providerSupportsReusableLeases}; false for a driver without it. */
async providerSupportsReusableLeases(environment: Pick<Environment, "driver" | "config">): Promise<boolean> {
return await getDriver(environment.driver)?.providerSupportsReusableLeases?.(environment) ?? false;
},
/**
* Resolve the general per-lease capability snapshot through the driver's
* {@link EnvironmentRuntimeDriver.resolveCapabilities}. Every registered
@@ -36,6 +36,8 @@ describe("admitted native lifecycle policy", () => {
describe("default lifecycle for an agent that never chose one", () => {
const defaultWarm: NativeLifecyclePolicy = { mode: "warm", idleTimeoutMs: 300_000 };
const unset: Input = { ...base, agentMode: undefined, agentIdleTimeoutMs: undefined, localTarget: true };
const reusable = { reusableLease: true };
const ephemeral = { reusableLease: false };
it.each<{ name: string; change: Partial<Input>; expected: NativeLifecyclePolicy }>([
{ name: "local Codex keeps a warm runner for the default idle window", change: {}, expected: defaultWarm },
{ name: "local OpenCode is warm", change: { backend: "opencode_server" }, expected: defaultWarm },
@@ -45,13 +47,20 @@ describe("default lifecycle for an agent that never chose one", () => {
{ name: "an explicit warm idle window is honored", change: { agentMode: "warm", agentIdleTimeoutMs: 900_000 }, expected: warm },
{ name: "a remote target stays per-turn", change: { localTarget: false }, expected: perTurn },
// A remote sandbox follow-up otherwise gets a new sandbox and uploads the whole workspace.
{ name: "remote-sandbox Codex is warm", change: { localTarget: false, remoteSandboxTarget: true }, expected: defaultWarm },
{ name: "remote-sandbox OpenCode is warm", change: { localTarget: false, remoteSandboxTarget: true, backend: "opencode_server" }, expected: defaultWarm },
{ name: "remote-sandbox Claude is warm", change: { localTarget: false, remoteSandboxTarget: true, backend: "acpx_runtime", acpxAgent: "claude" }, expected: defaultWarm },
{ name: "a remote-sandbox unmeasured ACP harness stays per-turn", change: { localTarget: false, remoteSandboxTarget: true, backend: "acpx_runtime", acpxAgent: "grok" }, expected: perTurn },
{ name: "a remote-sandbox explicit per-turn choice is honored", change: { localTarget: false, remoteSandboxTarget: true, agentMode: "per_turn" }, expected: perTurn },
{ name: "a remote-sandbox environment that pins per-turn wins", change: { localTarget: false, remoteSandboxTarget: true, environmentPolicy: perTurn }, expected: perTurn },
{ name: "a remote-sandbox session admitted per-turn stays per-turn", change: { localTarget: false, remoteSandboxTarget: true, persistedPolicy: perTurn }, expected: perTurn },
{ name: "remote-sandbox Codex on a reusable lease is warm", change: { localTarget: false, remoteSandboxTarget: reusable }, expected: defaultWarm },
{ name: "remote-sandbox OpenCode on a reusable lease is warm", change: { localTarget: false, remoteSandboxTarget: reusable, backend: "opencode_server" }, expected: defaultWarm },
{ name: "remote-sandbox Claude on a reusable lease is warm", change: { localTarget: false, remoteSandboxTarget: reusable, backend: "acpx_runtime", acpxAgent: "claude" }, expected: defaultWarm },
// Rule 1: without a reusable lease (E2B, Modal, fake, or reuse unavailable) the run stays per-turn, as on master.
{ name: "remote-sandbox Codex without a reusable lease stays per-turn", change: { localTarget: false, remoteSandboxTarget: ephemeral }, expected: perTurn },
{ name: "remote-sandbox Claude without a reusable lease stays per-turn", change: { localTarget: false, remoteSandboxTarget: ephemeral, backend: "acpx_runtime", acpxAgent: "claude" }, expected: perTurn },
{ name: "a remote-sandbox unmeasured ACP harness stays per-turn", change: { localTarget: false, remoteSandboxTarget: reusable, backend: "acpx_runtime", acpxAgent: "grok" }, expected: perTurn },
// 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 },
{ 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 },
{ name: "remote-sandbox managed Codex, which supports a managed warm session, is warm", change: { localTarget: false, remoteSandboxTarget: reusable, managedAiSelected: true }, expected: defaultWarm },
{ name: "an unknown target stays per-turn", change: { localTarget: undefined }, expected: perTurn },
{ name: "an unmeasured ACP harness stays per-turn", change: { backend: "acpx_runtime", acpxAgent: "grok" }, expected: perTurn },
{ name: "managed Claude without warm support stays per-turn", change: { backend: "acpx_runtime", acpxAgent: "claude", managedAiSelected: true }, expected: perTurn },
@@ -1,29 +1,11 @@
import { PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS, resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
import { supportsManagedNativeWarmSession } from "../native-runtime/native-warm-capability.js";
import {
defaultsRemoteSandboxRunToWarm, defaultsToWarmFollowUp, supportsManagedNativeWarmSession,
} from "../native-runtime/native-warm-capability.js";
export type NativeLifecyclePolicy = NonNullable<NativeExecutionInput["session"]["lifecyclePolicy"]>;
/**
* Harnesses measured to start a follow-up turn faster on a live runner
* (doc/plans/2026-11-07-warm-follow-up-ttft.md). An agent that never chose a
* lifecycle gets warm for these on a local target. ACPX harnesses (Claude)
* relaunch their provider process on every follow-up turn by design, so warm
* does not shorten their follow-up and they stay per-turn.
*/
function defaultsToWarmLocally(backend: string): boolean {
return backend === "codex_app_server" || backend === "opencode_server";
}
/**
* On a remote sandbox the follow-up's dominant cost is a new sandbox and a full
* workspace upload (doc/plans/2026-11-08-large-workspace-speed.md), so Claude
* gains from warm there too: its provider relaunch is small next to the upload.
*/
export function defaultsToWarmOnRemoteSandbox(backend: string, acpxAgent: unknown): boolean {
return defaultsToWarmLocally(backend) || (backend === "acpx_runtime" && acpxAgent === "claude");
}
export function resolveHeartbeatNativeLifecyclePolicy(input: {
adapterType: string;
backend: Parameters<typeof supportsManagedNativeWarmSession>[0]["backend"];
@@ -37,22 +19,34 @@ export function resolveHeartbeatNativeLifecyclePolicy(input: {
taskOwned: boolean;
/** The run executes on this controller's host (no remote transport). */
localTarget?: boolean;
/** The run executes in a remote sandbox (not a plugin task). */
remoteSandboxTarget?: boolean;
/**
* 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.
*/
remoteSandboxTarget?: { reusableLease: boolean };
}): NativeLifecyclePolicy {
// An explicit choice wins. With none, a local measured harness keeps its
// runner and provider session for the default idle window so a follow-up
// turn skips the launch and session load.
const agentMode = input.agentMode === "warm" || input.agentMode === "per_turn" ? input.agentMode
: input.adapterType === "paperclip_runner" && ((input.localTarget === true && defaultsToWarmLocally(input.backend))
|| (input.remoteSandboxTarget === true && defaultsToWarmOnRemoteSandbox(input.backend, input.acpxAgent)))
? "warm" : "per_turn";
// 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
// whose provider keeps the lease (the rule the run's environment view uses).
const profile = { backend: input.backend, acpxAgent: input.acpxAgent };
const explicit = input.agentMode === "warm" || input.agentMode === "per_turn";
const localDefault = input.localTarget === true && defaultsToWarmFollowUp(profile, "local");
const remoteDefault = input.remoteSandboxTarget !== undefined && defaultsRemoteSandboxRunToWarm({
profile,
reusableLeaseSupported: input.remoteSandboxTarget.reusableLease,
managedAi: input.managedAiSelected ? { attribution: input.managedAiAttribution } : null,
});
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.
const agentPolicy: NativeLifecyclePolicy = agentMode === "warm"
? { mode: "warm", idleTimeoutMs: input.agentMode === "warm"
? { mode: "warm", idleTimeoutMs: explicit || remoteDefault
? resolvePaperclipRunnerIdleTimeoutMs(input.agentIdleTimeoutMs) : PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS }
: { mode: "per_turn", idleTimeoutMs: null };
const supportsManagedWarm = input.adapterType === "paperclip_runner" && (input.taskOwned ||
supportsManagedNativeWarmSession({ backend: input.backend, acpxAgent: input.acpxAgent }, input.managedAiAttribution));
supportsManagedNativeWarmSession(profile, input.managedAiAttribution));
return input.persistedPolicy ??
(["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"].includes(input.backend) || input.managedAiSelected && !supportsManagedWarm
? { mode: "per_turn", idleTimeoutMs: null }
@@ -765,6 +765,22 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () =
expect(await coordinators(run.id)).toHaveLength(1);
});
// The remote-sandbox warm default follows the acquired lease: it never turns a
// run that works per-turn into a refused warm run.
it.each([
["a reusable lease", { reusableLeaseConfigured: true, effectiveCapabilities: { reusableLeases: true } }, "warm", "keep_running"],
["reuse configured on a provider without reusable leases", { reusableLeaseConfigured: true, effectiveCapabilities: { reusableLeases: false } }, "per_turn", "destroy_after_turn"],
["an ephemeral lease", {}, "per_turn", "destroy_after_turn"],
] as const)("defaults an agent without a lifecycle on %s", async (_name, lease, lifecycleMode, sandboxResource) => {
const { input } = await fixture();
const { lifecycleMode: _unset, ...adapterConfig } = (input.agent.adapterConfig ?? {}) as Record<string, unknown>;
input.agent.adapterConfig = adapterConfig;
input.workspace.executionTarget = { ...sandbox, ...lease } as never;
input.beforeNativeRuntimeSelection = async () => { throw new Error("selection interrupted"); };
await expect(selectHeartbeatRuntime(db, input)).rejects.toThrow("selection interrupted");
expect(input.onNativeLifecycleSelected).toHaveBeenCalledWith({ provider: "codex", harness: "codex_app_server", lifecycleMode, sandboxResource });
});
it("rejects a warm native sandbox that cannot reuse its lease", async () => {
const { input, run } = await fixture();
input.workspace.executionTarget = { ...sandbox, runnerLifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 } };
@@ -170,6 +170,14 @@ export interface NativeSandboxLifecycle {
failoverBackup: "verified";
}
/** The acquired sandbox lease is reusable: configured, and supported by the provider. */
function sandboxTargetHasReusableLease(target: {
reusableLeaseConfigured?: boolean;
effectiveCapabilities?: { reusableLeases: boolean } | null;
}): boolean {
return target.reusableLeaseConfigured === true && target.effectiveCapabilities?.reusableLeases === true;
}
export function resolveReusableSandboxLifecycle(input: {
lifecyclePolicy:
| { mode: "per_turn"; idleTimeoutMs: null }
@@ -184,9 +192,7 @@ export function resolveReusableSandboxLifecycle(input: {
if (input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
return null;
}
const reusableLease =
input.target.reusableLeaseConfigured === true &&
input.target.effectiveCapabilities?.reusableLeases === true;
const reusableLease = sandboxTargetHasReusableLease(input.target);
if (input.lifecyclePolicy.mode === "warm" && !reusableLease) {
throw new Error("runner_warm_lifecycle_requires_reusable_provider_lease");
}
@@ -262,17 +268,19 @@ function readNonEmptyString(value: unknown): string | null {
function resolveRuntimeLifecyclePolicy(input: HeartbeatRuntimeSelectionInput): NativeLifecyclePolicy | null {
if (input.nativeRuntimeResolution.kind !== "native") return null;
const target = input.workspace.executionTarget;
const sandboxTarget = target?.kind === "remote" && target.transport === "sandbox" ? target : null;
return resolveHeartbeatNativeLifecyclePolicy({
adapterType: input.agent.adapterType, backend: input.nativeRuntimeResolution.profile.backend,
acpxAgent: parseObject(input.agent.adapterConfig).acpxAgent,
agentMode: parseObject(input.agent.adapterConfig).lifecycleMode, agentIdleTimeoutMs: parseObject(input.agent.adapterConfig).idleTimeoutMs,
environmentPolicy: input.workspace.executionTarget?.kind === "remote" && input.workspace.executionTarget.transport === "sandbox" ? input.workspace.executionTarget.runnerLifecyclePolicy : null,
environmentPolicy: sandboxTarget ? sandboxTarget.runnerLifecyclePolicy : null,
persistedPolicy: input.session.persistedNativeExecutionInput?.session.lifecyclePolicy,
managedAiSelected: Boolean(input.config.managedAiRuntime), managedAiAttribution: input.config.managedAiRuntime?.attribution,
taskOwned: Boolean(input.workspace.executionTarget?.kind === "remote" && input.workspace.executionTarget.transport === "sandbox" && input.workspace.executionTarget.runnerTask),
localTarget: input.workspace.executionTarget?.kind !== "remote",
remoteSandboxTarget: input.workspace.executionTarget?.kind === "remote" && input.workspace.executionTarget.transport === "sandbox"
&& !input.workspace.executionTarget.runnerTask,
taskOwned: Boolean(sandboxTarget?.runnerTask),
localTarget: target?.kind !== "remote",
remoteSandboxTarget: sandboxTarget && !sandboxTarget.runnerTask
? { reusableLease: sandboxTargetHasReusableLease(sandboxTarget) } : undefined,
});
}
@@ -18,3 +18,35 @@ export function supportsManagedNativeWarmSession(
(profile.backend === "acpx_runtime" && profile.acpxAgent === "copilot" &&
attribution?.provider === "github" && attribution.method === "api_key");
}
/**
* Harnesses measured to start a follow-up turn faster on a live runner
* (doc/plans/2026-11-07-warm-follow-up-ttft.md, doc/plans/2026-11-08-large-workspace-speed.md).
* Locally, ACPX harnesses (Claude) relaunch their provider process on every
* follow-up turn, so warm does not shorten their follow-up. On a remote sandbox
* the dominant cost is a new sandbox and a full workspace upload, so Claude
* gains from warm there too.
*/
export function defaultsToWarmFollowUp(profile: WarmProviderSelection, target: "local" | "remote_sandbox"): boolean {
if (profile.backend === "codex_app_server" || profile.backend === "opencode_server") return true;
return target === "remote_sandbox" && profile.backend === "acpx_runtime" && profile.acpxAgent === "claude";
}
/**
* The one rule for the remote-sandbox warm default. The run-scoped environment
* view (lease retention) and the native session policy (runner retention) both
* call it, and apply it only when no lifecycle was chosen anywhere (admitted
* session, environment, agent). It holds only when the sandbox provider keeps
* reusable leases, so a default never turns a run that works per-turn into a
* refused warm run, and only when a selected managed AI account supports a
* warm session.
*/
export function defaultsRemoteSandboxRunToWarm(input: {
profile: WarmProviderSelection;
reusableLeaseSupported: boolean;
managedAi: { attribution?: { provider: string; method: string } } | null;
}): boolean {
return input.reusableLeaseSupported
&& defaultsToWarmFollowUp(input.profile, "remote_sandbox")
&& (input.managedAi === null || supportsManagedNativeWarmSession(input.profile, input.managedAi.attribution));
}
@@ -1,5 +1,5 @@
import {expect,it} from "vitest";
import {resolveRunnerEnvironmentForRun,resolveWarmRunnerIdleTimeoutMsForRun} from "./runner-environment-lifecycle.js";
import {describe,expect,it} from "vitest";
import {resolveRunnerEnvironmentForRun,resolveWarmRunnerIdleTimeoutMsForRun,runnerLifecycleDefaultNeedsLeaseProbe} from "./runner-environment-lifecycle.js";
it.each([undefined,"codex","acpx","opencode"])("defaults plugin Runner execution to warm without provider gates: %s",provider=>{
const environment={driver:"plugin" as const,config:{pluginKey:"fixture",driverKey:"tasks",driverConfig:{}}};
@@ -31,20 +31,65 @@ it("forwards the run's warm idle window for a warm sandbox-provider run only",()
expect(resolveWarmRunnerIdleTimeoutMsForRun({driver:"plugin" as const,config:{}},"paperclip_runner",{lifecycleMode:"warm"})).toBeUndefined();
});
it.each([
[{ provider: "codex" }, true], [{ provider: "opencode" }, true], [{ provider: "acpx", acpxAgent: "claude" }, true],
[{ provider: "acpx", acpxAgent: "grok" }, false], [{ provider: "codex", lifecycleMode: "per_turn" }, false], [{}, false],
] as const)("defaults a measured harness on an inheriting sandbox environment to a reusable warm sandbox: %j", (adapterConfig, warm) => {
const sandbox = { driver: "sandbox" as const, config: { provider: "daytona", reuseLease: false } };
const resolved = resolveRunnerEnvironmentForRun(sandbox, "paperclip_runner", adapterConfig);
expect(resolved.config.reuseLease).toBe(warm);
expect(resolveWarmRunnerIdleTimeoutMsForRun(sandbox, "paperclip_runner", adapterConfig)).toBe(warm ? 300_000 : undefined);
// The shared environment itself is never rewritten.
expect(sandbox.config.reuseLease).toBe(false);
});
it("keeps an environment that pins per-turn, and an admitted per-turn recovery, per-turn", () => {
const pinned = { driver: "sandbox" as const, config: { provider: "daytona", runnerLifecycleMode: "per_turn" } };
expect(resolveRunnerEnvironmentForRun(pinned, "paperclip_runner", { provider: "codex" })).toBe(pinned);
const inheriting = { driver: "sandbox" as const, config: { provider: "daytona" } };
expect(resolveRunnerEnvironmentForRun(inheriting, "paperclip_runner", { provider: "codex" }, "per_turn")).toBe(inheriting);
describe("remote-sandbox warm default", () => {
const inheriting = { driver: "sandbox" as const, config: { provider: "daytona", reuseLease: false } };
const supported = { reusableLeaseSupported: true };
const opencode = { provider: "opencode", model: "openai/gpt-5" };
const claude = { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" };
it.each([
["Codex", { provider: "codex" }, true],
// Rule 3: an omitted provider resolves to Codex, as the runtime resolves it.
["an omitted provider (Codex)", {}, true],
["OpenCode", opencode, true],
["ACPX Claude", claude, true],
["an unmeasured ACPX harness", { provider: "acpx", acpxAgent: "grok", model: "grok-4" }, false],
["a config the runtime refuses", { provider: "opencode" }, false],
] as const)("%s on a provider with reusable leases: warm=%s", (_name, adapterConfig, warm) => {
const resolved = resolveRunnerEnvironmentForRun(inheriting, "paperclip_runner", adapterConfig, undefined, undefined, supported);
expect(resolved.config.reuseLease).toBe(warm);
expect(resolveWarmRunnerIdleTimeoutMsForRun(inheriting, "paperclip_runner", adapterConfig, undefined, supported))
.toBe(warm ? 300_000 : undefined);
expect(runnerLifecycleDefaultNeedsLeaseProbe(inheriting, "paperclip_runner", adapterConfig)).toBe(warm);
// The shared environment itself is never rewritten.
expect(inheriting.config.reuseLease).toBe(false);
});
// Rule 1: a provider without reusable leases (E2B, Modal, fake) keeps master's per-turn behavior.
it.each([{ provider: "codex" }, {}, claude])("keeps %j per-turn on a provider without reusable leases", (adapterConfig) => {
for (const context of [{}, { reusableLeaseSupported: false }]) {
expect(resolveRunnerEnvironmentForRun(inheriting, "paperclip_runner", adapterConfig, undefined, undefined, context)).toBe(inheriting);
expect(resolveWarmRunnerIdleTimeoutMsForRun(inheriting, "paperclip_runner", adapterConfig, undefined, context)).toBeUndefined();
}
});
// Rule 2: explicit configuration wins and needs no probe.
it("honors explicit lifecycles and the configured idle window", () => {
const pinned = { driver: "sandbox" as const, config: { provider: "daytona", runnerLifecycleMode: "per_turn" } };
expect(resolveRunnerEnvironmentForRun(pinned, "paperclip_runner", { provider: "codex" }, undefined, undefined, supported)).toBe(pinned);
expect(runnerLifecycleDefaultNeedsLeaseProbe(pinned, "paperclip_runner", { provider: "codex" })).toBe(false);
expect(resolveRunnerEnvironmentForRun(inheriting, "paperclip_runner", { provider: "codex", lifecycleMode: "per_turn" }, undefined, undefined, supported)).toBe(inheriting);
expect(runnerLifecycleDefaultNeedsLeaseProbe(inheriting, "paperclip_runner", { lifecycleMode: "per_turn" })).toBe(false);
expect(resolveRunnerEnvironmentForRun(inheriting, "paperclip_runner", { provider: "codex" }, "per_turn", undefined, supported)).toBe(inheriting);
expect(runnerLifecycleDefaultNeedsLeaseProbe(inheriting, "paperclip_runner", {}, "per_turn")).toBe(false);
expect(resolveWarmRunnerIdleTimeoutMsForRun(inheriting, "paperclip_runner", { idleTimeoutMs: 900_000 }, undefined, supported)).toBe(900_000);
expect(resolveWarmRunnerIdleTimeoutMsForRun({ ...inheriting, config: { ...inheriting.config, runnerIdleTimeoutMs: 600_000 } },
"paperclip_runner", { idleTimeoutMs: 900_000 }, undefined, supported)).toBe(600_000);
});
// Rule 2: a managed AI account without a warm session gets no default; managed Codex supports one.
it("defaults managed AI runs only when the harness supports a managed warm session", () => {
expect(resolveRunnerEnvironmentForRun(inheriting, "paperclip_runner", claude, undefined, "account:generation", supported)).toBe(inheriting);
expect(runnerLifecycleDefaultNeedsLeaseProbe(inheriting, "paperclip_runner", claude, undefined, "account:generation")).toBe(false);
expect(resolveWarmRunnerIdleTimeoutMsForRun(inheriting, "paperclip_runner", claude, undefined,
{ ...supported, managedAiCredentialIdentity: "account:generation" })).toBeUndefined();
expect(resolveRunnerEnvironmentForRun(inheriting, "paperclip_runner", {}, undefined, "account:generation", supported).config.reuseLease).toBe(true);
});
it("never applies to plugin tasks or other adapters", () => {
expect(runnerLifecycleDefaultNeedsLeaseProbe({ driver: "plugin", config: {} }, "paperclip_runner", {})).toBe(false);
expect(runnerLifecycleDefaultNeedsLeaseProbe(inheriting, "codex_local", {})).toBe(false);
expect(resolveRunnerEnvironmentForRun(inheriting, "codex_local", {}, undefined, undefined, supported)).toBe(inheriting);
});
});
@@ -1,28 +1,80 @@
import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
import type { Environment } from "@paperclipai/shared";
import { REMOTE_RUNNER_RECONNECT_GRACE_MS } from "./native-runtime/native-runner-recovery-contract.js";
import { defaultsRemoteSandboxRunToWarm } from "./native-runtime/native-warm-capability.js";
import { resolvePaperclipRunnerProviderProfile } from "./native-runtime/provider-profile.js";
/**
* The same default as the native session policy: an agent that never chose a
* lifecycle, in an environment that inherits it, runs a measured harness warm
* on a sandbox, so its follow-up keeps the sandbox and workspace instead of
* uploading the workspace into a new one.
*/
function defaultsToWarmOnSandbox(adapterConfig: Record<string, unknown>): boolean {
const provider = adapterConfig.provider;
return provider === "codex" || provider === "opencode" || (provider === "acpx" && adapterConfig.acpxAgent === "claude");
/** Facts the remote-sandbox warm default needs beyond the configuration. */
export interface RunnerLifecycleDefaultContext {
/** The sandbox provider keeps reusable leases (probed before acquisition). */
reusableLeaseSupported?: boolean;
/** The run uses a managed AI account. */
managedAiCredentialIdentity?: string;
}
function resolveRunnerLifecycleModeForRun(
function explicitLifecycleMode(
config: Record<string, unknown>,
adapterConfig: Record<string, unknown>,
admittedLifecycleMode?: "warm" | "per_turn",
driver?: string,
): unknown {
): "warm" | "per_turn" | undefined {
if (admittedLifecycleMode) return admittedLifecycleMode;
if (config.runnerLifecycleMode === "warm" || config.runnerLifecycleMode === "per_turn") return config.runnerLifecycleMode;
if (adapterConfig.lifecycleMode === "warm" || adapterConfig.lifecycleMode === "per_turn") return adapterConfig.lifecycleMode;
return driver === "sandbox" && defaultsToWarmOnSandbox(adapterConfig) ? "warm" : adapterConfig.lifecycleMode;
return undefined;
}
/**
* The remote-sandbox warm default for a run whose lifecycle nobody chose,
* through the same rule as the native session policy. The harness resolves
* exactly as the runtime resolves it (an omitted provider is Codex); a config
* the runtime would refuse gets no default here.
*/
function sandboxRunDefaultsToWarm(
adapterConfig: Record<string, unknown>,
context: RunnerLifecycleDefaultContext,
): boolean {
let profile: ReturnType<typeof resolvePaperclipRunnerProviderProfile>;
try {
profile = resolvePaperclipRunnerProviderProfile(adapterConfig);
} catch {
return false;
}
return defaultsRemoteSandboxRunToWarm({
profile: { backend: profile.backend, acpxAgent: "acpxAgent" in profile ? profile.acpxAgent : undefined },
reusableLeaseSupported: context.reusableLeaseSupported === true,
// The attribution is not known here; a harness that needs it for a managed
// warm session (Copilot) is not a measured default, so this is exact.
managedAi: context.managedAiCredentialIdentity ? {} : null,
});
}
function resolveRunnerLifecycleModeForRun(
environment: Pick<Environment, "driver" | "config">,
adapterConfig: Record<string, unknown>,
admittedLifecycleMode: "warm" | "per_turn" | undefined,
context: RunnerLifecycleDefaultContext,
): unknown {
const config = environment.config ?? {};
const explicit = explicitLifecycleMode(config, adapterConfig, admittedLifecycleMode);
if (explicit) return explicit;
if (environment.driver === "sandbox" && sandboxRunDefaultsToWarm(adapterConfig, context)) return "warm";
return adapterConfig.lifecycleMode;
}
/**
* True when only the provider's reusable-lease support is left to decide this
* run's sandbox lifecycle, so the caller probes it only for these runs.
*/
export function runnerLifecycleDefaultNeedsLeaseProbe(
environment: Pick<Environment, "driver" | "config">,
adapterType: string,
adapterConfig: Record<string, unknown> = {},
admittedLifecycleMode?: "warm" | "per_turn",
managedAiCredentialIdentity?: string,
): boolean {
if (adapterType !== "paperclip_runner" || environment.driver !== "sandbox") return false;
if (explicitLifecycleMode(environment.config ?? {}, adapterConfig, admittedLifecycleMode)) return false;
return sandboxRunDefaultsToWarm(adapterConfig, { reusableLeaseSupported: true, managedAiCredentialIdentity });
}
/** Resolve runner retention before acquiring a sandbox, not after it is ephemeral.
@@ -35,10 +87,12 @@ export function resolveRunnerEnvironmentForRun<T extends Pick<Environment, "driv
adapterConfig: Record<string, unknown> = {},
admittedLifecycleMode?: "warm" | "per_turn",
managedAiCredentialIdentity?: string,
defaultContext: Pick<RunnerLifecycleDefaultContext, "reusableLeaseSupported"> = {},
): T {
if (adapterType !== "paperclip_runner" || !["sandbox", "plugin"].includes(environment.driver)) return environment;
const config = environment.config ?? {};
const lifecycleMode = resolveRunnerLifecycleModeForRun(config, adapterConfig, admittedLifecycleMode, environment.driver);
const lifecycleMode = resolveRunnerLifecycleModeForRun(environment, adapterConfig, admittedLifecycleMode,
{ ...defaultContext, managedAiCredentialIdentity });
// Warm tasks need a managed credential identity; recovery keeps its admitted policy.
if (environment.driver === "plugin") return { ...environment, config: { ...config,
runnerLifecycleMode: admittedLifecycleMode ?? (managedAiCredentialIdentity && lifecycleMode !== "per_turn" ? "warm" : "per_turn"),
@@ -66,10 +120,11 @@ export function resolveWarmRunnerIdleTimeoutMsForRun(
adapterType: string,
adapterConfig: Record<string, unknown> = {},
admittedLifecycleMode?: "warm" | "per_turn",
defaultContext: RunnerLifecycleDefaultContext = {},
): number | undefined {
if (adapterType !== "paperclip_runner" || environment.driver !== "sandbox") return undefined;
const config = environment.config ?? {};
if (resolveRunnerLifecycleModeForRun(config, adapterConfig, admittedLifecycleMode, environment.driver) !== "warm") return undefined;
if (resolveRunnerLifecycleModeForRun(environment, adapterConfig, admittedLifecycleMode, defaultContext) !== "warm") return undefined;
return resolvePaperclipRunnerIdleTimeoutMs(config.runnerIdleTimeoutMs ?? adapterConfig.idleTimeoutMs);
}