fix(workspaces): require durable finalization before migration

This commit is contained in:
Dotta committed 2026-11-03 15:04:05 -06:00
1 parent ffcde13f57
commit 1c172fc057
3 files changed
+25 -6

No files matched your search

@@ -77,7 +77,8 @@ provider termination evidence, or permission to delete the original allocation.
After independently verified sandbox preservation, the task accepts the exact
migrated snapshot receipt for continuation without requiring a fresh workspace.
Accepted results with unfinished copyback remain blockers. Once the original
sync receipt is finalized, assessment and status effects can continue from their
sync reference is finalized and its successful `workspace_finalize` operation
receipt exists, assessment and status effects can continue from their
existing database coordinator independently of the file storage migration.
Offline maintenance stops writers; it does not silently change workspace
@@ -28,6 +28,7 @@ import { workspaceOperationService } from "./workspace-operations.js";
import { resumeNativeWorkspaceSync } from "./native-runtime/native-workspace-sync.js";
import { claimNativeSessionResumptions } from "./native-runtime/native-finalization-reconciler.js";
import { claimNativeRestartRecoveries } from "./native-runtime/native-restart-recovery.js";
import { attemptNativeWorkspaceFinalization } from "./native-runtime/native-workspace-finalizer.js";
import { managedProjectCheckoutName } from "./managed-project-checkout-name.js";
import { captureGitWorkspaceContents, readManagedCheckoutContents } from "./git-workspace-contents.js";
@@ -214,7 +215,8 @@ describe.skipIf(!support.supported)("remote workspace migration", () => {
legacyWorkspaceRecoveryMigration: { workspaceId: f.workspaceId } },
});
});
it.each(["prepared", "finalized"])("keeps accepted-result finalization independent of migrated file storage (%s)", async state => {
it.each(["prepared", "finalized", "missing-receipt", "failed-receipt"])("keeps accepted-result finalization independent of migrated file storage (%s)", async mode => {
const state = mode === "prepared" ? "prepared" : "finalized";
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const contractId = randomUUID(), resultId = randomUUID();
await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId, revision: 1,
@@ -226,14 +228,21 @@ describe.skipIf(!support.supported)("remote workspace migration", () => {
await db.insert(nativeRunResults).values({ id: resultId, companyId: f.companyId, issueId: f.issueId, runId: f.runId,
completionContractId: contractId, serverFingerprint: "saved-result", schemaStatus: "accepted", resultJson: { saved: true }, canonicalSha256: "34".repeat(32) });
await db.update(nativeRunFinalizations).set({ phase: "retryable_failure", resultId }).where(eq(nativeRunFinalizations.runId, f.runId));
const operationId = randomUUID();
if (mode === "finalized" || mode === "failed-receipt") await db.insert(workspaceOperations).values({ id: operationId,
companyId: f.companyId, executionWorkspaceId: f.workspaceId, heartbeatRunId: f.runId, issueId: f.issueId,
phase: "workspace_finalize", status: mode === "finalized" ? "succeeded" : "failed" });
const [before] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
const migrate = () => migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
if (state === "prepared") await expect(migrate()).rejects.toThrow("remote_copyback_unresolved");
if (mode !== "finalized") await expect(migrate()).rejects.toThrow(state === "prepared"
? "remote_copyback_unresolved" : "execution_finalization_unresolved");
else {
await migrate();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]!.runnerProfileJson)
.not.toHaveProperty("legacyWorkspaceRecoveryMigration");
expect(await attemptNativeWorkspaceFinalization({ db, runId: f.runId }))
.toMatchObject({ kind: "completed", operation: { id: operationId } });
}
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(before);
});
@@ -137,9 +137,18 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
&& !(row.run.controllerLeaseExpiresAt && row.run.controllerLeaseExpiresAt > new Date())
&& !(row.leaseOwner && (!row.leaseExpiresAt || row.leaseExpiresAt > new Date()));
const reference = readNativeWorkspaceSyncReference(row.run.runnerProfileJson?.nativeWorkspaceSync);
// Finalized copyback has no outstanding local writer. Assessment/status
// effects stay with their existing durable coordinator after the move.
if (stopped && reference?.state === "finalized") continue;
// The finalizer retries from its operation receipt, not the sync descriptor.
// Without that receipt it would try exporting the newly migrated workspace,
// which intentionally has no live location. Keep the local authority until
// the existing owner records completion; then assessment/status can resume.
if (stopped && reference?.state === "finalized") {
const [finalized] = await db.select({ id: workspaceOperations.id }).from(workspaceOperations).where(and(
eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.heartbeatRunId, row.id),
eq(workspaceOperations.issueId, row.run.nativeIssueId!), eq(workspaceOperations.phase, "workspace_finalize"),
eq(workspaceOperations.status, "succeeded"),
)).limit(1);
if (finalized) continue;
}
if (stopped && reference && reference.workspaceId !== workspace.id) {
const [other] = await db.select({ providerType: executionWorkspaces.providerType, cwd: executionWorkspaces.cwd,
sourceIssueId: executionWorkspaces.sourceIssueId, metadata: executionWorkspaces.metadata }).from(executionWorkspaces).where(and(