mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user