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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude committed 2026-11-10 10:44:12 -06:00
1 parent 436bdde3ca
commit db0e1b4dba
5 files changed
+74 -14

No files matched your search

@@ -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
@@ -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<string, unknown> }, "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<string, unknown>;
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 } };
@@ -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,
@@ -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" ||
@@ -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<string, unknown>,
context: RunnerLifecycleDefaultContext,
): boolean {
let profile: ReturnType<typeof resolvePaperclipRunnerProviderProfile>;
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.