diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index c922db7ef04..5b7629cd36e 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -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], diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 2cc5c05d702..62a8310a482 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -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) diff --git a/server/src/services/environment-runtime-reusable-lease-probe.test.ts b/server/src/services/environment-runtime-reusable-lease-probe.test.ts new file mode 100644 index 00000000000..f8c5827c46b --- /dev/null +++ b/server/src/services/environment-runtime-reusable-lease-probe.test.ts @@ -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(), + 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) => ({ 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); + }); +}); diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index c401e326606..fa4b8e0cf29 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -674,6 +674,13 @@ export interface EnvironmentRuntimeDriver { ): Promise; /** 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): Promise; /** * 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; + 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): Promise { + return await getDriver(environment.driver)?.providerSupportsReusableLeases?.(environment) ?? false; + }, + /** * Resolve the general per-lease capability snapshot through the driver's * {@link EnvironmentRuntimeDriver.resolveCapabilities}. Every registered diff --git a/server/src/services/heartbeat/native-lifecycle.test.ts b/server/src/services/heartbeat/native-lifecycle.test.ts index f8b0b28b1ff..1a6e5540d49 100644 --- a/server/src/services/heartbeat/native-lifecycle.test.ts +++ b/server/src/services/heartbeat/native-lifecycle.test.ts @@ -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; 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 }, diff --git a/server/src/services/heartbeat/native-lifecycle.ts b/server/src/services/heartbeat/native-lifecycle.ts index 0610c9111b6..b23365a6884 100644 --- a/server/src/services/heartbeat/native-lifecycle.ts +++ b/server/src/services/heartbeat/native-lifecycle.ts @@ -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; -/** - * 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[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 } diff --git a/server/src/services/heartbeat/runtime-selection.test.ts b/server/src/services/heartbeat/runtime-selection.test.ts index 381c4c329ab..4e7b546cb61 100644 --- a/server/src/services/heartbeat/runtime-selection.test.ts +++ b/server/src/services/heartbeat/runtime-selection.test.ts @@ -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; + 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 } }; diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 01d257e10b5..c23cc8d1939 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -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, }); } diff --git a/server/src/services/native-runtime/native-warm-capability.ts b/server/src/services/native-runtime/native-warm-capability.ts index 48d015698ca..66dcc7294ad 100644 --- a/server/src/services/native-runtime/native-warm-capability.ts +++ b/server/src/services/native-runtime/native-warm-capability.ts @@ -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)); +} diff --git a/server/src/services/runner-environment-lifecycle.test.ts b/server/src/services/runner-environment-lifecycle.test.ts index eb6dbe0e396..aa8b011a731 100644 --- a/server/src/services/runner-environment-lifecycle.test.ts +++ b/server/src/services/runner-environment-lifecycle.test.ts @@ -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); + }); }); + diff --git a/server/src/services/runner-environment-lifecycle.ts b/server/src/services/runner-environment-lifecycle.ts index 112d5fa822d..398e2672fb4 100644 --- a/server/src/services/runner-environment-lifecycle.ts +++ b/server/src/services/runner-environment-lifecycle.ts @@ -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): 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, adapterConfig: Record, 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, + context: RunnerLifecycleDefaultContext, +): boolean { + let profile: ReturnType; + 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, + adapterConfig: Record, + 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, + adapterType: string, + adapterConfig: Record = {}, + 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 = {}, admittedLifecycleMode?: "warm" | "per_turn", managedAiCredentialIdentity?: string, + defaultContext: Pick = {}, ): 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 = {}, 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); }