fix(workspaces): cover task waits and reuse canonical recovery causes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-04 19:17:49 -06:00
1 parent e1caab7064
commit ba18efc7b5
3 files changed
+12 -9

No files matched your search

@@ -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 measure the fix but cannot replace fresh full-image migration and empty-home
recovery proof. 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. 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 They do not certify file recovery and are not resolved by migration. An active
system recovery, native execution failure marker, declared workspace recovery system recovery, native execution failure marker, declared workspace recovery
@@ -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((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)))[0]).toEqual(run);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).not.toBeNull(); 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 f = await seed();
const [action] = await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, 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"); await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_recovery_unresolved");
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true"); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: 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 }); await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n"); 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"); const f = await seed(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId,
kind: mode === "unknown-kind" ? "future_recovery" : "configuration_validation", 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" : "configuration_incomplete", cause: mode === "export-cause" ? "native_workspace_sync_out_unrecoverable" : mode === "workspace_validation" ? "workspace_validation_failed"
fingerprint: randomUUID(), nextAction: "Preserve unresolved recovery", evidence: mode === "export-cause" || mode === "unknown-kind" ? {} : 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" } } }); : { [mode]: mode === "systemRecovery" ? { state: "running" } : { schema: "unverified" } } });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true })).rejects.toThrow("workspace_recovery_unresolved"); await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true })).rejects.toThrow("workspace_recovery_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
@@ -9,6 +9,7 @@ import {
} from "@paperclipai/db"; } from "@paperclipai/db";
import { readRemoteWorkspaceAuthority, remoteWorkspaceSourceSchema, type RemoteWorkspaceAuthority, type RemoteWorkspaceSource } from "@paperclipai/shared"; import { readRemoteWorkspaceAuthority, remoteWorkspaceSourceSchema, type RemoteWorkspaceAuthority, type RemoteWorkspaceSource } from "@paperclipai/shared";
import { offlineMigrationExecutionWhere } from "./offline-migration-execution.js"; import { offlineMigrationExecutionWhere } from "./offline-migration-execution.js";
import { workspaceExportCauses } from "./workspace-recovery-source.js";
import type { StorageService } from "../storage/types.js"; import type { StorageService } from "../storage/types.js";
import { persistActivity, publishActivity } from "./activity-log.js"; import { persistActivity, publishActivity } from "./activity-log.js";
import { withExecutionWorkspaceLifecycleLock } from "./execution-workspace-lifecycle-lock.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 // These warnings govern task disposition, not file authority. Keep them
// unchanged while the offline owner saves the workspace. Unknown recovery // unchanged while the offline owner saves the workspace. Unknown recovery
// kinds and any declared file source still require explicit preservation. // kinds and any declared file source still require explicit preservation.
return !["stranded_assigned_issue", "configuration_validation", "missing_disposition"].includes(recovery.kind) return !["stranded_assigned_issue", "configuration_validation", "missing_disposition", "deliberate_wait_without_target"].includes(recovery.kind)
|| ["native_workspace_sync_out_unrecoverable", "native_workspace_sync_out_retry_exhausted"].includes(recovery.cause) || workspaceExportCauses.includes(recovery.cause)
|| ["workspaceRestoreRecovery", "workspaceRecovery", "historicalWorkspaceSource", "systemRecovery", "nativeFailureBlock"] || ["workspaceRestoreRecovery", "workspaceRecovery", "historicalWorkspaceSource", "systemRecovery", "nativeFailureBlock"]
.some(key => recovery.evidence?.[key] != null); .some(key => recovery.evidence?.[key] != null);
})) reasons.push("workspace_recovery_unresolved"); })) reasons.push("workspace_recovery_unresolved");