diff --git a/doc/plans/2026-11-08-large-workspace-speed.md b/doc/plans/2026-11-08-large-workspace-speed.md index 53cbac3377d..bbab3aae9a5 100644 --- a/doc/plans/2026-11-08-large-workspace-speed.md +++ b/doc/plans/2026-11-08-large-workspace-speed.md @@ -48,9 +48,10 @@ Run-log reuse records show `environment: fresh/created` on every follow-up. ## The default change - **Scope.** A sandbox environment that inherits the lifecycle (`runnerLifecycleMode` unset), and an agent with no stored lifecycle. Covers Codex and OpenCode, as locally, plus Claude: on a sandbox the upload, not the provider relaunch, dominates its follow-up. -- **Mechanics.** Warm on a sandbox already turns on `reuseLease`. The follow-up keeps the live sandbox and runner and adopts the workspace. +- **Gate.** One rule (`defaultsRemoteSandboxRunToWarm`) decides it for both the lease and the runner. It applies only when the sandbox provider keeps reusable leases (Daytona does; E2B, Modal and the built-in `fake` provider do not), and with a managed AI account only when that harness supports a managed warm session (Codex). Otherwise the run is per-turn, exactly as before. The lease side asks the provider before acquisition; the runner side reads the acquired lease, so a default never produces a refused warm run. +- **Mechanics.** Warm on a sandbox turns on `reuseLease` for the run. The follow-up keeps the live sandbox and runner and adopts the workspace. - **What does not change.** Explicit choices, environments that pin a lifecycle, plugin-task environments and admitted sessions keep their policy. -- **Cost.** An idle sandbox stays up for the warm idle window (5 minutes). After that the controller suspends the runner, and the provider auto-stop follows the same window. +- **Cost.** An idle sandbox stays up for the warm idle window (the agent's `idleTimeoutMs`, 5 minutes by default). After that the controller suspends the runner, and the provider auto-stop follows the same window. ## Gaps diff --git a/server/src/services/heartbeat/runtime-selection.test.ts b/server/src/services/heartbeat/runtime-selection.test.ts index 4e7b546cb61..ea595c7fc55 100644 --- a/server/src/services/heartbeat/runtime-selection.test.ts +++ b/server/src/services/heartbeat/runtime-selection.test.ts @@ -26,6 +26,7 @@ import { resolveHeartbeatNativeRuntimeMode } from "../native-runtime/runtime-mod import { claimNativeRestartRecoveries } from "../native-runtime/native-restart-recovery.js"; import { buildNativeModelEnvelope, canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js"; import { prepareAndSelectHeartbeatRuntime, selectHeartbeatRuntime, type HeartbeatRuntimeSelectionInput } from "./runtime-selection.js"; +import { resolveRunnerEnvironmentForRun } from "../runner-environment-lifecycle.js"; import { reserveRunBudget } from "../budget-reservations.js"; import { legacyControllerClaim } from "../legacy-controller-lease.js"; import { agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js"; @@ -781,6 +782,47 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () = expect(input.onNativeLifecycleSelected).toHaveBeenCalledWith({ provider: "codex", harness: "codex_app_server", lifecycleMode, sandboxResource }); }); + // The environment view (lease) and the native policy (runner) must make the + // same remote-sandbox decision for every harness, including a provider-less + // Codex agent and ACPX without an agent (Claude). + it.each([ + [{ provider: "codex", model: "gpt-5.6-luna" }, "warm"], + [{ model: "gpt-5.6-luna" }, "warm"], + [{ provider: "opencode", model: "openai/gpt-5" }, "warm"], + [{ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" }, "warm"], + [{ provider: "acpx", model: "claude-sonnet-4-6" }, "warm"], + [{ provider: "acpx", acpxAgent: "copilot", model: "gpt-5" }, "per_turn"], + ] as const)("agrees with the environment view's lease for %j", async (adapterConfig, mode) => { + const { input, run, agent, issue } = await fixture(); + input.agent.adapterConfig = adapterConfig; + input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: run, enabled: true, runtimeConfig: {}, adapterConfig, agent, issue, target: null, workspaceId: null }); + const view = resolveRunnerEnvironmentForRun({ driver: "sandbox" as const, config: { provider: "daytona" } as Record }, "paperclip_runner", adapterConfig, + undefined, undefined, { reusableLeaseSupported: true }); + expect(view.config.reuseLease === true).toBe(mode === "warm"); + input.workspace.executionTarget = { ...sandbox, reusableLeaseConfigured: view.config.reuseLease === true, effectiveCapabilities: { reusableLeases: true } } as never; + const stop = new Error("policy captured"); + const policies: unknown[] = []; + await expect(prepareAndSelectHeartbeatRuntime(db, input, { + measure: async (_phase, work) => work(), + prepareInstructions: async (policy) => { policies.push(policy?.mode); throw stop; }, + })).rejects.toBe(stop); + expect(policies).toEqual([mode]); + }); + + it("keeps a plugin task's sandbox target out of the remote default", async () => { + const { input } = await fixture(); + const { lifecycleMode: _unset, ...adapterConfig } = (input.agent.adapterConfig ?? {}) as Record; + input.agent.adapterConfig = adapterConfig; + input.workspace.executionTarget = { ...sandbox, reusableLeaseConfigured: true, effectiveCapabilities: { reusableLeases: true }, runnerTask: vi.fn() } as never; + const stop = new Error("policy captured"); + const policies: unknown[] = []; + await expect(prepareAndSelectHeartbeatRuntime(db, input, { + measure: async (_phase, work) => work(), + prepareInstructions: async (policy) => { policies.push(policy?.mode); throw stop; }, + })).rejects.toBe(stop); + expect(policies).toEqual(["per_turn"]); + }); + 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 c23cc8d1939..8926a17b694 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -17,6 +17,7 @@ import { isConversation } from "../agent-conversations.js"; import { renewLegacyControllerLease } from "../legacy-controller-lease.js"; import type { prepareManagedAiRuntime } from "../ai-connection-runtime.js"; import { resolveHeartbeatNativeLifecyclePolicy, type NativeLifecyclePolicy } from "./native-lifecycle.js"; +import { resolveWarmProviderSelection } from "../native-runtime/native-warm-capability.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext, @@ -270,9 +271,16 @@ function resolveRuntimeLifecyclePolicy(input: HeartbeatRuntimeSelectionInput): N if (input.nativeRuntimeResolution.kind !== "native") return null; const target = input.workspace.executionTarget; const sandboxTarget = target?.kind === "remote" && target.transport === "sandbox" ? target : null; + const backend = input.nativeRuntimeResolution.profile.backend; + const rawAcpxAgent = parseObject(input.agent.adapterConfig).acpxAgent; + // The environment view resolves the harness through the runtime's resolver + // (ACPX without an agent is Claude); use the same harness here so the lease + // and the runner decisions agree. + const resolved = backend === "acpx_runtime" && rawAcpxAgent === undefined + ? resolveWarmProviderSelection(input.agent.adapterConfig, input.agent.companyId) : null; return resolveHeartbeatNativeLifecyclePolicy({ - adapterType: input.agent.adapterType, backend: input.nativeRuntimeResolution.profile.backend, - acpxAgent: parseObject(input.agent.adapterConfig).acpxAgent, + adapterType: input.agent.adapterType, backend, + acpxAgent: resolved?.backend === backend ? resolved.acpxAgent : rawAcpxAgent, agentMode: parseObject(input.agent.adapterConfig).lifecycleMode, agentIdleTimeoutMs: parseObject(input.agent.adapterConfig).idleTimeoutMs, environmentPolicy: sandboxTarget ? sandboxTarget.runnerLifecyclePolicy : null, persistedPolicy: input.session.persistedNativeExecutionInput?.session.lifecyclePolicy, diff --git a/server/src/services/native-runtime/native-warm-capability.ts b/server/src/services/native-runtime/native-warm-capability.ts index 66dcc7294ad..9c1408976af 100644 --- a/server/src/services/native-runtime/native-warm-capability.ts +++ b/server/src/services/native-runtime/native-warm-capability.ts @@ -1,7 +1,21 @@ -import type { PaperclipRunnerProviderProfile } from "./provider-profile.js"; +import { resolvePaperclipRunnerProviderProfile, type PaperclipRunnerProviderProfile } from "./provider-profile.js"; type WarmProviderSelection = { backend: PaperclipRunnerProviderProfile["backend"]; acpxAgent?: unknown }; +/** + * The harness an agent config selects, resolved exactly as the runtime resolves + * it (an omitted provider is Codex; ACPX without an agent is Claude). Null when + * the runtime would refuse the config. + */ +export function resolveWarmProviderSelection(adapterConfig: unknown, companyId?: string): WarmProviderSelection | null { + try { + const profile = resolvePaperclipRunnerProviderProfile(adapterConfig, { companyId }); + return { backend: profile.backend, acpxAgent: profile.provider === "acpx" ? profile.acpxAgent : undefined }; + } catch { + return null; + } +} + /** Providers with a session-owned home and the current instruction-grant handoff. */ export function supportsNativeWarmFiles(profile: WarmProviderSelection): boolean { return profile.backend === "codex_app_server" || diff --git a/server/src/services/runner-environment-lifecycle.ts b/server/src/services/runner-environment-lifecycle.ts index 398e2672fb4..30b63b9cb48 100644 --- a/server/src/services/runner-environment-lifecycle.ts +++ b/server/src/services/runner-environment-lifecycle.ts @@ -1,8 +1,7 @@ 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"; +import { defaultsRemoteSandboxRunToWarm, resolveWarmProviderSelection } from "./native-runtime/native-warm-capability.js"; /** Facts the remote-sandbox warm default needs beyond the configuration. */ export interface RunnerLifecycleDefaultContext { @@ -33,14 +32,10 @@ function sandboxRunDefaultsToWarm( adapterConfig: Record, context: RunnerLifecycleDefaultContext, ): boolean { - let profile: ReturnType; - try { - profile = resolvePaperclipRunnerProviderProfile(adapterConfig); - } catch { - return false; - } + const profile = resolveWarmProviderSelection(adapterConfig); + if (!profile) return false; return defaultsRemoteSandboxRunToWarm({ - profile: { backend: profile.backend, acpxAgent: "acpxAgent" in profile ? profile.acpxAgent : undefined }, + profile, 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.