fix: share native lifecycle policy with instruction checkpoints (#19230)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Warm native Codex turns save registered agent files at each turn
boundary.
> - Native execution can inherit warm mode from the agent when the
environment has no explicit policy.
> - File preparation checked only the environment policy and could miss
that inheritance.
> - The new copy then had no warm checkpoint callback, even though the
native session stayed warm.
> - This pull request resolves one admitted policy before both owners
use it.
> - The benefit is a terminal file checkpoint under the selected
lifecycle, with the existing ownership and permission fences.

## Linked Issues or Issue Description

Refs #14735 and #19225. The first defines warm file checkpoints. The
second retains coordinator ownership while cleanup joins. This change
fixes the earlier lifecycle selection mismatch.

**What happened?**

With no explicit environment policy and an agent warm default, native
selection chose warm mode with a 900,000 ms idle window. Actual file
preparation registered an agent_files copy without its warm flag or
checkpoint callback. An unchanged turn could have no save marker. Later
retirement of a retained lease could reach the stopped-file retrieval
guard and preserve pending collection debt.

**Expected behavior**

File preparation and native execution use the same admitted lifecycle
policy. Warm native Codex copies receive their terminal checkpoint
callback. Explicit per-turn overrides and pinned admissions keep their
existing precedence. A pending historical copy does not become saved
because policy resolution changed.

**Steps to reproduce**

1. Configure a native Codex agent with a warm default and a reusable
sandbox without an explicit runner lifecycle policy.
2. Prepare its managed agent-file copy through the normal instruction
owner.
3. Select native execution through the normal runtime owner.
4. Compare the selected warm policy with the copy's missing warm flag
and checkpoint callback.

**Paperclip version or commit**

Base: `3a090b815b`. The owning policy code also exists on its parent
revision.

**Deployment mode**

Native Codex with registered agent files and a reusable sandbox that
inherits its lifecycle policy.

## What Changed

- Move the existing lifecycle precedence and backend gates into a narrow
shared resolver.
- Resolve one value before instruction preparation and pass it into
runtime selection.
- Select warm file ownership from that value and the existing file
capability gate.
- Add real-Postgres unchanged and changed terminal checkpoint cases,
policy precedence cases and updated preparation coverage.
- Exercise the same resolve, timed preparation and runtime-selection
orchestration used by heartbeat; preserve preparation error identity and
skip selection on preparation failure.
- Document new-admission overrides and pinned-policy behavior.

## Verification

- Red: actual preparation and runtime-selection producers disagree. The
selected native mode is warm, but the real persisted copy has no warm
flag. The fixture uses real PostgreSQL, managed-root authorization and a
synthetic loopback transport.
- Green: 85 owner tests pass. They include actual unchanged and changed
terminal checkpoints plus 19 lifecycle hierarchy/capability cases.
Changed synthetic bytes reach canonical storage with a saved receipt;
unchanged bytes record unchanged.
- Safety: 25 existing warm retirement and
stopped/destroyed/foreign-owner cases pass. Six authorization,
stale-adoption and adopted-owner preservation cases pass. The lease
guard and native executor are unchanged.
- Workspace typecheck, build and map checks pass. The final candidate's
code and test blobs equal the tested source; its exact build stamp is
recorded locally.
- No new repository-wide local run was repeated. A separate earlier
broad command on an older head failed a reproducible GitHub enrollment
fixture; it is not a full local pass, established flake or proven caused
by this change. Fresh full hosted CI is green on the reviewed final
head; both current-head Greptile and native substantive reviews are 5/5
with no actionable findings.
- Review regression: removing actual policy propagation to preparation
in the shared production orchestration fails both real-Postgres cases
while runtime selection still chooses warm mode. Restoring it passes.
These cases invoke the production helper used by heartbeat, not the
entire outer heartbeat service.
- The provider-facing transport is a local loopback fixture. No model
turn, actual remote platform, physical stop receipt or deployed recovery
is proved by these tests.

## Risks

- New Codex copies that inherit agent warm mode now use the existing
terminal checkpoint path. This can add the expected file checkpoint
where only an unchanged probe ran before.
- The resolver preserves explicit environment per-turn overrides, pinned
admissions and backend/credential gates. It does not create a timer,
provider retry or new permission.
- Existing pending copies remain pending. This change does not authorize
retrieval from stopped or replaced leases and does not establish
recovery of historical files.
- No database migration, public API field, agent setting, provider
change or manual rollout is included.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, with reasoning, source inspection and local
code execution. Context window and reasoning effort are host-managed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-05 07:00:23 -06:00
1 parent 47027e7ba5
commit 5d8d9bf83e
8 files changed
+207 -36

No files matched your search

+9 -4
View File
@@ -44,7 +44,7 @@ import { prepareHeartbeatWorkspace } from "./heartbeat/workspace-preparation.js"
import { executeHeartbeatRuntime, NativeSessionResumeScheduledError, NativeWorkspaceFinalizeScheduledError } from "./heartbeat/runtime-execution.js";
import { preserveNativeFailedWorkspace } from "./native-runtime/native-failed-workspace-recovery.js";
import { nativeRunHasNoWorkspaceAccess } from "./workspace-restore-recovery-state.js";
import { selectHeartbeatRuntime } from "./heartbeat/runtime-selection.js";
import { prepareAndSelectHeartbeatRuntime } from "./heartbeat/runtime-selection.js";
import { createHeartbeatRunCompletion } from "./heartbeat/run-completion.js";
export {
MAX_TURN_CONTINUATION_WAKE_REASON,
@@ -3319,8 +3319,7 @@ export function heartbeatService(
const hasInstructionFilesystem = nativeRuntimeResolution.kind !== "native"
? adapter.supportsInstructionsBundle === true
: nativeRuntimeHasInstructionFilesystem(nativeRuntimeResolution.profile.backend);
runtimeConfig = await timePreparation("instructions", () => preparedInstructions.prepare({ runtimeConfig, nativeRuntimeResolution, hasInstructionFilesystem }));
const runtimeSelection = await timePreparation("runtime_selection", () => selectHeartbeatRuntime(db, {
const runtimeSelection = await prepareAndSelectHeartbeatRuntime(db, {
run,
agent,
nativeRuntimeResolution,
@@ -3388,7 +3387,13 @@ export function heartbeatService(
nativeOwnershipHeld = true;
},
stopControllerLease: () => controllerLease.stop(),
}));
}, {
measure: timePreparation,
prepareInstructions: async nativeLifecyclePolicy => {
runtimeConfig = await preparedInstructions.prepare({ runtimeConfig, nativeRuntimeResolution, hasInstructionFilesystem, nativeLifecyclePolicy });
return { runtimeConfig, instructionCopy: preparedInstructions.instructionCopy };
},
});
if (!runtimeSelection.selected) return;
const { nativeExecution, nativeRunnerInstanceId, getNativeFreshSessionHandoff } = runtimeSelection;
nativeWorkspaceSync = runtimeSelection.nativeWorkspaceSync;
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHeartbeatInstructionWorkingCopy } from "./instruction-working-copy.js";
import { AGENT_FILES_CONTRACT } from "../agent-file-store.js";
import { AgentDirectoryReuseInvalidatedError } from "../agent-directory-working-copies.js";
import { resolveHeartbeatNativeLifecyclePolicy } from "./native-lifecycle.js";
const native = vi.hoisted(() => ({ reserve: vi.fn(), claim: vi.fn() }));
vi.mock("../native-runtime/index.js", async importOriginal => ({
@@ -39,6 +40,10 @@ function fixture(executionTarget: Input["workspace"]["executionTarget"] = null,
appendRunEvent,
});
const prepare: Prepare = { runtimeConfig: { retained: true }, hasInstructionFilesystem: true,
nativeLifecyclePolicy: resolveHeartbeatNativeLifecyclePolicy({ adapterType: acpxAgent ? "paperclip_runner" : "codex_local",
backend: acpxAgent ? "acpx_runtime" : "codex_app_server", acpxAgent, agentMode: "warm", agentIdleTimeoutMs: undefined,
environmentPolicy: executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" ? executionTarget.runnerLifecyclePolicy : null,
managedAiSelected: false, taskOwned: Boolean(executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" && executionTarget.runnerTask) }),
nativeRuntimeResolution: { kind: "native", profile: { backend: acpxAgent ? "acpx_runtime" : "codex_app_server" } } as Prepare["nativeRuntimeResolution"] };
const reservation = { reuseRunId: "previous-run", adopt: vi.fn(), release: vi.fn().mockResolvedValue(undefined) };
native.reserve.mockResolvedValue(reservation);
@@ -13,6 +13,7 @@ import { supportsNativeWarmFiles } from "../native-runtime/native-warm-capabilit
import type { HeartbeatRuntimeSelectionInput } from "./runtime-selection.js";
import type { createHeartbeatLifecycle } from "./run-lifecycle.js";
import type { EffectiveRunSessionConfigMetadata } from "./workspaces.js";
import type { NativeLifecyclePolicy } from "./native-lifecycle.js";
type Copies = ReturnType<typeof agentInstructionWorkingCopyService>;
type Selection = HeartbeatRuntimeSelectionInput;
@@ -123,10 +124,11 @@ export function createHeartbeatInstructionWorkingCopy(input: InstructionWorkingC
retirementFailed: async () => { await instructionCopies.reportRetirementUnconfirmed(agent.companyId, readRun().id); },
} : undefined;
async function prepare({ runtimeConfig, nativeRuntimeResolution, hasInstructionFilesystem }: {
async function prepare({ runtimeConfig, nativeRuntimeResolution, hasInstructionFilesystem, nativeLifecyclePolicy }: {
runtimeConfig: Record<string, unknown>;
nativeRuntimeResolution: Selection["nativeRuntimeResolution"];
hasInstructionFilesystem: boolean;
nativeLifecyclePolicy: NativeLifecyclePolicy | null;
}) {
if (hasInstructionFilesystem && !task) {
try {
@@ -141,9 +143,7 @@ export function createHeartbeatInstructionWorkingCopy(input: InstructionWorkingC
const priorWorkingCopy = parseObject(parseObject(parseObject(priorFileInput.runtimeContext).instructions).workingCopy);
const warmFiles = nativeRuntimeResolution.kind === "native" && (supportsNativeWarmFiles({ ...nativeRuntimeResolution.profile, acpxAgent: parseObject(agent.adapterConfig).acpxAgent }) ||
(nativeRuntimeResolution.profile.backend === "acpx_runtime" && parseObject(agent.adapterConfig).acpxAgent === "cursor")) &&
(executionTarget?.kind === "remote" && executionTarget.transport === "sandbox"
? executionTarget.runnerLifecyclePolicy?.mode === "warm"
: parseObject(agent.adapterConfig).lifecycleMode === "warm");
nativeLifecyclePolicy?.mode === "warm";
if (warmFiles && taskSessionForRun?.lastRunId) {
nativeInstructionReservation = await reserveWarmNativeInstructionDirectory({ companyId: agent.companyId, agentId: agent.id,
previousRunId: taskSessionForRun.lastRunId, runId: readRun().id, target: executionTarget,
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { resolveHeartbeatNativeLifecyclePolicy, type NativeLifecyclePolicy } from "./native-lifecycle.js";
type Input = Parameters<typeof resolveHeartbeatNativeLifecyclePolicy>[0];
const warm: NativeLifecyclePolicy = { mode: "warm", idleTimeoutMs: 900_000 };
const perTurn: NativeLifecyclePolicy = { mode: "per_turn", idleTimeoutMs: null };
const base: Input = { adapterType: "paperclip_runner", backend: "codex_app_server", agentMode: "warm",
agentIdleTimeoutMs: 900_000, managedAiSelected: false, taskOwned: false };
describe("admitted native lifecycle policy", () => {
it.each<{ name: string; change: Partial<Input>; expected: NativeLifecyclePolicy }>([
{ name: "absent environment inherits agent warm", change: {}, expected: warm },
{ name: "inherited environment inherits agent warm", change: { environmentPolicy: null }, expected: warm },
{ name: "local selection inherits agent warm", change: { environmentPolicy: undefined }, expected: warm },
{ name: "explicit environment per-turn overrides agent warm", change: { environmentPolicy: perTurn }, expected: perTurn },
{ name: "explicit environment warm overrides agent per-turn", change: { agentMode: "per_turn", environmentPolicy: warm }, expected: warm },
{ name: "admitted per-turn stays pinned", change: { persistedPolicy: perTurn, environmentPolicy: warm }, expected: perTurn },
{ name: "admitted warm stays pinned", change: { persistedPolicy: warm, environmentPolicy: perTurn }, expected: warm },
{ name: "default agent is per-turn", change: { agentMode: undefined }, expected: perTurn },
{ name: "managed Codex supports warm", change: { managedAiSelected: true }, expected: warm },
{ name: "managed process provider without warm support is per-turn", change: { backend: "acpx_runtime", acpxAgent: "claude", managedAiSelected: true, environmentPolicy: warm }, expected: perTurn },
{ name: "unmanaged ACP retains its prior warm policy", change: { backend: "acpx_runtime", acpxAgent: "claude" }, expected: warm },
{ name: "Copilot API-key ownership supports warm", change: { backend: "acpx_runtime", acpxAgent: "copilot", managedAiSelected: true,
managedAiAttribution: { provider: "github", method: "api_key" } }, expected: warm },
{ name: "unqualified Copilot ownership remains per-turn", change: { backend: "acpx_runtime", acpxAgent: "copilot", managedAiSelected: true,
managedAiAttribution: { provider: "github", method: "subscription" } }, expected: perTurn },
{ name: "non-native adapter does not gain managed warm support", change: { adapterType: "codex_local", managedAiSelected: true }, expected: perTurn },
{ name: "task-owned provider keeps its existing ownership gate", change: { backend: "acpx_runtime", acpxAgent: "claude", managedAiSelected: true, taskOwned: true }, expected: warm },
...(["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"] as const).map(backend =>
({ name: `${backend} remains per-turn`, change: { backend, environmentPolicy: warm }, expected: perTurn })),
])("$name", ({ change, expected }) => {
expect(resolveHeartbeatNativeLifecyclePolicy({ ...base, ...change })).toEqual(expected);
});
});
@@ -0,0 +1,28 @@
import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
import { supportsManagedNativeWarmSession } from "../native-runtime/native-warm-capability.js";
export type NativeLifecyclePolicy = NonNullable<NativeExecutionInput["session"]["lifecyclePolicy"]>;
export function resolveHeartbeatNativeLifecyclePolicy(input: {
adapterType: string;
backend: Parameters<typeof supportsManagedNativeWarmSession>[0]["backend"];
acpxAgent?: unknown;
agentMode: unknown;
agentIdleTimeoutMs: unknown;
environmentPolicy?: NativeLifecyclePolicy | null;
persistedPolicy?: NativeLifecyclePolicy | null;
managedAiSelected: boolean;
managedAiAttribution?: { provider: string; method: string };
taskOwned: boolean;
}): NativeLifecyclePolicy {
const agentPolicy: NativeLifecyclePolicy = input.agentMode === "warm"
? { mode: "warm", idleTimeoutMs: resolvePaperclipRunnerIdleTimeoutMs(input.agentIdleTimeoutMs) }
: { mode: "per_turn", idleTimeoutMs: null };
const supportsManagedWarm = input.adapterType === "paperclip_runner" && (input.taskOwned ||
supportsManagedNativeWarmSession({ backend: input.backend, acpxAgent: input.acpxAgent }, 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 }
: input.environmentPolicy ?? agentPolicy);
}
@@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { activityLog, agents, companies, completionContracts, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, workspaceOperations, type Db } from "@paperclipai/db";
import { activityLog, agents, authUsers, companyMemberships, principalPermissionGrants, environments, environmentLeases, companies, completionContracts, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, workspaceOperations, type Db } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
@@ -14,9 +16,12 @@ import { prepareNativeWorkspaceSync } from "../native-runtime/native-workspace-s
import { resolveHeartbeatNativeRuntimeMode } from "../native-runtime/runtime-mode.js";
import { claimNativeRestartRecoveries } from "../native-runtime/native-restart-recovery.js";
import { buildNativeModelEnvelope, canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js";
import { selectHeartbeatRuntime, type HeartbeatRuntimeSelectionInput } from "./runtime-selection.js";
import { prepareAndSelectHeartbeatRuntime, selectHeartbeatRuntime, type HeartbeatRuntimeSelectionInput } from "./runtime-selection.js";
import { reserveRunBudget } from "../budget-reservations.js";
import { legacyControllerClaim } from "../legacy-controller-lease.js";
import { agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js";
import { createHeartbeatInstructionWorkingCopy } from "./instruction-working-copy.js";
import { environmentService } from "../environments.js";
// Keep the real contract, session, and runtime-selection transactions. Replace only
// filesystem materialization and sandbox synchronization at their external boundaries.
@@ -82,6 +87,87 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () =
const coordinators = (id: string) => db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, id));
const sandbox = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: "/workspace", reusableLeaseConfigured: false };
it("preserves preparation failure and skips runtime selection", async () => {
const { input, run } = await fixture();
const initiating = new Error("Synthetic preparation failure");
const phases: string[] = [];
await expect(prepareAndSelectHeartbeatRuntime(db, input, {
measure: async (phase, work) => { phases.push(phase); return work(); },
prepareInstructions: async () => { throw initiating; },
})).rejects.toBe(initiating);
expect(phases).toEqual(["instructions"]);
expect(await coordinators(run.id)).toEqual([]);
expect(buildNativeRuntimeContext).not.toHaveBeenCalled();
});
it.each([false, true])("checkpoints the real instruction copy under the selected agent-default warm policy (changed: %s)", async changed => {
const { input, issue, agent, run } = await fixture();
const userId = randomUUID(), environmentId = randomUUID(), leaseId = randomUUID();
vi.stubEnv("PAPERCLIP_HOME", instructionRoot);
const { resolveManagedInstructionsRoot } = await import("../agent-instructions.js");
const canonical = resolveManagedInstructionsRoot(agent), remoteCwd = join(instructionRoot, randomUUID(), "task");
await mkdir(canonical, { recursive: true }); await mkdir(remoteCwd, { recursive: true });
await writeFile(join(canonical, "AGENTS.md"), "Synthetic instructions for this local fixture only.\n");
const execute = promisify(execFileCallback);
await execute("git", ["init", remoteCwd]);
const target = { kind: "remote" as const, transport: "sandbox" as const, environmentId, leaseId, remoteCwd,
runnerLifecyclePolicy: null, reusableLeaseConfigured: true, effectiveCapabilities: { reusableLeases: true } as never,
runner: { execute: async (command: { command: string; args?: string[]; cwd?: string; env?: Record<string,string>; stdin?: string; timeoutMs?: number }) => {
const args = [...(command.args ?? [])], env = { PATH: process.env.PATH, ...command.env };
if (command.stdin != null && (args[0] === "-c" || args[0] === "-lc")) {
Object.assign(env, { PAPERCLIP_TEST_STDIN: command.stdin });
args[1] = `printf '%s' "$PAPERCLIP_TEST_STDIN" | (${args[1]})`;
}
const result = await execute(command.command, args, { cwd: command.cwd, env, timeout: command.timeoutMs, maxBuffer: 32 * 1024 * 1024 });
return { exitCode: 0, signal: null, timedOut: false, ...result, pid: null, startedAt: new Date().toISOString() };
} } };
await db.insert(authUsers).values({ id: userId, name: "Fixture editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values([{ companyId: agent.companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
{ companyId: agent.companyId, principalType: "agent", principalId: agent.id, membershipRole: "member" }]);
await db.insert(principalPermissionGrants).values({ companyId: agent.companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agent.id] } });
await db.insert(environments).values({ id: environmentId, name: `Loopback sandbox ${environmentId}`, driver: "sandbox" });
const [lease] = await db.insert(environmentLeases).values({ id: leaseId, companyId: agent.companyId, environmentId, heartbeatRunId: run.id,
provider: "fixture", providerLeaseId: "local-loopback", leasePolicy: "reuse_by_environment" }).returning();
input.agent = { ...agent, adapterConfig: { ...agent.adapterConfig, lifecycleMode: "warm", idleTimeoutMs: 900_000,
instructionsBundleMode: "managed", instructionsRootPath: canonical, instructionsEntryFile: "AGENTS.md" } };
await db.update(agents).set({ adapterConfig: input.agent.adapterConfig }).where(eq(agents.id, agent.id));
await db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, run.id));
input.run = await read(run.id);
input.workspace.executionTarget = target;
input.workspace.executionWorkspace.cwd = remoteCwd;
input.workspace.activeEnvironmentLease = { lease: (await environmentService(db).getLeaseById(lease.id))! };
input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: input.run, enabled: true, runtimeConfig: {}, adapterConfig: input.agent.adapterConfig,
agent: input.agent, issue, target, workspaceId: null });
expect(input.nativeRuntimeResolution.kind).toBe("native");
const { agentInstructionsBundleMode } = await import("../agent-instructions.js");
expect(agentInstructionsBundleMode(input.agent)).toBe("managed");
const copies = agentInstructionWorkingCopyService(db);
const instructionEvents = vi.fn(async () => {});
const controller = createHeartbeatInstructionWorkingCopy({ db, instructionCopies: copies, readRun: () => input.run, agent: input.agent,
context: input.task.context, session: { taskSession: null, taskSessionForRun: null, sessionConfigMetadata: {} as never },
workspace: input.workspace, appendRunEvent: instructionEvents });
const phases: string[] = [];
const selected = await prepareAndSelectHeartbeatRuntime(db, input, {
measure: async (phase, work) => { phases.push(phase); return work(); },
prepareInstructions: async nativeLifecyclePolicy => {
const runtimeConfig = await controller.prepare({ runtimeConfig: {}, nativeRuntimeResolution: input.nativeRuntimeResolution, hasInstructionFilesystem: true, nativeLifecyclePolicy });
return { runtimeConfig, instructionCopy: controller.instructionCopy };
},
});
expect(phases).toEqual(["instructions", "runtime_selection"]);
expect(instructionEvents.mock.calls).toEqual([]);
expect(controller.instructionCopy).not.toBeNull();
if (!selected.selected || !selected.nativeExecution) throw new Error("Expected native selection");
expect(selected.nativeExecution.session.lifecyclePolicy).toEqual({ mode: "warm", idleTimeoutMs: 900_000 });
expect((await copies.get(agent.companyId, run.id))?.receipt?.warm).toBe(true);
if (changed) await writeFile(join(controller.instructionCopy!.executionRoot, "memory.txt"), "Synthetic changed memory.\n");
expect(await controller.nativeInstructionWorkingCopy()!.checkpointWarm!()).toBe(true);
expect((await copies.get(agent.companyId, run.id))?.state).toBe("warm_saved");
expect(instructionEvents).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ eventType: "instruction_save",
payload: expect.objectContaining({ state: changed ? "saved" : "unchanged", contract: "agent_files", errorCode: null }) }));
if (changed) expect(await readFile(join(canonical, "memory.txt"), "utf8")).toBe("Synthetic changed memory.\n");
}, 45_000);
it.each(["matching", "different-session", "absent"])("persists the migrated logical cwd only for the receipt's exact session: %s", async receipt => {
const sameSession = receipt === "matching";
const { input, run, agent, issue } = await fixture();
@@ -15,7 +15,7 @@ import { isAgentDirectoryCopy } from "../agent-directory-working-copies.js";
import { isConversation } from "../agent-conversations.js";
import { renewLegacyControllerLease } from "../legacy-controller-lease.js";
import type { prepareManagedAiRuntime } from "../ai-connection-runtime.js";
import { supportsManagedNativeWarmSession } from "../native-runtime/native-warm-capability.js";
import { resolveHeartbeatNativeLifecyclePolicy, type NativeLifecyclePolicy } from "./native-lifecycle.js";
import {
getNativeReviewAssignment,
readNativeReviewAssignmentContext,
@@ -82,7 +82,6 @@ import { CHAT_CONTROL_RECOVERY_ADMISSION_KEY } from "../chat-control-recovery-st
import { NATIVE_PREPARATION_FENCING_VERSION } from "../native-runtime/native-active-runs.js";
import type { issueService } from "../issues.js";
import { buildPlanReviewContext } from "../plan-review-context.js";
import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
import { normalizePaperclipWakePayload, selectPaperclipTaskMarkdown } from "@paperclipai/adapter-utils/server-utils";
import type { ProviderResourceDisposition } from "../environment-runtime.js";
@@ -132,6 +131,7 @@ export interface HeartbeatRuntimeSelectionInput {
onPhase?: Parameters<typeof prepareNativeWorkspaceSync>[0]["onPhase"];
};
config: {
nativeLifecyclePolicy?: NativeLifecyclePolicy | null;
runtimeConfig: Record<string, unknown>;
runtimeSkillEntries: Parameters<typeof buildNativeRuntimeContext>[0]["runtimeSkillEntries"];
runScopedMentionedSkillKeys: string[];
@@ -258,7 +258,35 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
/** Prepare and persist the selected runtime before the executor dispatches provider work. */
function resolveRuntimeLifecyclePolicy(input: HeartbeatRuntimeSelectionInput): NativeLifecyclePolicy | null {
if (input.nativeRuntimeResolution.kind !== "native") return 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,
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),
});
}
export async function prepareAndSelectHeartbeatRuntime(
db: Db,
input: HeartbeatRuntimeSelectionInput,
preparation: {
prepareInstructions: (policy: NativeLifecyclePolicy | null) => Promise<Pick<HeartbeatRuntimeSelectionInput["config"], "runtimeConfig" | "instructionCopy">>;
measure: <T>(phase: "instructions" | "runtime_selection", work: () => Promise<T>) => Promise<T>;
},
): Promise<HeartbeatRuntimeSelectionResult> {
const nativeLifecyclePolicy = resolveRuntimeLifecyclePolicy(input);
const prepared = await preparation.measure("instructions", () => preparation.prepareInstructions(nativeLifecyclePolicy));
return preparation.measure("runtime_selection", () => selectHeartbeatRuntime(db, {
...input,
config: { ...input.config, ...prepared, nativeLifecyclePolicy },
}));
}
export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSelectionInput): Promise<HeartbeatRuntimeSelectionResult> {
const { run, agent, nativeRuntimeResolution, issuesSvc, runtimeEnv, beforeNativeRuntimeSelection, onAdapterEvent,
onNativeLifecycleSelected, onProviderResourceDisposition, onNativeOwnershipHeld, stopControllerLease } = input;
@@ -501,28 +529,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
let nativeResumeCheckpoint: ReturnType<
typeof rebindNativeSessionCheckpoint
> = null;
const agentLifecyclePolicy =
parseObject(agent.adapterConfig).lifecycleMode === "warm"
? {
mode: "warm" as const,
idleTimeoutMs: resolvePaperclipRunnerIdleTimeoutMs(
parseObject(agent.adapterConfig).idleTimeoutMs,
),
}
: { mode: "per_turn" as const, idleTimeoutMs: null };
const environmentLifecyclePolicy =
executionTarget?.kind === "remote" &&
executionTarget.transport === "sandbox"
? (executionTarget.runnerLifecyclePolicy ?? null)
: null;
// Task providers own credential lifetime; process-backed managed homes retain their provider-specific cleanup policy.
const supportsManagedWarmSession = agent.adapterType === "paperclip_runner" &&
((executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" && !!executionTarget.runnerTask) ||
supportsManagedNativeWarmSession({ ...nativeRuntimeResolution.profile, acpxAgent: parseObject(agent.adapterConfig).acpxAgent }, managedAiRuntime?.attribution));
const effectiveLifecyclePolicy = persistedNativeExecutionInput?.session.lifecyclePolicy ??
(["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"].includes(nativeRuntimeResolution.profile.backend) || managedAiRuntime && !supportsManagedWarmSession
? { mode: "per_turn" as const, idleTimeoutMs: null }
: environmentLifecyclePolicy ?? agentLifecyclePolicy);
const effectiveLifecyclePolicy = input.config.nativeLifecyclePolicy ?? resolveRuntimeLifecyclePolicy(input)!;
if (
effectiveLifecyclePolicy.mode === "warm" &&
executionTarget?.kind === "remote" &&