Files
PaperClipAI/server/src/__tests__/environment-runtime-reusable-resume-replacement.test.ts
T
DottaandClaude Opus 5.5 566a97b882 fix(native): an unretirable predecessor sends the next attempt to a fresh sandbox (#20863)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs on a reused sandbox keep the previous run's warm Runner,
and a new run must retire that owner before it starts
> - #20844 retires the owner when Paperclip can prove it stopped. Some
owners can never be proven stopped: the controller journal drain
outlives the close grace, the Runner never confirms suspension, or a
parked question leaves a tool input unanswered
> - Then the new run failed before any provider work, and the task
stayed blocked with no automatic continuation
> - The general answer is to leave the old Runner on its sandbox and
start the next attempt on a fresh sandbox restored from the saved
workspace
> - This pull request adds that fallback for every path that retires a
predecessor, and settles a parked question's tool input once its Runner
is gone
> - The benefit is that an unprovable predecessor no longer fails a task
that did no work

## Linked Issues or Issue Description

**What happened?**
An automatic wake started a new run 30 to 60 seconds after a long
successful run on the same task, on the same reused sandbox. The new run
had to retire the previous run's warm session first. The retirement
failed and the run failed before any provider work, with one of:
- `Controller journal drain timed out; preserve the session before
replacing its writer.` (during instruction preparation, before runtime
selection, or in the executor);
- `provider_transport_failed: runner did not durably suspend before
checkpoint` (during instruction preparation);
- `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate;
retirement_refused=journal_tool_input_unanswered` (the previous run had
parked on a question).
The tasks rested `blocked` with a reconciliation hold or with nothing
scheduled. One bounded retry reused the same sandbox and ended
`execution_finalization_deadline_exceeded`, then its recovery refused
with `allocation_ownership_ambiguous`.

**Expected behavior**
A run that did no provider work does not fail the task because its
predecessor cannot be retired. The next attempt drops the reused sandbox
and starts on a fresh one restored from the saved workspace. Unsaved
work is never dropped: when the workspace or the session backup is not
durably saved, the sandbox is kept and today's behavior applies.

**Steps to reproduce**
1. Complete a warm native run on a Daytona sandbox.
2. Wake the task again so that the next run must retire the warm owner
(configuration change, new session, changed agent files).
3. Make the owner's close fail (journal drain timeout, unconfirmed
suspension).
4. The new run fails before provider work and the task is not continued.

Refs #20844, #18373.

## What Changed

- `native-runtime/predecessor-retirement.ts`: a typed pre-provider error
`NativePredecessorRetirementUnprovenError`
(`native_predecessor_retirement_unproven`) and the `replaceBeforeReuse`
lease marker.
- Native executor: every path that retires a predecessor for a new run
(instruction-copy claim, instruction-directory reservation, session
reset, configuration change) throws the typed error when the close
fails. The owner stays registered for its own sandbox, so no run on that
sandbox launches over it. A run on a different sandbox forgets that
owner instead of replaying its cached rejected close. The executor does
not settle this error as a provider failure.
- A prior-owner fence that refuses a terminal owner on a resumed sandbox
(`prior_owner_terminal_state_indeterminate`) also throws the typed
error.
- Retirement of a finished remote owner accepts an unanswered tool input
when the owner's committed result is a governed-wait park
(`response_wake` on a task interaction) and its Runner's sandbox lease
ended elsewhere.
- Heartbeat (`heartbeat/predecessor-retirement-fallback.ts`): on the
typed error with no execution-start evidence and no result, mark this
run's reusable lease on that sandbox `replaceBeforeReuse` and log
`environment.lease_replacement_requested`. After the run ends, `failRun`
hands the prepared workspace on only through the shared unchanged-files
proof (`proveRemoteWorkspaceUnchanged`; a provisioning command or any
runner command refuses it). With the proof it records
`predecessorRetirementFallback`, closes the run's coordinator without a
result and re-queues the run in the uncharged `interrupted_preparation`
lane (native and legacy runtime modes); a run that answered a task
interaction keeps its own wake in that uncharged lane, so the retry
still carries the answer. Without the proof the workspace stays dirty,
heartbeat runs today's failed-workspace preservation and no retry is
scheduled.
- Environment runtime: a marked lease is replaced instead of resumed,
under the same rule as a failed resume
(`reusableSandboxReplacementIsSafe`: run ended, clean workspace with a
committed save, verified harness backup). The old lease goes to
`pending_cleanup` for the sweep. Otherwise the sandbox is resumed as
before.
- Chaos: session fault SR1U, the SR1 variant where the retained owner
cannot be retired. The server gets a test-only fault
(`PAPERCLIP_TEST_WARM_RETIREMENT_FAULT=unconfirmed`, armed only when
`NODE_ENV=test`). The oracle allows only the fallback failure and
requires the succeeding follow-up on a different sandbox.
- Docs: `doc/plugins/PLUGIN_RUNNER_EXECUTION.md`, chaos README.

