mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## 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>
328 lines
20 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|