diff --git a/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md b/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md index 08baa908696..8ba9c30ed72 100644 --- a/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md +++ b/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md @@ -206,7 +206,7 @@ finite offline operation adds no idle background work. A resumed rehearsal can measure the fix but cannot replace fresh full-image migration and empty-home recovery proof. -During offline migration, known task-only assignment, configuration and +During offline migration, known task-only assignment, configuration, deliberate-wait and disposition warnings can remain unchanged while the owner saves local files. They do not certify file recovery and are not resolved by migration. An active system recovery, native execution failure marker, declared workspace recovery diff --git a/server/src/services/remote-workspace-migration.test.ts b/server/src/services/remote-workspace-migration.test.ts index c9cbcbe1ea8..79f5eb7a865 100644 --- a/server/src/services/remote-workspace-migration.test.ts +++ b/server/src/services/remote-workspace-migration.test.ts @@ -151,10 +151,11 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)))[0]).toEqual(run); expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).not.toBeNull(); }); - it.each(["stranded_assigned_issue", "configuration_validation", "missing_disposition"])("preserves a task-only %s warning through offline file migration", async kind => { + it.each([["stranded_assigned_issue", "stranded_assigned_issue"], ["configuration_validation", "configuration_incomplete"], + ["missing_disposition", "successful_run_missing_state"], ["deliberate_wait_without_target", "deliberate_wait_without_target"]])("preserves a task-only %s warning through offline file migration", async (kind, cause) => { const f = await seed(); const [action] = await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, - kind, cause: kind, fingerprint: randomUUID(), nextAction: "Keep the existing task warning", evidence: { latestRunStatus: "failed" } }).returning(); + kind, cause, fingerprint: randomUUID(), nextAction: "Keep the existing task warning", evidence: { latestRunStatus: "failed" } }).returning(); await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_recovery_unresolved"); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true"); await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true }); @@ -167,12 +168,13 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest }); expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n"); }); - it.each(["workspaceRestoreRecovery", "workspaceRecovery", "historicalWorkspaceSource", "systemRecovery", "nativeFailureBlock", "export-cause", "unknown-kind"])("retains unresolved file or execution recovery as an offline blocker (%s)", async mode => { + it.each(["workspaceRestoreRecovery", "workspaceRecovery", "historicalWorkspaceSource", "systemRecovery", "nativeFailureBlock", "export-cause", "unknown-kind", "workspace_validation", "active_run_watchdog"])("retains unresolved file or execution recovery as an offline blocker (%s)", async mode => { const f = await seed(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true"); await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, - kind: mode === "unknown-kind" ? "future_recovery" : "configuration_validation", - cause: mode === "export-cause" ? "native_workspace_sync_out_unrecoverable" : "configuration_incomplete", - fingerprint: randomUUID(), nextAction: "Preserve unresolved recovery", evidence: mode === "export-cause" || mode === "unknown-kind" ? {} + kind: mode === "unknown-kind" ? "future_recovery" : ["workspace_validation", "active_run_watchdog"].includes(mode) ? mode : "configuration_validation", + cause: mode === "export-cause" ? "native_workspace_sync_out_unrecoverable" : mode === "workspace_validation" ? "workspace_validation_failed" + : mode === "active_run_watchdog" ? "native_session_interrupted" : "configuration_incomplete", + fingerprint: randomUUID(), nextAction: "Preserve unresolved recovery", evidence: ["export-cause", "unknown-kind", "workspace_validation", "active_run_watchdog"].includes(mode) ? {} : { [mode]: mode === "systemRecovery" ? { state: "running" } : { schema: "unverified" } } }); await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true })).rejects.toThrow("workspace_recovery_unresolved"); expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); diff --git a/server/src/services/remote-workspace-migration.ts b/server/src/services/remote-workspace-migration.ts index 0ad155c1ea7..a2f579466c8 100644 --- a/server/src/services/remote-workspace-migration.ts +++ b/server/src/services/remote-workspace-migration.ts @@ -9,6 +9,7 @@ import { } from "@paperclipai/db"; import { readRemoteWorkspaceAuthority, remoteWorkspaceSourceSchema, type RemoteWorkspaceAuthority, type RemoteWorkspaceSource } from "@paperclipai/shared"; import { offlineMigrationExecutionWhere } from "./offline-migration-execution.js"; +import { workspaceExportCauses } from "./workspace-recovery-source.js"; import type { StorageService } from "../storage/types.js"; import { persistActivity, publishActivity } from "./activity-log.js"; import { withExecutionWorkspaceLifecycleLock } from "./execution-workspace-lifecycle-lock.js"; @@ -213,8 +214,8 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri // These warnings govern task disposition, not file authority. Keep them // unchanged while the offline owner saves the workspace. Unknown recovery // kinds and any declared file source still require explicit preservation. - return !["stranded_assigned_issue", "configuration_validation", "missing_disposition"].includes(recovery.kind) - || ["native_workspace_sync_out_unrecoverable", "native_workspace_sync_out_retry_exhausted"].includes(recovery.cause) + return !["stranded_assigned_issue", "configuration_validation", "missing_disposition", "deliberate_wait_without_target"].includes(recovery.kind) + || workspaceExportCauses.includes(recovery.cause) || ["workspaceRestoreRecovery", "workspaceRecovery", "historicalWorkspaceSource", "systemRecovery", "nativeFailureBlock"] .some(key => recovery.evidence?.[key] != null); })) reasons.push("workspace_recovery_unresolved");