fix(recovery): reopen retained sources under preservation ownership

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-20 22:49:20 -05:00
1 parent 65f59e339a
commit 66990f4208
8 files changed
+130 -8

No files matched your search

+8
View File
@@ -33,6 +33,12 @@ The source recovery action holds a validated `systemRecovery` record. Its
revision and generation fence late bookkeeping writes. Physical exports still
use the existing workspace ownership lock and controller stop receipts.
The export owner can reopen its exact stopped sandbox through the environment
runtime's recovery path. This path checks the current source action, run,
environment, allocation, stop-only intent, and physical ownership claim before
calling the provider. The retained-source fence still blocks ordinary resume
and destruction. Recovery does not clear it to gain access to the saved files.
Execution containment and file preservation are independent facts:
- Another agent needs authoritative containment of the old executor.
@@ -113,6 +119,8 @@ Run the focused PostgreSQL/filesystem suites from `server/`:
`server/scripts/workspace-recovery-provider.test.mjs` is an opt-in controlled
Daytona probe. It requires `PAPERCLIP_RECOVERY_LIVE=1`, `DAYTONA_API_KEY`,
`PAPERCLIP_DAYTONA_SDK_PATH` and an immutable `PAPERCLIP_RECOVERY_IMAGE`.
An immutable provider snapshot UUID in `PAPERCLIP_RECOVERY_SNAPSHOT_ID` can
replace the image digest. The probe checks its ID and active state first.
It interrupts export,
joins the first controller, starts a second controller and verifies files,
preservation receipts, agent-start count and external-operation count before
+6
View File
@@ -212,6 +212,12 @@ Gaps: An acknowledged retry can remain pending or fail; report actual useful res
Capability ID: `recovery/workspace`.
The internal preservation resume path grants no board or agent execution API.
It requires the exact source action, company, run, lease and environment, a
held physical workspace owner, and its current stop-only cleanup claim. The
ordinary resume and destruction fences still apply to protected sources. The
assembled-runtime regression checks this separation before any provider call.
Review: `operation-reviewed`.
Each workspace repair/export/reconstruction/physical-owner operation has its own proof and authority.
+8
View File
@@ -222,6 +222,14 @@ cover quiet live recovery, read-only access, stale or missing stop proof,
source-only repair, and preserved task status. These tests use fixtures; separately
verify original files and their attached artifact before recording a real repair.
For a source with a retained-file protection marker, exercise recovery through
the assembled environment runtime. Its exact preservation owner may reopen the
stopped allocation to read saved files; ordinary resume stays blocked. Reject a
changed claim, missing stop intent, wrong lease or wrong environment before a
provider call. Verify saved and newer local files, one preservation receipt, and
no additional agent turn. The PostgreSQL regression covers this boundary; the
opt-in provider probe also uses this runtime path with the protection marker.
Repeat source-only repair with a `succeeded` legacy run that still has a retained
file obligation. Its result must remain successful, its current task owner and
status must stay unchanged, and later saved input must become eligible for normal
@@ -15,6 +15,7 @@ import { NOOP_PLUGIN_TRACER } from "@paperclipai/plugin-sdk";
import { startEmbeddedPostgresTestDatabase } from "../src/__tests__/helpers/embedded-postgres.js";
import { readSystemRecovery } from "../src/services/system-recovery-state.js";
import { recoverRetainedWorkspace } from "../src/services/workspace-recovery.js";
import { classifyEnvironmentCapabilities, environmentRuntimeService } from "../src/services/environment-runtime.js";
import { attachPreservedWorkspaceSnapshots } from "../src/services/workspace-recovery-snapshot.js";
import { performSyncOut } from "../../packages/plugins/sandbox-providers/daytona/src/file-sync.js";
@@ -68,7 +69,11 @@ it.skipIf(!live || !worker)("provider controller", async () => {
},
syncOut: operations => performSyncOut({ sandbox, operations, remoteDir: f.remoteCwd, timeoutSeconds: 120 }),
} };
const environmentRuntime = {
const driver = {
driver: "sandbox",
acquireRunLease: async () => { throw new Error("Recovery must not acquire another sandbox"); },
releaseRunLease: async () => { throw new Error("Recovery must not destroy its retained source"); },
resolveCapabilities: async () => classifyEnvironmentCapabilities({ verifiedMethods: [] }),
resumeRunLease: async () => {
await sandbox.start(120); await sandbox.refreshData();
if (sandbox.state !== "started") throw new Error("Provider did not confirm start");
@@ -80,6 +85,7 @@ it.skipIf(!live || !worker)("provider controller", async () => {
return { providerLeaseId: sandbox.id, state: "stopped" };
},
};
const environmentRuntime = environmentRuntimeService(db, { drivers: [driver] });
await recoverRetainedWorkspace({ db, ...f, environmentRuntime });
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId));
const state = readSystemRecovery(action.evidence);
@@ -90,7 +96,9 @@ it.skipIf(!live || !worker)("provider controller", async () => {
it.skipIf(!live || Boolean(worker))("preserves provider files across an interrupted export and controller restart without restarting the old agent", async () => {
const image = process.env.PAPERCLIP_RECOVERY_IMAGE;
if (!image?.includes("@sha256:")) throw new Error("An immutable qualification image is required");
const snapshotId = process.env.PAPERCLIP_RECOVERY_SNAPSHOT_ID;
if (!image?.includes("@sha256:") && !/^[0-9a-f-]{36}$/i.test(snapshotId ?? ""))
throw new Error("An immutable qualification image or snapshot ID is required");
const directory = await mkdtemp(path.join(os.tmpdir(), "paperclip-provider-recovery-"));
const priorHome = process.env.PAPERCLIP_HOME;
process.env.PAPERCLIP_HOME = path.join(directory, "home");
@@ -100,10 +108,15 @@ it.skipIf(!live || Boolean(worker))("preserves provider files across an interrup
const db = createDb(database.connectionString);
const daytona = await client();
let sandbox;
const evidence = { image, interrupted: false, restored: false, oldAgentStarts: null, externalOperations: null,
const evidence = { image, snapshotId, interrupted: false, restored: false, oldAgentStarts: null, externalOperations: null,
prematureCleanup: false, controllerProcesses: [], snapshotCount: 0 };
try {
sandbox = await daytona.create({ image, autoStopInterval: 10, autoArchiveInterval: 60,
if (snapshotId) {
const snapshot = await daytona.snapshot.get(snapshotId);
expect(snapshot.id).toBe(snapshotId);
expect(snapshot.state).toBe("active");
}
sandbox = await daytona.create({ ...(snapshotId ? { snapshot: snapshotId } : { image }), autoStopInterval: 10, autoArchiveInterval: 60,
resources: { cpu: 2, memory: 4, disk: 10 }, labels: { purpose: "paperclip-recovery-qualification" } }, { timeout: 300 });
const remoteCwd = "/tmp/paperclip-recovery-fixture";
await sandbox.process.executeCommand(`mkdir -p ${remoteCwd}`, undefined, undefined, 30);
@@ -124,7 +137,8 @@ it.skipIf(!live || Boolean(worker))("preserves provider files across an interrup
const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId,
environmentId: environment.id, provider: "daytona", providerLeaseId: sandbox.id,
leasePolicy: "retain_on_failure", status: "released", releasedAt: new Date(),
metadata: { remoteCwd, driver: "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: true } }).returning();
metadata: { remoteCwd, driver: "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: true,
workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", runId } } }).returning();
const recovery = { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [lease.id] };
await db.update(heartbeatRuns).set({ resultJson: { workspaceRestoreRecovery: recovery, workspaceRestoreFailure: "restore_failed" } })
.where(eq(heartbeatRuns.id, runId));
@@ -4,6 +4,8 @@ import { allowLegacyShutdownWorkspaceCleanup, legacyShutdownWorkspaceResourcePro
import { nativeWorkspaceExportPendingForLease } from "./native-runtime/native-workspace-finalization-state.js";
import { beginIdleTrackedWork } from "./task-admission.js";
import { historicalProviderCredentials } from "./historical-provider-credentials.js";
import { ownedWorkspaceRecoveryLease, type WorkspaceRecoverySourceIdentity } from "./workspace-recovery-source.js";
import type { NativeWorkspaceFinalizationOwnership } from "./native-runtime/native-workspace-finalization-ownership.js";
import { hasStopOnlyCleanup, prepareSandboxStopAndRetain, readStopOnlyCleanup, settleStopOnlyCleanup, stopOnlyCleanupKey } from "./sandbox-stop-and-retain.js";
import { JsonRpcCallError, readEnvironmentAcquisitionDiagnostic, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
import { preserveEnvironmentSyncOutErrorDiagnostic } from "./environment-sync-out-error.js";
@@ -4569,6 +4571,19 @@ export function environmentRuntimeService(
return await driver.resumeRunLease(input);
},
async resumeWorkspaceRecoveryLease(input: {
source: WorkspaceRecoverySourceIdentity; leaseId: string; environmentId: string;
ownership: NativeWorkspaceFinalizationOwnership;
}): Promise<PluginEnvironmentLease | EnvironmentLease | null> {
const { lease, environment } = await ownedWorkspaceRecoveryLease(db, { ...input.source,
leaseId: input.leaseId, environmentId: input.environmentId, ownership: input.ownership });
const driver = requireDriverKey(getLeaseDriverKey(lease, environment as Environment));
if (!driver.resumeRunLease) throw new Error(`Environment driver "${driver.driver}" does not support lease resume.`);
// Maintenance reopens the protected source only to preserve its files.
// Ordinary resume and destructive cleanup keep their existing fences.
return await driver.resumeRunLease({ environment: environment as Environment, lease: lease as EnvironmentLease });
},
async destroyRunLease(input: EnvironmentDriverLeaseInput): Promise<EnvironmentLease | null> {
if (await nativeWorkspaceExportPendingForLease(db, input.lease)) return null;
if (await legacyShutdownWorkspaceResourceProtected(db, input.lease)) return null;
@@ -3,11 +3,31 @@ import { environmentLeases, environments, heartbeatRuns, issueRecoveryActions, n
import { adapterExecutionControls } from "./adapter-execution-control.js";
import { readNativeWorkspaceSyncReference } from "./native-runtime/native-workspace-sync.js";
import { SystemRecoveryFailure } from "./system-recovery-state.js";
import type { NativeWorkspaceFinalizationOwnership } from "./native-runtime/native-workspace-finalization-ownership.js";
import { readStopOnlyCleanup } from "./sandbox-stop-and-retain.js";
export const workspaceExportCauses = ["native_workspace_sync_out_unrecoverable", "native_workspace_sync_out_retry_exhausted"];
export const terminalWorkspaceRunStates = ["failed", "interrupted", "timed_out", "cancelled", "succeeded"];
export type WorkspaceRecoverySourceIdentity = { companyId: string; issueId: string; runId: string; actionId: string };
/** The preservation owner may reopen only its exact stop-only allocation.
* This does not release the source's ordinary execution or destruction fence. */
export async function ownedWorkspaceRecoveryLease(db: Db, input: WorkspaceRecoverySourceIdentity & {
leaseId: string; environmentId: string; ownership: NativeWorkspaceFinalizationOwnership;
}) {
await input.ownership.assertHeld();
const inspected = await inspectWorkspaceRecoverySource(db, input);
const source = inspected.sources.find(item => item.lease.id === input.leaseId);
if (!source || source.environment.id !== input.environmentId || source.lease.status !== "pending_cleanup"
|| source.lease.metadata?.pendingCleanupInFlight !== true
|| source.lease.metadata?.pendingCleanupAttemptId !== input.ownership.token
|| !readStopOnlyCleanup(source.lease)) {
throw new SystemRecoveryFailure("source_binding_invalid", "service_required");
}
await input.ownership.assertHeld();
return source;
}
/** Resolve only the immutable source recorded by the failed run. Never discover
* a candidate by recency, mutable adapter configuration, or a provider search. */
export async function inspectWorkspaceRecoverySource(db: Db, input: WorkspaceRecoverySourceIdentity) {
+52 -1
View File
@@ -1,6 +1,7 @@
import { prepareHeartbeatWorkspace } from "./heartbeat/workspace-preparation.js";
import { createHeartbeatWorkspaceResolver, resolveExecutionWorkspaceReuseRequestForIssue } from "./heartbeat/workspaces.js";
import { environmentService } from "./environments.js";
import { classifyEnvironmentCapabilities, environmentRuntimeService } from "./environment-runtime.js";
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { workspaceOperationService } from "./workspace-operations.js";
@@ -111,7 +112,11 @@ describe("automatic retained workspace recovery", () => {
fingerprint: runId, nextAction: "Manual repair required", evidence: { runId, workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: recovery } });
const runtime = { resumeRunLease: vi.fn(async (_input: { lease: EnvironmentLease }) => ({ providerLeaseId, metadata: { remoteCwd: remote } })),
retryPendingSandboxTeardown: vi.fn(async () => ({ providerLeaseId, state: "stopped" })) };
const input = { db, companyId, issueId, runId, actionId, environmentRuntime: runtime as never };
const environmentRuntime = environmentRuntimeService(db, { drivers: [{ driver: "sandbox",
acquireRunLease: vi.fn(), releaseRunLease: vi.fn(), ...runtime,
resolveCapabilities: async () => classifyEnvironmentCapabilities({ verifiedMethods: [], declared: null, narrowing: null }),
}] });
const input = { db, companyId, issueId, runId, actionId, environmentRuntime };
return { ...input, input, runtime, host, remote, lease, providerLeaseId };
}
const action = async (id: string) => (await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, id)))[0]!;
@@ -283,6 +288,52 @@ describe("automatic retained workspace recovery", () => {
expect(await db.select().from(workspaceOperations).where(and(eq(workspaceOperations.companyId, f.companyId), eq(workspaceOperations.phase, "workspace_recovery")))).toHaveLength(1);
}, 30_000);
it("recovers a protected retained source through the assembled environment runtime", async () => {
const f = await seed();
await db.update(environmentLeases).set({ metadata: { ...f.lease.metadata,
workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", runId: f.runId },
remoteExecutionTermination: remoteTerminationReceipt(f.lease, { providerLeaseId: f.providerLeaseId, state: "stopped" }),
} }).where(eq(environmentLeases.id, f.lease.id));
const runtime = f.input.environmentRuntime;
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id));
const [environment] = await db.select().from(environments).where(eq(environments.id, lease.environmentId!));
expect(await runtime.resumeRunLease({ environment: environment as never, lease: lease as EnvironmentLease })).toBeNull();
expect(f.runtime.resumeRunLease).not.toHaveBeenCalled();
await recoverRetainedWorkspace({ ...f.input, environmentRuntime: runtime });
expect(readWorkspaceRecovery((await action(f.actionId)).evidence)?.status).toBe("recovered");
expect(await readFile(path.join(f.host, "saved.txt"), "utf8")).toBe("finished work\n");
expect(await readFile(path.join(f.host, "newer.txt"), "utf8")).toBe("newer local work\n");
expect(f.runtime.resumeRunLease).toHaveBeenCalledOnce();
await recoverRetainedWorkspace({ ...f.input, environmentRuntime: runtime });
expect(f.runtime.resumeRunLease).toHaveBeenCalledOnce();
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
}, 30_000);
it.each(["changed_claim", "missing_stop_intent", "wrong_lease", "wrong_environment"])(
"does not reopen a retained source without exact recovery authority: %s", async kind => {
const f = await seed();
const runtime = f.input.environmentRuntime;
const resume = runtime.resumeWorkspaceRecoveryLease.bind(runtime);
vi.spyOn(runtime, "resumeWorkspaceRecoveryLease").mockImplementationOnce(async input => {
if (kind === "changed_claim") await db.update(environmentLeases).set({ metadata: sql`
${environmentLeases.metadata} || '{"pendingCleanupAttemptId":"other-owner"}'::jsonb`
}).where(eq(environmentLeases.id, f.lease.id));
if (kind === "missing_stop_intent") await db.update(environmentLeases).set({ metadata: sql`
${environmentLeases.metadata} - 'sandboxStopAndRetain'`
}).where(eq(environmentLeases.id, f.lease.id));
return resume({ ...input,
...(kind === "wrong_lease" ? { leaseId: randomUUID() } : {}),
...(kind === "wrong_environment" ? { environmentId: randomUUID() } : {}),
});
});
await recoverRetainedWorkspace(f.input);
expect(readSystemRecovery((await action(f.actionId)).evidence)?.failure?.code).toBe("source_binding_invalid");
expect(f.runtime.resumeRunLease).not.toHaveBeenCalled();
expect(await readFile(path.join(f.remote, "saved.txt"), "utf8")).toBe("finished work\n");
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
}, 30_000);
it("keeps verified copyback files in the preserved workspace on ordinary Retry", async () => {
const f = await seed();
await recoverRetainedWorkspace(f.input);
+2 -2
View File
@@ -169,8 +169,8 @@ export async function recoverRetainedWorkspace(input: Source & { db: Db; environ
}).catch(() => undefined); }, 30_000);
let stopSettled = false;
try {
const opened = await input.environmentRuntime.resumeRunLease({ environment: environment as unknown as Environment,
lease: cleanupLease as EnvironmentLease });
const opened = await input.environmentRuntime.resumeWorkspaceRecoveryLease({ source: input,
leaseId: cleanupLease.id, environmentId: environment.id, ownership });
if (opened?.providerLeaseId !== lease.providerLeaseId || opened.metadata?.remoteCwd !== remoteCwd)
throw new SystemRecoveryFailure("source_binding_invalid", "service_required");
await inspectWorkspaceRecoverySource(db, input);