## Verification

- RED on master before this change (with #20844), then GREEN:
- `native-session-executor.test.ts -t "predecessor cannot be retired"`:
base fails with `Error: Controller journal drain timed out…` instead of
the typed error.
- `native-session-executor.test.ts -t "unretired predecessor"`
(instruction preparation): same base failure.
- `native-recovered-owner-followup.test.ts -t "unacknowledged"`: base
fails with `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate;
retirement_refused=journal_tool_input_unanswered`.
- New contract tests: `heartbeat-native-predispatch-retry.test.ts`
(claimed native run: lease marked, activity logged, uncharged
`interrupted_preparation` retry, no recovery action, task not blocked,
retry succeeds) and
`environment-runtime-reusable-resume-replacement.test.ts` (a marked
lease is replaced without a resume; with unsaved work or no verified
backup it resumes as before),
`remote-workspace-unchanged-release.test.ts` (an untouched workspace is
handed on; a setup-written one stays dirty) and `retries.test.ts` (an
answered interaction re-queued in the uncharged lane stays a
resolved-interaction continuation with no failure retry spent).
- Existing suites pass: the native executor, recovered-owner follow-up,
instruction working copy, run completion, run failure cause and resume
replacement test files. `pnpm exec vitest run --config
tests/restart-chaos/vitest.config.ts question-handoff scheduler config`
passes. Server and chaos harness `tsc --noEmit` pass.
- Not run: a Daytona SR1U wave. It needs a runner image that matches
this checkout.

## Risks

- A sandbox marked for replacement is resumed when it cannot be replaced
safely. That run can meet the same unretired owner again, fail again in
the bounded lane, and then rest with Retry, as today.
- The replacement session restores the latest verified harness backup.
When the old Runner's final checkpoint was never saved, a same-session
continuation resumes from that backup.
- An unanswered tool input is accepted only for a committed
governed-wait park whose Runner's sandbox lease ended on another
sandbox.
- Previous-release story: the marker and the run record are additive
JSON. An older server ignores the marker and resumes the sandbox as
before.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, agentic coding with
tool use (shell, file edits, test runs) in Claude Code.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:10:24 -06:00

328 lines
20 KiB
TypeScript

import { JsonRpcCallError, environmentResumeErrorData } from "@paperclipai/plugin-sdk";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
agents,
companies,
createDb,
environmentLeases,
environments,
executionWorkspaceSnapshots,
executionWorkspaces,
heartbeatRuns,
plugins,
projects,
} from "@paperclipai/db";
import type { Environment } from "@paperclipai/shared";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { environmentRuntimeService } from "../services/environment-runtime.ts";
import { environmentService } from "../services/environments.ts";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts";
// Durable harness-backup verification has its own tests. Here it is a boundary:
// a lease-bound stamp verifies, a missing stamp (an older release's lease) does not.
vi.mock("../services/native-runtime/native-harness-backup-storage.js", () => ({
verifyRecoverableNativeHarnessBackup: vi.fn(async (input: { stamp: unknown; providerLeaseId: string }) =>
Boolean(input.stamp && (input.stamp as { authorizedProviderLeaseId?: unknown }).authorizedProviderLeaseId === input.providerLeaseId)),
}));
const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ENVIRONMENT_RUNTIME_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
// The staging failure: Daytona kept the idle sandbox in a state transition past
// its five-minute start deadline, and the worker reported it with no status.
const STAGING_REFUSAL = "Daytona sandbox 0f6c1e52-3d1a-4b7e-9c2a-5e8f7a1b2c3d is still stopping at its start deadline";
describeEmbeddedPostgres("reusable sandbox resume failure replacement", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
let connectionString: string;
beforeAll(async () => {
if (externalTestDatabaseUrl) connectionString = externalTestDatabaseUrl;
else {
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-resume-replacement");
stopDb = started.cleanup;
connectionString = started.connectionString;
}
});
beforeEach(() => { db = createDb(connectionString); });
afterEach(async () => {
vi.restoreAllMocks();
await db.delete(executionWorkspaceSnapshots);
await db.delete(environmentLeases);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(environments);
await db.delete(executionWorkspaces);
await db.delete(plugins);
await db.delete(projects);
await db.delete(companies);
await db.$client.end({ timeout: 1 });
});
afterAll(async () => { await stopDb?.(); });
type Shape = {
leaseStatus?: "retained" | "released";
previousRunStatus?: "succeeded" | "running";
dirty?: boolean;
snapshot?: "committed" | "pending" | null;
backupStamp?: boolean;
};
async function seed(shape: Shape = {}) {
const companyId = randomUUID();
const agentId = randomUUID();
const environmentId = randomUUID();
const pluginId = randomUUID();
const previousRunId = randomUUID();
const runId = randomUUID();
const projectId = randomUUID();
const executionWorkspaceId = randomUUID();
const providerLeaseId = "0f6c1e52-3d1a-4b7e-9c2a-5e8f7a1b2c3d";
// The staging environment's provider settings, including its 300s start deadline.
const providerConfig = { provider: "fake-plugin", image: "fake:test", timeoutMs: 300_000, reuseLease: true };
await db.insert(companies).values({ id: companyId, name: "Acme", status: "active" });
await db.insert(agents).values({ id: agentId, companyId, name: "Coder", role: "engineer", status: "active",
adapterType: "paperclip_runner", adapterConfig: {}, runtimeConfig: {}, permissions: {} });
await db.insert(environments).values({ id: environmentId, name: "Paperclip Sandbox", driver: "sandbox", status: "active",
config: providerConfig });
await db.insert(plugins).values({
id: pluginId, pluginKey: "acme.reusable-sandbox-provider", packageName: "@acme/reusable-sandbox-provider",
version: "1.0.0", apiVersion: 1, categories: ["automation"], status: "ready", installOrder: 1, updatedAt: new Date(),
manifestJson: {
id: "acme.reusable-sandbox-provider", apiVersion: 1, version: "1.0.0", displayName: "Reusable Sandbox Provider",
description: "Test provider", author: "Paperclip", categories: ["automation"],
capabilities: ["environment.drivers.register"], entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{ driverKey: "fake-plugin", kind: "sandbox_provider", displayName: "Fake Plugin",
supportsReusableLeases: true,
configSchema: { type: "object", properties: { image: { type: "string" }, timeoutMs: { type: "number" }, reuseLease: { type: "boolean" } } } }],
},
} as never);
await db.insert(projects).values({ id: projectId, companyId, name: "Project", status: "active" });
await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, projectId, mode: "isolated_workspace",
strategyType: "project_primary", name: "Task workspace", status: "active", providerType: "cloud_sandbox",
cwd: `/workspaces/${executionWorkspaceId}` });
await db.insert(heartbeatRuns).values([
{ id: previousRunId, companyId, agentId, invocationSource: "assignment", status: "running" },
{ id: runId, companyId, agentId, invocationSource: "assignment", status: "running" },
]);
const environment: Environment = { id: environmentId, name: "Paperclip Sandbox", description: null, driver: "sandbox",
status: "active", config: providerConfig, envVars: {}, metadata: null, createdAt: new Date(), updatedAt: new Date() };
// The previous run creates the sandbox through the real acquisition path,
// so the preserved lease carries exactly the identity production records.
const priming = environmentRuntimeService(db, { pluginWorkerManager: {
isRunning: () => true,
call: async (_pluginId: string, method: string) => {
if (method !== "environmentAcquireLease") throw new Error(`Unexpected plugin method: ${method}`);
return { providerLeaseId, metadata: { ...providerConfig, sandboxId: providerLeaseId, remoteCwd: "/home/daytona/paperclip-workspace" } };
},
getWorker: () => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] }),
} as unknown as PluginWorkerManager });
const created = await priming.acquireRunLease({ companyId, environment, issueId: null, agentId, adapterType: "paperclip_runner",
heartbeatRunId: previousRunId, persistedExecutionWorkspace: { id: executionWorkspaceId, mode: "isolated_workspace" } });
// The run then ended after a clean final save and left its lease-bound
// harness backup stamp. A lease saved by an older release has no
// acquisition outcome and may predate the backup stamp.
const { sandboxLeaseAcquisition: _outcome, ...metadata } = created.lease.metadata ?? {};
const lease = (await environmentService(db).updateLeaseMetadata(created.lease.id, {
...(shape.leaseStatus === "released" ? metadata : created.lease.metadata),
remoteWorkspaceId: executionWorkspaceId,
...(shape.backupStamp === false ? {} : { nativeHarnessBackup: {
schema: "paperclip.native-harness-backup-stamp.v2", sourceProviderLeaseId: providerLeaseId,
authorizedProviderLeaseId: providerLeaseId,
} }),
}))!;
await db.update(environmentLeases).set({ status: shape.leaseStatus ?? "retained" }).where(eq(environmentLeases.id, lease.id));
await db.update(heartbeatRuns).set({ status: shape.previousRunStatus ?? "succeeded" }).where(eq(heartbeatRuns.id, previousRunId));
const snapshotId = randomUUID();
if (shape.snapshot !== null) {
await db.insert(executionWorkspaceSnapshots).values({ id: snapshotId, companyId, executionWorkspaceId,
sourceOperationId: randomUUID(), sourceRunId: previousRunId, generation: 1, epoch: 1, status: shape.snapshot ?? "committed" });
}
// This run already holds the workspace writer slot (reserved before the
// environment is acquired); the saved version is the previous run's save.
await db.update(executionWorkspaces).set({ metadata: { remoteWorkspace: {
schema: "paperclip.remote-workspace.v1", epoch: 2, snapshotId: shape.snapshot === null ? null : snapshotId,
source: { kind: "empty" }, writerRunId: runId, dirty: shape.dirty ?? false,
location: { environmentId, leaseId: lease.id, providerLeaseId, remoteCwd: "/home/daytona/paperclip-workspace/.paperclip-workspaces/w" },
} } }).where(eq(executionWorkspaces.id, executionWorkspaceId));
return { companyId, agentId, environment, pluginId, runId, executionWorkspaceId, lease, providerLeaseId };
}
function worker(seeded: Awaited<ReturnType<typeof seed>>, resume: (attempt: number) => Promise<unknown>,
options: { acquireFails?: boolean } = {}) {
let attempts = 0;
const call = vi.fn(async (_pluginId: string, method: string, _params: unknown, _timeoutMs?: number) => {
if (method === "environmentResumeLease") return await resume(++attempts);
if (method === "environmentAcquireLease") {
if (options.acquireFails) throw new JsonRpcCallError({ code: -32000, message: "create refused" });
return { providerLeaseId: "replacement-sandbox", metadata: { provider: "fake-plugin", image: "fake:test",
timeoutMs: 300_000, reuseLease: true, remoteCwd: "/home/daytona/paperclip-workspace" } };
}
throw new Error(`Unexpected plugin method: ${method}`);
});
const manager = { isRunning: vi.fn((id: string) => id === seeded.pluginId), call,
getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })),
} as unknown as PluginWorkerManager;
return { call, runtime: environmentRuntimeService(db, { pluginWorkerManager: manager }) };
}
function acquire(seeded: Awaited<ReturnType<typeof seed>>, runtime: ReturnType<typeof environmentRuntimeService>,
runId = seeded.runId) {
return runtime.acquireRunLease({ companyId: seeded.companyId, environment: seeded.environment, issueId: null,
agentId: seeded.agentId, adapterType: "paperclip_runner", heartbeatRunId: runId,
persistedExecutionWorkspace: { id: seeded.executionWorkspaceId, mode: "isolated_workspace" } });
}
// Each refused resume consumes 40s of the run's clock, so the in-run retry
// budget is exercised without real waiting.
function stepClock() {
let clock = Date.now();
vi.spyOn(Date, "now").mockImplementation(() => clock);
return () => { clock += 40_000; };
}
it("replaces a clean, saved sandbox that keeps refusing to resume, within a bounded budget (staging shape)", async () => {
const seeded = await seed();
const step = stepClock();
const { call, runtime } = worker(seeded, async () => {
step();
throw new JsonRpcCallError({ code: -32000, message: STAGING_REFUSAL });
});
const acquired = await acquire(seeded, runtime);
expect(acquired.lease.providerLeaseId).toBe("replacement-sandbox");
expect(acquired.lease.metadata?.sandboxLeaseAcquisition).toEqual({ outcome: "replacement", reason: "resume_failed" });
const resumes = call.mock.calls.filter(([, method]) => method === "environmentResumeLease");
// Retried inside the run, then stopped well short of the five-minute deadline.
expect(resumes.length).toBeGreaterThan(1);
for (const [, , , timeoutMs] of resumes) expect(timeoutMs).toBeLessThanOrEqual(90_000);
// The old sandbox is handed to the cleanup sweep; no synchronous destroy
// delays the first turn.
expect(call).not.toHaveBeenCalledWith(seeded.pluginId, "environmentDestroyLease", expect.anything(), expect.anything());
await expect(environmentService(db).getLeaseById(seeded.lease.id)).resolves.toMatchObject({
status: "pending_cleanup", cleanupStatus: "failed", failureReason: "resume_failed",
});
});
it("keeps the preserved lease when the replacement cannot be created, so the next attempt replaces it", async () => {
const seeded = await seed();
const refuse = async () => { throw new JsonRpcCallError({ code: -32000, message: STAGING_REFUSAL }); };
const step = stepClock();
const failing = worker(seeded, async () => { step(); return await refuse(); }, { acquireFails: true });
await expect(acquire(seeded, failing.runtime)).rejects.toThrow();
await expect(environmentService(db).getLeaseById(seeded.lease.id)).resolves.toMatchObject({
status: "retained", providerLeaseId: seeded.providerLeaseId,
});
// The bounded retry runs as a new run that reserves the same clean workspace.
const retryRunId = randomUUID();
await db.insert(heartbeatRuns).values({ id: retryRunId, companyId: seeded.companyId, agentId: seeded.agentId,
invocationSource: "assignment", status: "running" });
await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, seeded.runId));
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
await db.update(executionWorkspaces).set({ metadata: { ...workspace!.metadata,
remoteWorkspace: { ...(workspace!.metadata!.remoteWorkspace as Record<string, unknown>), writerRunId: retryRunId } } })
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
const retry = worker(seeded, async () => { step(); return await refuse(); });
const acquired = await acquire(seeded, retry.runtime, retryRunId);
expect(acquired.lease.metadata?.sandboxLeaseAcquisition).toEqual({ outcome: "replacement", reason: "resume_failed" });
// The replacement carries the verified harness backup, so the session restores
// without waiting for the old sandbox to be deleted.
expect(acquired.lease.metadata?.nativeHarnessBackup).toMatchObject({ authorizedProviderLeaseId: seeded.providerLeaseId });
await expect(environmentService(db).getLeaseById(seeded.lease.id)).resolves.toMatchObject({ status: "pending_cleanup" });
});
it("replaces a released lease saved by an older release in the same way", async () => {
const seeded = await seed({ leaseStatus: "released" });
const step = stepClock();
const { runtime } = worker(seeded, async () => {
step();
throw new JsonRpcCallError({ code: -32000, message: "Sandbox state change in progress",
data: environmentResumeErrorData(Object.assign(new Error("private"), { statusCode: 409 })) });
});
const acquired = await acquire(seeded, runtime);
expect(acquired.lease.metadata?.sandboxLeaseAcquisition).toEqual({ outcome: "replacement", reason: "resume_failed" });
});
// Staging: a run could not retire its predecessor's warm
// Runner on this sandbox, did no provider work, and asked for a fresh one.
async function markReplaceBeforeReuse(seeded: Awaited<ReturnType<typeof seed>>) {
await environmentService(db).updateLeaseMetadata(seeded.lease.id, { ...seeded.lease.metadata,
replaceBeforeReuse: { schema: "paperclip.replace-before-reuse.v1", runId: randomUUID(), predecessorRunId: randomUUID(),
recordedAt: new Date().toISOString() } });
}
it("replaces a resumable sandbox whose previous Runner could not be retired, without resuming it", async () => {
const seeded = await seed();
await markReplaceBeforeReuse(seeded);
const { call, runtime } = worker(seeded, async () => ({ providerLeaseId: seeded.providerLeaseId,
metadata: { remoteCwd: "/home/daytona/paperclip-workspace" } }));
const acquired = await acquire(seeded, runtime);
expect(acquired.lease.providerLeaseId).toBe("replacement-sandbox");
expect(acquired.lease.metadata?.sandboxLeaseAcquisition).toEqual({ outcome: "replacement", reason: "resume_failed",
cause: "native_predecessor_retirement_unproven" });
// The new sandbox restores the saved session from the verified backup.
expect(acquired.lease.metadata?.nativeHarnessBackup).toMatchObject({ authorizedProviderLeaseId: seeded.providerLeaseId });
expect(call).not.toHaveBeenCalledWith(seeded.pluginId, "environmentResumeLease", expect.anything(), expect.anything());
// The old sandbox, with the Runner on it, is kept for the cleanup sweep.
await expect(environmentService(db).getLeaseById(seeded.lease.id)).resolves.toMatchObject({ status: "pending_cleanup" });
});
it.each([
["unsaved work on the sandbox", { dirty: true }],
["no verified harness backup", { backupStamp: false }],
] as Array<[string, Shape]>)("resumes a sandbox marked for replacement as before when it holds %s", async (_label, shape) => {
const seeded = await seed(shape);
await markReplaceBeforeReuse(seeded);
const { runtime } = worker(seeded, async () => ({ providerLeaseId: seeded.providerLeaseId,
metadata: { remoteCwd: "/home/daytona/paperclip-workspace" } }));
const acquired = await acquire(seeded, runtime);
expect(acquired.lease.providerLeaseId).toBe(seeded.providerLeaseId);
expect(acquired.lease.metadata?.sandboxLeaseAcquisition).toEqual({ outcome: "resumed" });
});
it("retries a sandbox state change inside the run and resumes the same sandbox", async () => {
const seeded = await seed();
const { call, runtime } = worker(seeded, async (attempt) => {
if (attempt === 1) throw new JsonRpcCallError({ code: -32000, message: "Sandbox state change in progress" });
return { providerLeaseId: seeded.providerLeaseId, metadata: { remoteCwd: "/home/daytona/paperclip-workspace" } };
});
const acquired = await acquire(seeded, runtime);
expect(acquired.lease.providerLeaseId).toBe(seeded.providerLeaseId);
expect(acquired.lease.metadata?.sandboxLeaseAcquisition).toEqual({ outcome: "resumed" });
expect(call.mock.calls.filter(([, method]) => method === "environmentResumeLease")).toHaveLength(2);
expect(call).not.toHaveBeenCalledWith(seeded.pluginId, "environmentAcquireLease", expect.anything(), expect.anything());
});
it.each([
["unsaved work on the sandbox", { dirty: true }, undefined],
["no saved workspace version", { snapshot: null }, undefined],
["a saved version that never committed", { snapshot: "pending" }, undefined],
["a live owner", { previousRunStatus: "running" }, undefined],
["no verified harness backup (older lease)", { backupStamp: false }, undefined],
["a provider access refusal", {}, 403],
] as Array<[string, Shape, number | undefined]>)("keeps the sandbox and fails strictly with %s", async (_label, shape, status) => {
const seeded = await seed(shape);
const step = stepClock();
const { call, runtime } = worker(seeded, async () => {
step();
throw new JsonRpcCallError({ code: -32000, message: STAGING_REFUSAL,
...(status ? { data: environmentResumeErrorData(Object.assign(new Error("private"), { statusCode: status })) } : {}) });
});
await expect(acquire(seeded, runtime)).rejects.toThrow(
/could not be resumed \(cause=sandbox_state_transition( status=403)?\); the lease was preserved and no replacement was created\.$/,
);
expect(call).not.toHaveBeenCalledWith(seeded.pluginId, "environmentAcquireLease", expect.anything(), expect.anything());
expect(call).not.toHaveBeenCalledWith(seeded.pluginId, "environmentDestroyLease", expect.anything(), expect.anything());
await expect(environmentService(db).getLeaseById(seeded.lease.id)).resolves.toMatchObject({
status: shape.leaseStatus ?? "retained", providerLeaseId: seeded.providerLeaseId,
});
});
});