fix(native): defer warm continuation during predecessor finalization (#18811)

## Thinking Path

> - Paperclip manages agent work and preserves its saved results.
> - Native warm sessions can continue on a retained remote Runner.
> - A completed predecessor can still have a live finalization lease.
> - The continuation must wait for that owner before it can start work.
> - Two admission checks treated that temporary lease as ambiguous
state.
> - This pull request uses the existing deadline-based wait for both
checks.
> - The same business run can continue once, with its saved context and
gates.

## Linked Issues or Issue Description

**What happened?**

A compatible remote warm continuation can fail before provider work
starts. Its predecessor has completed, but a finalizer still holds a
live lease. The warm-session check reports
`runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate`. The earlier retained-sandbox
check has the same problem on the command transport.

**Expected behavior**

Keep the unstarted continuation on its existing run. Wait until the
recorded owner deadline. Recheck ordinary ownership, Stop, credentials
and checkpoint evidence before execution.

**Steps to reproduce**

1. Complete a remote warm turn and retain its controller records and
provider session.
2. Give that completed predecessor a live finalization lease.
3. Admit a compatible follow-up with full local controller projections.
4. Before this change, admission fails permanently. It must instead
persist a bounded pre-provider wait.

**Paperclip version or commit**

The PostgreSQL regression failed before production edits on
`adb49a4434`. The current head is `5762dfbe4e`, based on master
`e491b23eb5`.

**Deployment mode**

Native remote sandbox execution. No database migration or provider
artifact change is required.

Related: #18718 refreshes stale warm-controller projections. #18343 adds
controller replacement recovery. #18554 restores suspended remote
sessions. This change handles a live predecessor finalizer after those
source checks.

The shared historical error does not identify its failed predicate. This
regression proves the finalizer race; it does not prove that every
historical instance of that error had this cause.

## What Changed

- Defer a compatible warm continuation while its completed predecessor
has a live finalization lease.
- Use the same wait in the preceding retained-sandbox read check.
- Preserve the current run, provider attempt count and checkpoint. Reuse
the existing resumption scheduler.
- Add PostgreSQL cases for both finalization phases, lease expiry,
company scope, incompatible configuration, unfinished commands, Stop and
duplicate resumption claims.
- Add an opt-in three-turn Linux Runner case. Update the
child-controller fixture to own and register a local API listener.
- Document the waiting behavior and acceptance case.
- Repair two readiness barriers in the refresh-worker integration
fixture after a required CI shard failed twice: wait for paginated
startup and for active worker promises to settle before checking idle
SQL.

## Verification

- **Native regression RED:** before production edits on `adb49a4434`,
the PostgreSQL boundary case received the permanent identity error
instead of `NativeControllerRecoveryWaitingError`.
- **Regression GREEN on the current head:** all **71** focused recovery,
settlement, heartbeat safety and refresh-worker cases pass on
`5762dfbe4e` with Node 24.21.0 and PostgreSQL 18. They cover
incompatible identities, company/configuration mismatches, expired or
missing deadlines, unfinished commands, Stop, retained files and
duplicate resumption claims.
- **Real process acceptance on the current head:** the three-turn
`finalizer-wait` case passes in 58.01 seconds. PostgreSQL, TLS, a
rebuilt Linux Runner and the deterministic Codex app-server fixture
confirm zero attempts and no provider turn during the wait, one claim
resuming the same run, retained history, a healthy third warm turn and
final suspension. The sibling `warm-suspended` case from master also
passes, in a fresh container, in 46.02 seconds. Both runs use
`5762dfbe4e`; the artifacts were rebuilt from that checkout with the
repository's pinned Rust 1.97.1 toolchain.
- Acceptance used Node 24.21.0 and glibc 2.41. Earlier probes exposed
the missing fixture API listener and the preceding retained-sandbox
guard; both are corrected. A stale disposable listener and an
incompatible libc image failed setup before the successful
fresh-container run. These setup failures are not the original
regression's RED.
- Repository typecheck/build passed locally before the rebase. Server
typecheck passes after the rebase. **Full CI passes on the current
head:** 48 successful jobs and one expected scope skip, including
typecheck, build, Runner checks, all serialized-server shards, general
tests, E2E and the final aggregate. Module-boundary and whitespace
checks also pass.
- Broad local inventories are incomplete: six chat shards passed (1,834
tests), but untouched server/UI/adapter cases hit timeouts. The exact
shared-base expiration case passes alone; a full-file rerun passed all
89 assertions but timed out in teardown. A Node 24 rerun timed out in
another lifecycle case and teardown. A later PostgreSQL recheck could
not allocate shared memory; this task's redundant broad controllers were
stopped and their logs retained.
- CI attempt 1 failed a Cursor Cloud cancellation deadline, an E2E
cleanup cancellation with a PostgreSQL lock timeout, and the
refresh-worker startup settling limit. One failed-only retry passed
Runner and E2E; the same refresh startup case failed again. Two
aggregate jobs had previously stalled in the hosted-runner queue and
blocked the retry; that obsolete attempt was cancelled. No repeated
blind rerun is being used.
- **Readiness repair RED/GREEN:** real PostgreSQL 18 behind a loopback
proxy with 20 ms delay per direction reproduces the exact startup error
before the test change; the same probe passes afterward. An initial
controlled probe hit the dedicated-database naming guard before tests
ran and is excluded from RED evidence. The full-file check then exposed
a separate false-idle barrier: a worker could be running while its
scheduler had no timer. The fixture now also checks the existing
active-work tracker. All **24 cases pass** with both repairs. The
at-most-six scoped SQL reads and zero recurring idle-SQL assertions are
unchanged; startup alone has an explicit bounded condition wait. No
production behavior or timeout changes.
- Greptile and Paperclip Review are both **5/5 on the current head**,
with no open findings. Both reviews used source inspection; the runtime
evidence above was obtained separately. The previous review workspace
was recovered on the first production attempt after repairing its
partial-clone export. Its preserved result and real follow-up both
finalized successfully, saved snapshots and released their writers and
finalization leases. Three current-head CI shards received hosted-runner
shutdown signals and were cancelled. All three passed their one
justified retry. The queued aggregate had to be cancelled to schedule
that retry; the final aggregate also passed. [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/41706617909).
- This acceptance does not qualify a live Codex model or Daytona API run
of the native repair. No deployment or recovery of the original task is
claimed.

## Risks

- A wait must not grant a second executor. The current attempt stays at
zero until the existing scheduler claims it. Expired or missing
deadlines retain their existing hold.
- Saved state must survive the wait. Admission still requires the same
company, task, session, configuration and physical sandbox evidence.
- Current Stop, budget, credential and permission gates remain with
ordinary resumption. No polling loop is added.

## Model Used

OpenAI Codex, GPT-6 family. The runtime does not expose the exact
serving model ID or context-window size. Capabilities: reasoning,
repository inspection, code editing, shell tools and test execution.

## 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-03 14:18:18 -06:00
1 parent e0276e0166
commit 0b87308d5d
8 files changed
+148 -12

No files matched your search

+6 -1
View File
@@ -3030,7 +3030,12 @@ resumption path. This does not consume a provider attempt or stop the retained
sandbox. A locked run/coordinator check rejects Stop, changed ownership or any
provider progress. Startup restores even an overdue wait to the same scheduler;
ordinary resumption rechecks current permissions and task authority. No idle
scan is added. Per-turn continuation reconstructs a missing suspended-state
scan is added. The same wait applies when a compatible successful predecessor
still has a live finalization lease, even with full local controller projections.
An expired or undated owner, incomplete canonical commands and incompatible
execution bindings retain their existing reconciliation holds. Once the owner
releases its lease, the existing resumption claim admits the same run once.
Per-turn continuation reconstructs a missing suspended-state
receipt from the admitted sandbox or prepared-task owner, including after a
previous read failed with only a PRP projection written.
+4
View File
@@ -23,6 +23,10 @@ The `warm-suspended` case suspends the first turn, resumes the second turn,
then keeps that controller warm for the third turn. It must retire the first
turn's local suspension receipt so the third turn can reuse the resumed Runner
and provider history without a second process or repeated turn.
The `finalizer-wait` case gives the completed predecessor a live finalization
lease. The next run must wait without starting a provider turn or consuming an
attempt. After lease release, one durable resumption claim continues that same
run and preserves provider history through the third turn.
The same-server case also starts preparation lease renewal during recovery and
holds instruction collection open past a renewal tick after coordinator release.
The executor must acquire renewal before preparation releases its reference, then
@@ -16,6 +16,7 @@ import { instanceSettingsService } from "../services/instance-settings.js";
import { notifyExternalObjectRefresh } from "../services/external-object-refresh-notifications.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -92,7 +93,7 @@ const active: ExternalObjectResolveResult = { ok: true, snapshot: {
}
throw new Error("refresh worker did not settle");
}
async function quiet() { await settle(() => coordinator!.nextWakeAt() === null); }
async function quiet() { await settle(() => coordinator!.nextWakeAt() === null && idleWorkSnapshot().active === 0); }
function clock() { vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true }); }
async function change(companyId: string, patch: Partial<typeof externalObjects.$inferInsert>) {
await db.transaction(async tx => {
@@ -601,7 +602,10 @@ const active: ExternalObjectResolveResult = { ok: true, snapshot: {
await db.insert(externalObjectMentions).values(batch.map(objectId => ({ companyId: f.companyId,
sourceIssueId: f.issueId, sourceKind: "description" as const, objectId })));
}
await start(svc); await settle(() => coordinator!.nextWakeAt() === future.getTime());
await start(svc);
await vi.waitFor(() => {
expect(coordinator!.nextWakeAt()).toBe(future.getTime());
}, { timeout: 10_000, interval: 5 });
const [incoming] = await db.insert(issues).values({ companyId: f.companyId, title: "Incoming",
description: "https://example.com/new-object" }).returning();
const reads: Array<{ query: string; params: unknown[] }> = [];
@@ -1,6 +1,8 @@
import { createDb } from "@paperclipai/db";
import { closeIdleWarmNativeSessionsForRestart, executePaperclipNativeSession } from "../../services/native-runtime/native-session-executor.js";
import { dockerCommandRunner } from "./docker-command-runner.js";
import { createServer } from "node:http";
import { registerLocalRuntimeApiServer } from "../../runtime-api.js";
// Separate TypeScript fixture import time from actual controller admission.
process.send?.({ ready: true });
@@ -15,7 +17,10 @@ process.once("message", async (input: {
}) => {
const target = input.options.runnerExecutionTarget;
if (target?.kind !== "remote") throw new Error("Expected remote worker target");
const api = createServer((_request, response) => { response.writeHead(404).end(); });
registerLocalRuntimeApiServer(api);
try {
await new Promise<void>(resolve => api.listen(0, "127.0.0.1", resolve));
const runner = dockerCommandRunner(input.container);
let inspectionOverridden = false;
const execute = runner.execute.bind(runner);
@@ -55,6 +60,9 @@ process.once("message", async (input: {
process.send?.({ result: { ...result, inspectionOverridden } });
} catch (error) {
process.send?.({ error: error instanceof Error ? error.stack : String(error) });
} finally {
api.closeAllConnections();
await new Promise<void>(resolve => api.close(() => resolve()));
}
});
setInterval(() => undefined, 60_000);
@@ -4,16 +4,17 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agentTaskSessions, agents, companies, createDb, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db";
import { DurablePrpControlPlane, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
import { agentTaskSessions, agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db";
import { DurablePrpControlPlane, parseNativeExecutionInput, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
import { startRecoveryPostgres, recoveryPostgresSupport } from "../../__tests__/helpers/recovery-postgres.js";
import { createIsolatedTestHome } from "../../__tests__/helpers/isolated-test-home.js";
import { prepareNativeHeartbeatRun } from "./prepare-native-run.js";
import { nativeRunnerCheckpointWriter } from "./native-runner-checkpoint.js";
import { createNativeControllerRecovery, deferNativeControllerRecovery, importNativeControllerRecovery, NATIVE_RETAINED_OWNER_WAIT, resolveNativeControllerRecovery } from "./native-controller-recovery.js";
import { assertNativeRetainedSandboxBinding, createNativeControllerRecovery, deferNativeControllerRecovery, importNativeControllerRecovery, NativeControllerRecoveryWaitingError, NATIVE_RETAINED_OWNER_WAIT, resolveNativeControllerRecovery } from "./native-controller-recovery.js";
import { claimNativeSessionResumptions } from "./native-finalization-reconciler.js";
import { claimNativeRestartRecoveries } from "./native-restart-recovery.js";
import { readNativeControllerRecoveryInventory } from "./native-controller-recovery-inventory.js";
import { inspectRemoteWarmSessionRecovery } from "./remote-warm-session-recovery.js";
describe.skipIf(!recoveryPostgresSupport.supported)("native controller durable discovery and import", () => {
let database: Awaited<ReturnType<typeof startRecoveryPostgres>>;
@@ -132,6 +133,91 @@ describe.skipIf(!recoveryPostgresSupport.supported)("native controller durable d
expect(await claimNativeSessionResumptions({ db, runnerInstanceId: "successor", runIds: [runId], now: nextDeadline })).toEqual([]);
});
it.each(["committed", "workspace_finalizing"])("defers a remote warm continuation while its %s predecessor has a finalization lease", async phase => {
const fixture = await seed(), runId = randomUUID(), environmentId = randomUUID();
const previous = parseNativeExecutionInput({
schema: "paperclip.native-execution-input.v1", provider: { kind: "codex", model: null },
binding: { companyId, agentId, issueId: fixture.scope.issueId, runId: fixture.scope.runId,
executionWorkspaceId: randomUUID() },
task: { identifier: "NCD-1", title: "Continue retained execution", description: null,
prompt: "Continue the saved work.", workMode: "standard" },
workspace: { cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: fixture.scope.normalizedSessionId, driverKind: "codex_app_server",
protocolVersion: 1, lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 } },
completionContract: { id: randomUUID(), sha256: "fixture", schemaVersion: "paperclip.completion-contract.v1",
contract: { revision: "1", objective: "Continue the saved work.", criteria: [{ id: "objective", requirement: "Save the result." }] } },
credentialBindings: [], interactionResponses: [],
});
const current = { ...previous, binding: { ...previous.binding, runId } };
const checkpoint: PersistedNativeSession = { ...fixture.snapshot, driverKind: "codex_app_server",
providerSessionId: "retained-provider-session", activeTurnId: null, pendingRuntimeRequests: [] };
const metadata = createNativeControllerRecovery(checkpoint, compatibility, { runId: fixture.scope.runId,
runnerInstanceId: fixture.native.runnerInstanceId, normalizedSessionId: fixture.scope.normalizedSessionId });
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...fixture.profile, nativeExecutionInput: previous,
sessionCheckpoint: checkpoint, nativeControllerRecovery: metadata } }).where(eq(heartbeatRuns.id, fixture.scope.runId));
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: fixture.scope.issueId,
nativeSessionId: fixture.scope.normalizedSessionId, status: "running", runtimeMode: "native",
runnerProfileJson: { nativeExecutionInput: current, sessionCheckpoint: { ...checkpoint,
identity: { ...checkpoint.identity, runId } } } });
await db.insert(nativeRunFinalizations).values({ companyId, issueId: fixture.scope.issueId, runId, phase: "observed" });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, fixture.scope.issueId));
await db.insert(environments).values({ id: environmentId, name: `Retained ${environmentId}`, driver: "sandbox" });
await db.insert(environmentLeases).values({ companyId, environmentId, issueId: fixture.scope.issueId,
heartbeatRunId: fixture.scope.runId, provider: "daytona", providerLeaseId: "retained-sandbox", status: "released",
metadata: { remoteCwd: "/workspace" } });
const deadline = new Date(Date.now() + 60_000);
await db.update(nativeRunFinalizations).set({ phase, leaseOwner: "finalizer", leaseExpiresAt: deadline,
controllerPid: process.pid, controllerProcessStartedAt: new Date() }).where(eq(nativeRunFinalizations.runId, fixture.scope.runId));
const identity = { runId: fixture.scope.runId, normalizedSessionId: fixture.scope.normalizedSessionId,
runnerInstanceId: fixture.native.runnerInstanceId, environmentLeaseId: fixture.native.environmentLeaseId,
turnId: fixture.native.turnId, itemId: fixture.native.itemId };
const target = { kind: "remote", transport: "sandbox", environmentId, leaseId: randomUUID(), providerKey: "daytona",
remoteCwd: "/workspace", sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "retained-sandbox" } } as const;
const readSandbox = (override: Record<string, unknown> = {}) => assertNativeRetainedSandboxBinding(db,
{ ...fixture.scope, target: { ...target, ...override } as never });
const inspect = (overrides: Partial<Parameters<typeof inspectRemoteWarmSessionRecovery>[0]> = {}) => inspectRemoteWarmSessionRecovery({ db, execution: current,
target: target as never,
identity, readControlState: () => ({ identity, commands: [{ type: "turn.start", status: "completed" }],
committedEvents: [{ eventType: "run.terminal", envelope: identity }] }),
matchesExecution: saved => saved.binding.runId === fixture.scope.runId,
loadCheckpoint: () => checkpoint, canReuseProcess: () => true, ...overrides,
});
const denied = "runner_state_identity_mismatch: prior_owner_terminal_state_indeterminate";
await expect(inspect({ execution: { ...current, binding: { ...current.binding, companyId: randomUUID() } } }))
.rejects.toThrow(denied);
await expect(inspect({ matchesExecution: () => false })).rejects.toThrow(denied);
await expect(readSandbox({ remoteCwd: "/unrelated" })).rejects.toThrow("retained_sandbox_binding");
for (const leaseExpiresAt of [null, new Date(Date.now() - 1_000)]) {
await db.update(nativeRunFinalizations).set({ leaseExpiresAt }).where(eq(nativeRunFinalizations.runId, fixture.scope.runId));
await expect(inspect()).rejects.toThrow(denied);
await expect(readSandbox()).rejects.toThrow("retained_sandbox_binding");
}
await db.update(nativeRunFinalizations).set({ leaseExpiresAt: deadline }).where(eq(nativeRunFinalizations.runId, fixture.scope.runId));
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, fixture.scope.runId));
await expect(inspect()).rejects.toThrow(denied);
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, fixture.scope.runId));
await expect(readSandbox()).rejects.toMatchObject({ retryAt: deadline });
const waiting = await inspect().catch(error => error);
expect(waiting).toBeInstanceOf(NativeControllerRecoveryWaitingError);
expect(waiting.retryAt).toEqual(deadline);
await deferNativeControllerRecovery(db, { ...current.binding, retryAt: waiting.retryAt });
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)))[0])
.toMatchObject({ phase: "retryable_failure", attempt: 0, nextAttemptAt: deadline, resultId: null });
expect(await claimNativeSessionResumptions({ db, runnerInstanceId: "successor", runIds: [runId] })).toEqual([]);
await db.update(nativeRunFinalizations).set({ phase: "committed", leaseOwner: null, leaseExpiresAt: null })
.where(eq(nativeRunFinalizations.runId, fixture.scope.runId));
await expect(readSandbox()).resolves.toBeUndefined();
await expect(inspect({ readControlState: () => ({ identity, commands: [{ status: "pending" }],
committedEvents: [{ eventType: "run.terminal", envelope: identity }] }) })).rejects.toThrow(denied);
const recovered = await inspect();
expect(recovered.identity).toEqual(identity);
expect(recovered.checkpoint).toBe(checkpoint);
expect(recovered.reuseProcess).toBe(true);
const claims = await claimNativeSessionResumptions({ db, runnerInstanceId: "successor", runIds: [runId], now: deadline });
expect(claims).toHaveLength(1);
expect(await claimNativeSessionResumptions({ db, runnerInstanceId: "duplicate", runIds: [runId], now: deadline })).toEqual([]);
});
it.each(["live-owner", "expired-owner", "provider-attempt", "stopped", "operator-stop"])("does not publish an admission wait after authority changes: %s", async scenario => {
const fixture = await seed(), runId = randomUUID();
const scope = { ...fixture.scope, runId };
@@ -138,11 +138,17 @@ export async function assertNativeRetainedSandboxBinding(db: Db, input: {
eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.issueId, input.issueId),
eq(environmentLeases.heartbeatRunId, input.runId), eq(environmentLeases.environmentId, target.environmentId),
eq(environmentLeases.provider, target.providerKey), eq(environmentLeases.providerLeaseId, target.sandboxLeaseAcquisition.providerLeaseId)));
if (!owner || owner.leaseOwner || owner.phase !== "committed" || !owner.resultId || owner.recoveryState
if (!owner || !owner.resultId || owner.recoveryState
|| leases.length !== 1 || leases[0].metadata?.remoteCwd !== target.remoteCwd
|| !["active", "released", "expired"].includes(leases[0].status)) {
throw new Error("native_controller_recovery_missing_evidence:retained_sandbox_binding");
}
if (owner.leaseOwner && owner.leaseExpiresAt && owner.leaseExpiresAt > new Date()) {
throw new NativeControllerRecoveryWaitingError(owner.leaseExpiresAt);
}
if (owner.leaseOwner || owner.phase !== "committed") {
throw new Error("native_controller_recovery_missing_evidence:retained_sandbox_binding");
}
}
/** Only the existing bounded selector chooses history. Never search backwards
@@ -1,5 +1,7 @@
import { renewNativeSessionExecutionLease, startNativeSessionExecutionLeaseRenewal, type NativeExecutionLeaseRenewal } from "./native-execution-lease.js";
import { enableRunFailureReporting } from "../../__tests__/helpers/run-failure-reporting.js";
import { NativeControllerRecoveryWaitingError } from "./native-controller-recovery.js";
import { claimNativeSessionResumptions } from "./native-finalization-reconciler.js";
import { hasNativeLocalProcessStop } from "../native-local-process-stop.js";
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
@@ -150,7 +152,7 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
expect(next.kind).toBe("bootstrap_incomplete");
});
it.skipIf(!process.env.PAPERCLIP_TEST_RUNNER_CONTAINER).each(["warm", "hard", "warm-suspended", "stale-projection", "legacy", "changed", "same-server", "diskless", "diskless-per-turn", "diskless-per-turn-read-retry", "diskless-warm-suspended", "diskless-warm-suspended-read-retry", "diskless-live-inspection"])("preserves remote warm continuity across completed turns: %s", async (scenario) => {
it.skipIf(!process.env.PAPERCLIP_TEST_RUNNER_CONTAINER).each(["warm", "hard", "finalizer-wait", "warm-suspended", "stale-projection", "legacy", "changed", "same-server", "diskless", "diskless-per-turn", "diskless-per-turn-read-retry", "diskless-warm-suspended", "diskless-warm-suspended-read-retry", "diskless-live-inspection"])("preserves remote warm continuity across completed turns: %s", async (scenario) => {
const hardRestart = !["warm", "same-server", "warm-suspended"].includes(scenario);
const diskless = scenario.startsWith("diskless");
const perTurn = scenario.startsWith("diskless-per-turn");
@@ -408,6 +410,23 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
...(perTurn ? { sessionCheckpoint: admission.checkpoint } : {}) } }).where(eq(heartbeatRuns.id, row.id));
};
await admitContinuation(nextOptions.execution);
let continuationLeaseOwner: string | undefined;
if (scenario === "finalizer-wait") {
const retryAt = new Date(Date.now() + 60_000);
await fixture.db.update(nativeRunFinalizations).set({ leaseOwner: "fixture-finalizer", leaseExpiresAt: retryAt })
.where(eq(nativeRunFinalizations.runId, fixture.runId));
await expect(executePaperclipNativeSession(nextOptions)).rejects.toBeInstanceOf(NativeControllerRecoveryWaitingError);
const [waiting] = await fixture.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, nextRunId));
expect(waiting).toMatchObject({ phase: "retryable_failure", attempt: 0, nextAttemptAt: retryAt });
const calls = await runner.execute({ command: "cat", args: [`${remoteCwd}/provider-calls.log`] });
expect(calls.stdout.split("\n").filter(line => line === "turn/start")).toHaveLength(1);
await fixture.db.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null })
.where(eq(nativeRunFinalizations.runId, fixture.runId));
const [claim] = await claimNativeSessionResumptions({ db: fixture.db, runnerInstanceId: next.runnerInstanceId,
runIds: [nextRunId], now: retryAt });
expect(claim).toBeDefined();
continuationLeaseOwner = claim.leaseOwner;
}
if (scenario.endsWith("-read-retry")) {
let rejectedReads = 0;
await expect(executePaperclipNativeSession({ ...nextOptions,
@@ -425,7 +444,8 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
// Retry with the partial PRP projection left by the failed read. No
// fixture writes a suspended receipt or changes the surviving Runner.
}
const second = diskless ? await executeInController(nextOptions) : await executePaperclipNativeSession(nextOptions);
const second = diskless ? await executeInController(nextOptions) : await executePaperclipNativeSession({ ...nextOptions,
...(continuationLeaseOwner ? { leaseOwner: continuationLeaseOwner } : {}) });
expect(second.exitCode).toBe(0);
if (scenario === "diskless-live-inspection") expect("inspectionOverridden" in second && second.inspectionOverridden).toBe(true);
const historyAfter = await runner.execute({ command: "sh", args: ["-c", `cat '${remoteCwd}'/.paperclip-runtime/paperclip-runner/sessions/*/filesystem/codex-home/fake-codex-state.json`] });
@@ -3,7 +3,7 @@ import { agents, companies, environmentLeases, heartbeatRuns, issues, nativeRunF
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { parseNativeExecutionInput, type NativeExecutionInput, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
import { currentNativeControllerIdentity, evaluateNativeControllerTakeover } from "./native-restart-recovery.js";
import { readNativeControllerRecovery } from "./native-controller-recovery.js";
import { NativeControllerRecoveryWaitingError, readNativeControllerRecovery } from "./native-controller-recovery.js";
export type RemoteWarmSessionRecovery = {
identity: Record<string, string>;
@@ -71,11 +71,14 @@ export async function inspectRemoteWarmSessionRecovery(input: {
.from(heartbeatRuns).innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
.where(and(eq(heartbeatRuns.id, identity.runId), eq(heartbeatRuns.companyId, binding.companyId),
eq(heartbeatRuns.agentId, binding.agentId), eq(heartbeatRuns.nativeIssueId, binding.issueId)));
if (!prior || prior.run.status !== "succeeded" || prior.owner.phase !== "committed" ||
!prior.owner.resultId || prior.owner.leaseOwner || !prior.owner.controllerPid ||
if (!prior || prior.run.status !== "succeeded" || !prior.owner.resultId || !prior.owner.controllerPid ||
!prior.owner.controllerProcessStartedAt || !prior.owner.controllerBootId) return deny();
const previous = parseNativeExecutionInput(prior.run.runnerProfileJson?.nativeExecutionInput);
if (previous.binding.runId !== identity.runId || !input.matchesExecution(previous)) return deny();
if (prior.owner.leaseOwner && prior.owner.leaseExpiresAt && prior.owner.leaseExpiresAt > new Date()) {
throw new NativeControllerRecoveryWaitingError(prior.owner.leaseExpiresAt);
}
if (prior.owner.phase !== "committed" || prior.owner.leaseOwner) return deny();
const checkpoint = input.loadCheckpoint(previous);
if (!checkpoint || checkpoint.identity.runId !== identity.runId ||
checkpoint.identity.companyId !== binding.companyId || checkpoint.identity.issueId !== binding.issueId ||