From db0e1b4dba77a9986fdd04dbe198f8c793e9d3bc Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Nov 2026 10:44:12 -0600 Subject: [PATCH] fix(native): resolve the warm-default harness once for both decisions The native policy now resolves ACPX without an agent to Claude, through the same resolver as the environment view, so the lease and the runner agree. Adds runtime-selection tests that feed the environment view's lease into selection for every measured harness (including a provider-less Codex agent) and keep plugin-task targets out of the remote default, and documents the gate in the plan. Co-Authored-By: Claude --- doc/plans/2026-11-08-large-workspace-speed.md | 5 ++- .../heartbeat/runtime-selection.test.ts | 42 +++++++++++++++++++ .../services/heartbeat/runtime-selection.ts | 12 +++++- .../native-runtime/native-warm-capability.ts | 16 ++++++- .../services/runner-environment-lifecycle.ts | 13 ++---- 5 files changed, 74 insertions(+), 14 deletions(-) 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.