diff --git a/server/src/services/issue-recovery-actions.ts b/server/src/services/issue-recovery-actions.ts
index f274af2e15c..bddd3c5ec96 100644
--- a/server/src/services/issue-recovery-actions.ts
+++ b/server/src/services/issue-recovery-actions.ts
@@ -17,6 +17,7 @@ import type {
} from "@paperclipai/shared";
import { isNativeWorkspaceFinalizationOperationActive } from "./workspace-operations.js";
+import { exhaustedSystemRecoveryAction, readSystemRecovery } from "./system-recovery-state.js";
const ACTIVE_RECOVERY_ACTION_STATUSES = ["active", "escalated"] as const satisfies readonly IssueRecoveryActionStatus[];
const MAX_UPSERT_RETRIES = 3;
@@ -87,6 +88,14 @@ export type ResolveIssueRecoveryActionInput = {
resolutionNote?: string | null;
};
+/** A spent saved-work recovery is described from its current state. Text saved
+ * by an older release could ask a person to inspect the run's environment. */
+function readModelNextAction(row: IssueRecoveryActionRow): string {
+ const system = readSystemRecovery(row.evidence);
+ return system?.state === "exhausted" && row.evidence?.workspaceRecovery
+ ? exhaustedSystemRecoveryAction(system) : row.nextAction;
+}
+
function toReadModel(row: IssueRecoveryActionRow): IssueRecoveryAction {
return {
id: row.id,
@@ -103,7 +112,7 @@ function toReadModel(row: IssueRecoveryActionRow): IssueRecoveryAction {
cause: row.cause,
fingerprint: row.fingerprint,
evidence: row.evidence,
- nextAction: row.nextAction,
+ nextAction: readModelNextAction(row),
wakePolicy: row.wakePolicy,
monitorPolicy: row.monitorPolicy,
attemptCount: row.attemptCount,
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index dd5765d3eca..2531fe72169 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -7984,7 +7984,7 @@ const NATIVE_STARTUP_MODEL_REJECTED_ACTION =
"The provider could not start the selected model before any provider work began. Choose a supported model for this agent, then retry the task. Automatic retries are stopped.";
const NATIVE_CLEANUP_OPERATOR_RECOVERY_MESSAGE =
- "Send a new message to continue after Paperclip verifies that the previous provider and its tools have stopped. If cleanup cannot be verified, inspect the run and its environment. Clearing a task session does not resolve this quarantine. Automatic retries are stopped.";
+ "Send a new message to continue after Paperclip verifies that the previous provider and its tools have stopped. Clearing a task session does not resolve this quarantine. Automatic retries are stopped.";
const PROVIDER_DURABLE_EVENT_TYPES = NATIVE_PROVIDER_DURABLE_EVENT_TYPES;
diff --git a/server/src/services/system-recovery-state.test.ts b/server/src/services/system-recovery-state.test.ts
index 04059e8e678..e04ba8ec10a 100644
--- a/server/src/services/system-recovery-state.test.ts
+++ b/server/src/services/system-recovery-state.test.ts
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import { createSystemRecovery, failSystemRecovery, readSystemRecovery, reserveSystemRecoveryAttempt, SystemRecoveryFailure } from "./system-recovery-state.js";
import { exhaustedSystemRecoveryAction } from "./system-recovery.js";
+import { workspaceRecoveryDisplay } from "./workspace-recovery-receipt.js";
describe("durable system recovery budget", () => {
it("makes six attempts with exponential delays that survive serialization and exhaustion", () => {
@@ -43,6 +44,25 @@ describe("durable system recovery budget", () => {
{ now: 0, cause: { label: "/home/daytona/secret", code: null, status: null } });
expect(unsafe.failure).not.toHaveProperty("cause");
});
+ it("never asks a person to inspect a sandbox, even for text saved by an older release", () => {
+ const manual = /administrator|\bowner\b|board member|inspect the run|environment/i;
+ const exhausted = failSystemRecovery(createSystemRecovery(randomUUID()),
+ new SystemRecoveryFailure("allocation_ownership_ambiguous", "service_required"));
+ const copyback = failSystemRecovery(createSystemRecovery(randomUUID()),
+ new SystemRecoveryFailure("copyback_stop_unconfirmed", "recheck_evidence"));
+ for (const state of [exhausted, { ...copyback, state: "exhausted" as const }])
+ expect(exhaustedSystemRecoveryAction(state)).not.toMatch(manual);
+ const runId = exhausted.sourceRunId;
+ const display = workspaceRecoveryDisplay({ runId, systemRecovery: exhausted, workspaceRecovery: {
+ schema: "paperclip.workspace-recovery.v1", runId, status: "blocked", stage: "verifying_stop", attempt: 1,
+ attemptLimit: 6, requestIds: [], retryable: true,
+ message: "Automatic recovery of saved work stopped after 1 attempts (last failure: allocation_ownership_ambiguous). "
+ + "Retry recovery to export and merge from the retained sandbox again; no provider turn is submitted. "
+ + "If Retry keeps failing, inspect the run and its environment." } }, runId);
+ expect(display?.message).toContain("Retry to try again");
+ expect(display?.message).not.toMatch(manual);
+ });
+
it("names the last failure and its cause in the board handoff", () => {
const state = reserveSystemRecoveryAttempt(createSystemRecovery(randomUUID()), 0)!;
const withCause = failSystemRecovery(state, new SystemRecoveryFailure("provider_unavailable", "retry_transient"),
diff --git a/server/src/services/system-recovery-state.ts b/server/src/services/system-recovery-state.ts
index 411f2cc43f2..703bf0fa9b6 100644
--- a/server/src/services/system-recovery-state.ts
+++ b/server/src/services/system-recovery-state.ts
@@ -126,3 +126,16 @@ export function failSystemRecovery(
evidenceFingerprint: options.evidenceFingerprint ?? createHash("sha256").update(`${state.sourceRunId}:${failure.code}`).digest("hex") },
};
}
+
+/** User-facing text for a spent recovery budget. It names what happens next and
+ * never asks a person to inspect a sandbox or environment. The code stays for support. */
+export function exhaustedSystemRecoveryAction(state: SystemRecoveryState): string {
+ // Another Retry cannot release a copyback owner that stopped without reporting.
+ if (state.failure?.code === "copyback_stop_unconfirmed")
+ return "Recovery of saved work is waiting for the previous server's file copy to stop. "
+ + "Recovery resumes once Paperclip can confirm that it stopped. No provider turn is submitted.";
+ const cause = state.failure?.cause ? `, cause: ${state.failure.cause.label}` : "";
+ const reason = state.failure?.code ? ` (last failure: ${state.failure.code}${cause})` : "";
+ return `Automatic recovery of saved work stopped after ${state.attempts} attempts${reason}. `
+ + "Retry to try again; the agent will not repeat its work.";
+}
diff --git a/server/src/services/system-recovery.ts b/server/src/services/system-recovery.ts
index 3edd496a1d3..d816e32c049 100644
--- a/server/src/services/system-recovery.ts
+++ b/server/src/services/system-recovery.ts
@@ -3,7 +3,7 @@ import { and, eq, sql } from "drizzle-orm";
import { agents, issues, heartbeatRuns, issueComments, issueRecoveryActions, type Db } from "@paperclipai/db";
import { writeExecutionControl } from "./execution-control-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
-import { createSystemRecovery, readSystemRecovery, type SystemRecoveryState } from "./system-recovery-state.js";
+import { createSystemRecovery, exhaustedSystemRecoveryAction, readSystemRecovery, type SystemRecoveryState } from "./system-recovery-state.js";
import { workspaceRecoverySourceEligible, type WorkspaceRecoverySourceIdentity } from "./workspace-recovery-source.js";
export async function adoptSystemRecovery(db: Db, source: WorkspaceRecoverySourceIdentity) {
@@ -47,18 +47,7 @@ export async function adoptSystemRecovery(db: Db, source: WorkspaceRecoverySourc
});
}
-export function exhaustedSystemRecoveryAction(state: SystemRecoveryState): string {
- // Another Retry cannot release a copyback owner that stopped without reporting.
- if (state.failure?.code === "copyback_stop_unconfirmed")
- return "Automatic recovery of saved work is waiting for the previous server's file copy to stop, and Paperclip cannot verify "
- + "that it stopped. A board member who manages this company's runtime can confirm that it stopped; recovery then "
- + "resumes. No provider turn is submitted.";
- const cause = state.failure?.cause ? `, cause: ${state.failure.cause.label}` : "";
- const reason = state.failure?.code ? ` (last failure: ${state.failure.code}${cause})` : "";
- return `Automatic recovery of saved work stopped after ${state.attempts} attempts${reason}. `
- + "Retry recovery to export and merge from the retained sandbox again; no provider turn is submitted. "
- + "If Retry keeps failing, inspect the run and its environment.";
-}
+export { exhaustedSystemRecoveryAction };
/** CAS protects a newer manual generation from an old worker's late write.
* This is bookkeeping authority only; physical writes still require ownership. */
diff --git a/server/src/services/task-retry.test.ts b/server/src/services/task-retry.test.ts
index b2b2c42a25e..cbc567f6aa3 100644
--- a/server/src/services/task-retry.test.ts
+++ b/server/src/services/task-retry.test.ts
@@ -678,6 +678,40 @@ describe("durable task Retry admission", () => {
expect(wakes.find(row => row.id === first.requestId)?.status).toBe("failed");
expect(wakes.filter(row => row.status === "deferred_issue_execution")).toHaveLength(1);
});
+ it("accepts Retry for an old-release retained-export hold whose source ownership could not be verified", async () => {
+ // State saved by an earlier release: a board-owned export hold whose only
+ // attempt ended with a permanent ownership refusal. The task UI now offers
+ // Retry for it; Retry must open one new bounded generation that re-verifies
+ // the source, keep the hold in place, and never resolve or bypass it.
+ const input = await seed();
+ const previous = { ...failSystemRecovery({ ...createSystemRecovery(input.observedRunId), revision: 4 },
+ new SystemRecoveryFailure("allocation_ownership_ambiguous", "service_required")), revision: 5 };
+ expect(previous.state).toBe("exhausted");
+ const [action] = await db.insert(issueRecoveryActions).values({
+ companyId: input.companyId, sourceIssueId: input.issueId, kind: "active_run_watchdog", status: "active",
+ ownerType: "board", cause: "native_workspace_sync_out_retry_exhausted", fingerprint: randomUUID(),
+ nextAction: "Automatic recovery of saved work stopped after 1 attempts (last failure: allocation_ownership_ambiguous).",
+ evidence: { runId: input.observedRunId, systemRecovery: previous,
+ workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: input.observedRunId, stage: "verifying_stop",
+ status: "blocked", attempt: 1, attemptLimit: 6, requestIds: [], retryable: true, message: "Retry recovery." },
+ coordinatorAttempt: 1, originalFailureCode: "native_workspace_sync_out_failed",
+ workspaceFinalizeAttempt: 3, previousWorkspaceRecovery: null },
+ }).returning();
+ const receipt = await requestTaskRetry(input);
+ expect(receipt).toMatchObject({ accepted: true, stale: false, observedRunId: input.observedRunId });
+ const [updated] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id));
+ expect(updated.status).toBe("active");
+ expect(updated.resolvedAt).toBeNull();
+ expect(readSystemRecovery(updated.evidence)).toMatchObject({ generationId: receipt.requestId, sourceRunId: input.observedRunId,
+ attempts: 0, failure: null, revision: 6, freshAttemptRunId: null, preservationOperationIds: [] });
+ // File recovery is queued again for the worker, which re-verifies the source.
+ expect(updated.evidence.workspaceRecovery).toMatchObject({ runId: input.observedRunId, status: "queued",
+ stage: "verifying_stop", attemptLimit: 6, retryable: true });
+ // A replayed click is idempotent and does not open another generation.
+ expect((await requestTaskRetry(input)).requestId).toBe(receipt.requestId);
+ const [replayed] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id));
+ expect(readSystemRecovery(replayed.evidence)?.revision).toBe(6);
+ });
it("binds a key to its company, task and actor across all statuses", async () => {
const input = await seed();
const receipt = await requestTaskRetry(input);
diff --git a/server/src/services/workspace-recovery-receipt.ts b/server/src/services/workspace-recovery-receipt.ts
index f0dda4daff1..1126d87aa97 100644
--- a/server/src/services/workspace-recovery-receipt.ts
+++ b/server/src/services/workspace-recovery-receipt.ts
@@ -5,6 +5,7 @@ import { z } from "zod";
import { readNativeWorkspaceSyncReference, type NativeWorkspaceSyncReference } from "./native-runtime/native-workspace-sync.js";
import { readMigratedLegacyWorkspaceSource } from "./legacy-workspace-recovery-migration.js";
import { readSnapshot } from "./workspace-recovery-snapshot.js";
+import { exhaustedSystemRecoveryAction, readSystemRecovery } from "./system-recovery-state.js";
export const workspaceRecoverySchema = z.object({
schema: z.literal("paperclip.workspace-recovery.v1"),
@@ -58,10 +59,17 @@ export function workspaceRecoveryDisplay(evidence: Record, runI
canRetry: state.status === "blocked" && state.retryable === true,
message: state.status === "queued" || state.status === "recovering" ? "Recovering saved work…"
: state.status === "recovered" ? "Saved work recovered. Send a message to continue."
- : state.message ?? "Paperclip couldn’t safely recover this task. This task needs service attention.",
+ : blockedWorkspaceRecoveryMessage(evidence) ?? state.message ?? "Recovery of this task's saved work has not finished.",
};
}
+/** Text saved by an older release can name a manual step. A spent budget is
+ * described from its current state instead of the stored text. */
+export function blockedWorkspaceRecoveryMessage(evidence: Record | null | undefined): string | null {
+ const system = readSystemRecovery(evidence ?? undefined);
+ return system?.state === "exhausted" ? exhaustedSystemRecoveryAction(system) : null;
+}
+
async function verifiedNativeCopybackReceipt(db: Db, input: {
companyId: string; issueId: string; runId: string; reference: NativeWorkspaceSyncReference;
}) {
diff --git a/server/src/services/workspace-recovery-retry-copyback.test.ts b/server/src/services/workspace-recovery-retry-copyback.test.ts
index 101ae89912b..cb0a5895304 100644
--- a/server/src/services/workspace-recovery-retry-copyback.test.ts
+++ b/server/src/services/workspace-recovery-retry-copyback.test.ts
@@ -508,7 +508,7 @@ describe("automatic retained workspace recovery", () => {
const stable = (row: typeof after) => ({ ...row, updatedAt: null, ownerType: null, nextAction: null,
evidence: { ...row.evidence, workspaceRecovery: { ...(row.evidence.workspaceRecovery as object), message: null } } });
if (spent) {
- expect(after).toMatchObject({ ownerType: "board", nextAction: expect.stringMatching(/Retry recovery/) });
+ expect(after).toMatchObject({ ownerType: "board", nextAction: expect.stringMatching(/Retry to try again/) });
expect(stable(after)).toEqual(stable(before));
} else expect(after).toEqual(before);
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0].leasePolicy).toBe("reuse_by_environment");
diff --git a/server/src/services/workspace-recovery-warm-handoff.test.ts b/server/src/services/workspace-recovery-warm-handoff.test.ts
index eb4b7e51706..bfe5a9c1534 100644
--- a/server/src/services/workspace-recovery-warm-handoff.test.ts
+++ b/server/src/services/workspace-recovery-warm-handoff.test.ts
@@ -474,7 +474,7 @@ describe("automatic retained workspace recovery", () => {
expect(exhausted.nextAction).toMatch(/stopped after 6 attempts/);
const blocker = await getExecutionBlocker(db, f.companyId, f.issueId);
expect(blocker?.workspaceRecovery).toMatchObject({ status: "blocked", canRetry: true });
- expect(blocker?.nextAction).toMatch(/Retry recovery/);
+ expect(blocker?.nextAction).toMatch(/Retry to try again/);
expect(blocker?.nextAction).not.toMatch(/send a (new )?message/i);
// Nothing physical or provider-facing ran after the budget was spent.
expect(f.runtime.resumeRunLease).not.toHaveBeenCalled();
@@ -501,7 +501,7 @@ describe("automatic retained workspace recovery", () => {
const handed = await action(f.actionId);
expect(handed).toMatchObject({ status: "active", ownerType: "board" });
expect(readSystemRecovery(handed.evidence)).toEqual(spent);
- expect((await getExecutionBlocker(db, f.companyId, f.issueId))?.nextAction).toMatch(/Retry recovery/);
+ expect((await getExecutionBlocker(db, f.companyId, f.issueId))?.nextAction).toMatch(/Retry to try again/);
expect(f.runtime.resumeRunLease).not.toHaveBeenCalled();
// Repeat sweeps are idempotent and still grant no attempt.
await recoverRetainedWorkspace(f.input);
diff --git a/ui/src/components/RunWorkspaceSourceInspection.tsx b/ui/src/components/RunWorkspaceSourceInspection.tsx
index 0ba5812fd62..c9347a633dd 100644
--- a/ui/src/components/RunWorkspaceSourceInspection.tsx
+++ b/ui/src/components/RunWorkspaceSourceInspection.tsx
@@ -51,7 +51,7 @@ export function RunWorkspaceSourceInspection({ issueId, runId, companyId }: { is
Action {current.actionId}
This read-only observation checks recorded identity. It does not confirm provider termination, local files, or successful preservation.
{current.status === "verified" ?
Recorded-source identity verified.
- : current.status === "not_inspected" ?
Source not inspected: this record exceeds the inspection limit. Service attention is required.
+ : current.status === "not_inspected" ?
Source not inspected: this record exceeds the inspection limit.
diff --git a/ui/src/components/WorkspaceExportRecovery.control.test.tsx b/ui/src/components/WorkspaceExportRecovery.control.test.tsx
index 0a10fc87c7f..49ef3c4cb01 100644
--- a/ui/src/components/WorkspaceExportRecovery.control.test.tsx
+++ b/ui/src/components/WorkspaceExportRecovery.control.test.tsx
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IssueRecoveryAction } from "@paperclipai/shared";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
+import { writePendingSend } from "../lib/pending-send";
import { WorkspaceExportRecovery } from "./WorkspaceExportRecovery";
const api = vi.hoisted(() => ({ inspect: vi.fn(), recover: vi.fn(), resume: vi.fn() }));
@@ -153,7 +154,106 @@ describe("governed retained-file recovery", () => {
} });
await mount();
expect(button("Recover saved files")).toBeUndefined();
- expect(container.textContent).toContain("source is unavailable or has changed");
+ expect(container.textContent).toContain("can’t verify the saved files’ recorded source (source_binding_invalid)");
+ });
+
+ // Recovery saved by an older release: a board-owned hold whose
+ // sandbox predates ownership receipts. The server still records a retryable
+ // blocked recovery, but the read-only source probe cannot verify ownership.
+ const legacyHold = { ...action, id: "legacy-hold", ownerType: "board", evidence: { runId: "run-1",
+ workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: "run-1", stage: "verifying_stop",
+ status: "blocked", attempt: 1, attemptLimit: 6, requestIds: [], retryable: true,
+ message: "Automatic recovery of saved work stopped after 1 attempts (last failure: allocation_ownership_ambiguous)." },
+ systemRecovery: { schema: "paperclip.system-recovery.v1", phase: "verify_execution", state: "exhausted", revision: 5,
+ sourceRunId: "run-1", attempts: 1, retriesUsed: 0, preservationOperationIds: [], freshAttemptRunId: null,
+ failure: { code: "allocation_ownership_ambiguous", retryClass: "service_required" } },
+ originalFailureCode: "native_workspace_sync_out_failed", workspaceFinalizeAttempt: 3, previousWorkspaceRecovery: null },
+ } as unknown as IssueRecoveryAction;
+ const blockedInspection = (value: IssueRecoveryAction, failureCode = "allocation_ownership_ambiguous") => ({
+ active: value, actions: [value], sourceInspection: { schema: "paperclip.workspace-source-inspection.v1",
+ actionId: value.id, runId: "run-1", status: "blocked", failureCode,
+ scope: "recorded_source_identity", observedAt: "2026-11-07T14:44:46Z" } });
+
+ it("hands the next action to the task Retry for an old-release hold whose source cannot be verified", async () => {
+ api.inspect.mockResolvedValue(blockedInspection(legacyHold));
+ const owns = vi.fn();
+ await act(async () => root.render(
+
+ ));
+ await settle();
+ expect(owns).toHaveBeenLastCalledWith(false);
+ expect(button("Recover saved files")).toBeUndefined();
+ // No dead-end: no "changed, refresh" alert and no status check that can only repeat it.
+ expect(button("Check file recovery status")).toBeUndefined();
+ expect(container.textContent).not.toContain("has changed");
+ expect(container.textContent).toContain("can’t confirm that this run alone used the sandbox");
+ expect(api.recover).not.toHaveBeenCalled();
+ });
+
+ it("does not request recovery when the source stops verifying between render and click", async () => {
+ api.inspect.mockResolvedValueOnce(inspection()).mockResolvedValueOnce({ ...inspection(), sourceInspection: {
+ ...inspection().sourceInspection, status: "blocked", failureCode: "allocation_ownership_ambiguous" } });
+ await mount();
+ await click("Recover saved files");
+ expect(api.recover).not.toHaveBeenCalled();
+ expect(container.textContent).toContain("can’t confirm that this run alone used the sandbox");
+ expect(container.textContent).not.toContain("File recovery changed");
+ expect(button("Recover saved files")).toBeUndefined();
+ });
+
+ it("keeps naming the unverified source after an accepted request failed", async () => {
+ const key = "accepted-request";
+ writePendingSend("workspace-file-recovery:company-1:issue-1:legacy-hold:run-1:board-1",
+ { idempotencyKey: key, payload: { actionId: "legacy-hold", runId: "run-1" }, createdAt: Date.now() });
+ const failed = { ...legacyHold, evidence: { ...legacyHold.evidence, workspaceRecovery: {
+ ...(legacyHold.evidence.workspaceRecovery as object), requestIds: [key] } } } as IssueRecoveryAction;
+ api.inspect.mockResolvedValue(blockedInspection(failed));
+ await mount(failed);
+ expect(container.textContent).toContain("File recovery has not finished");
+ expect(container.textContent).toContain("can’t confirm that this run alone used the sandbox");
+ // While recovery is running, the panel shows progress instead of the reason.
+ const running = { ...failed, evidence: { ...failed.evidence, workspaceRecovery: {
+ ...(failed.evidence.workspaceRecovery as object), status: "recovering" } } } as IssueRecoveryAction;
+ api.inspect.mockResolvedValue(blockedInspection(running));
+ await act(async () => client.invalidateQueries({ queryKey: queryKeys.issues.recoveryActions("issue-1", "legacy-hold", "run-1") }));
+ await settle();
+ expect(container.textContent).toContain("Recovering saved files");
+ expect(container.textContent).not.toContain("can’t confirm that this run alone used the sandbox");
+ expect(api.recover).not.toHaveBeenCalled();
+ });
+
+ it("keeps ownership of the next action while the source is verified", async () => {
+ const owns = vi.fn();
+ await act(async () => root.render(
+
+ ));
+ await settle();
+ expect(owns).toHaveBeenLastCalledWith(true);
+ expect(button("Recover saved files")).toBeDefined();
+ });
+
+ it("reports recovered files even when the source no longer matches after recovery", async () => {
+ // Preservation completed, so the workspace moved past the
+ // recorded source and inspection reports a reference mismatch.
+ const recovered = { ...legacyHold, ownerType: "system", evidence: { ...legacyHold.evidence,
+ workspaceRecovery: { ...(legacyHold.evidence.workspaceRecovery as object), status: "recovered" },
+ systemRecovery: { ...(legacyHold.evidence.systemRecovery as object), state: "complete",
+ preservationOperationIds: ["preservation-1"] } } } as IssueRecoveryAction;
+ api.inspect.mockResolvedValue(blockedInspection(recovered, "source_binding_invalid"));
+ await mount(recovered);
+ expect(container.textContent).toContain("Saved files recovered");
+ expect(container.textContent).not.toContain("need recovery");
+ expect(container.textContent).not.toContain("has changed");
+ expect(button("Recover saved files")).toBeUndefined();
+ });
+
+ it("reloads the task instead of looping when the recovery record was replaced", async () => {
+ api.inspect.mockResolvedValue({ active: null, actions: [], sourceInspection: undefined });
+ const invalidate = vi.spyOn(client, "invalidateQueries");
+ await mount(legacyHold);
+ await click("Check file recovery status");
+ expect(container.textContent).not.toContain("Refresh the task");
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.detail("issue-1") });
});
it("persists the exact request before POST and prevents a double click", async () => {
@@ -288,6 +388,37 @@ describe("governed retained-file recovery", () => {
expect(api.recover).not.toHaveBeenCalled();
});
+ // Recovery is automatic. No recovery state may tell a person to fix or inspect
+ // a sandbox, or name an owner or administrator as the one who must act.
+ const manualCopy = /administrator|\bowner\b|operator assistance|service assistance|service attention|inspect the (run|failed run)|environment/i;
+ const withRecovery = (value: IssueRecoveryAction, patch: Record, system?: Record) => ({
+ ...value, evidence: { ...value.evidence, workspaceRecovery: { ...(value.evidence.workspaceRecovery as object), ...patch },
+ ...(system ? { systemRecovery: { ...(value.evidence.systemRecovery as object), ...system } } : {}) } }) as IssueRecoveryAction;
+ const operatorAccess = { userId: "board-1", companyIds: ["company-1"], source: "session", isInstanceAdmin: false,
+ memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }] };
+ const viewerAccess = { ...operatorAccess, memberships: [{ companyId: "company-1", membershipRole: "viewer", status: "active" }] };
+ it.each([
+ ["verified and retryable", action, inspection(action), operatorAccess],
+ ["unverified ownership", legacyHold, blockedInspection(legacyHold), operatorAccess],
+ ["unverified binding", legacyHold, blockedInspection(legacyHold, "source_binding_invalid"), operatorAccess],
+ ["not inspected", legacyHold, { ...blockedInspection(legacyHold), sourceInspection: { ...blockedInspection(legacyHold).sourceInspection,
+ status: "not_inspected", reason: "source_limit" } }, operatorAccess],
+ ["queued", withRecovery(legacyHold, { status: "queued" }), blockedInspection(withRecovery(legacyHold, { status: "queued" })), operatorAccess],
+ ["not retryable", withRecovery(legacyHold, { retryable: false }), blockedInspection(withRecovery(legacyHold, { retryable: false })), operatorAccess],
+ ["recovered", withRecovery(legacyHold, { status: "recovered" }, { state: "complete", preservationOperationIds: ["op-1"] }),
+ blockedInspection(withRecovery(legacyHold, { status: "recovered" }, { state: "complete", preservationOperationIds: ["op-1"] })), operatorAccess],
+ ["viewer", legacyHold, blockedInspection(legacyHold), viewerAccess],
+ ["copyback owner hold for an operator", { ...action, evidence: { ...action.evidence,
+ workspaceOwnerRecovery: { state: "operator_required", owner: { token: "token" } } } } as IssueRecoveryAction, inspection(action), operatorAccess],
+ ["board-owned status", { ...action, cause: "native_workspace_finalization_owner_unverified", ownerType: "board",
+ evidence: { runId: "run-1" } } as unknown as IssueRecoveryAction, inspection(action), operatorAccess],
+ ] as const)("renders no manual sandbox or administrator copy: %s", async (_name, value, response, access) => {
+ client.setQueryData(queryKeys.access.currentBoardAccess, access);
+ api.inspect.mockResolvedValue(response);
+ await mount(value);
+ expect(container.textContent).not.toMatch(manualCopy);
+ });
+
describe("previous server stop confirmation", () => {
const ownerAction = { ...action, cause: "native_workspace_finalization_owner_unverified", ownerType: "board", status: "escalated",
evidence: { runId: "run-1", owner: { token: "owner-token", hostname: "secret-host", pid: 2 },
@@ -350,7 +481,7 @@ describe("governed retained-file recovery", () => {
companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }] });
await mount(ownerAction);
expect(button("Confirm the previous server stopped")).toBeUndefined();
- expect(container.textContent).toContain("owner or administrator can confirm");
+ expect(container.textContent).toContain("waiting to confirm that the previous server stopped");
expect(api.resume).not.toHaveBeenCalled();
});
});
diff --git a/ui/src/components/WorkspaceExportRecovery.test.tsx b/ui/src/components/WorkspaceExportRecovery.test.tsx
index 28a2409d2f1..3c909c334b5 100644
--- a/ui/src/components/WorkspaceExportRecovery.test.tsx
+++ b/ui/src/components/WorkspaceExportRecovery.test.tsx
@@ -20,16 +20,16 @@ describe("WorkspaceExportRecovery", () => {
});
it("does not mistake platform identity for confirmed recovery", async () => {
await mount({ ...action, evidence: { ...action.evidence, owner: { ...(action.evidence.owner as object), worker: { provider: "railway", deploymentId: "old" } } } });
- expect(container.textContent).toContain("operator assistance");
+ expect(container.textContent).toContain("Saved work is not recovered yet");
});
it("states that an operator is needed for legacy receipts", async () => {
await mount(action);
- expect(container.textContent).toContain("Workspace recovery needs operator assistance");
+ expect(container.textContent).toContain("Saved work is not recovered yet");
});
it("keeps export repair out of the task", async () => {
const cause = "native_workspace_sync_out_retry_exhausted";
await mount({ ...action, cause });
- expect(container.textContent).toContain("operator assistance");
+ expect(container.textContent).toContain("Saved work is not recovered yet");
});
it("removes the status when recovery resolves, including updates in place", async () => {
await mount({ ...action, evidence: { ...action.evidence, workspaceOwnerStop: {} } });
diff --git a/ui/src/components/WorkspaceExportRecovery.tsx b/ui/src/components/WorkspaceExportRecovery.tsx
index bf48de41db2..e66e04707ad 100644
--- a/ui/src/components/WorkspaceExportRecovery.tsx
+++ b/ui/src/components/WorkspaceExportRecovery.tsx
@@ -1,6 +1,6 @@
-import { useRef, useState } from "react";
+import { useLayoutEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import type { IssueRecoveryAction } from "@paperclipai/shared";
+import type { IssueRecoveryAction, WorkspaceSourceInspection } from "@paperclipai/shared";
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
import { isTransientError } from "../api/errors";
import { useQueryView } from "./QueryView";
@@ -15,37 +15,53 @@ import { createUuid } from "../lib/uuid";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
+/** Reports whether this surface owns the task's next recovery action. The task's
+ * generic Retry stays hidden only while this surface offers its own control. */
+function ReportRecoveryControl({ owns, onChange }: { owns: boolean; onChange?: (owns: boolean) => void }) {
+ useLayoutEffect(() => {
+ onChange?.(owns);
+ return () => onChange?.(false);
+ }, [owns, onChange]);
+ return null;
+}
+
/** Requests preservation; the existing worker owns stop, copyback and verification. */
-export function WorkspaceExportRecovery({ action, className, issueId, companyId, showStatus = true }: {
+export function WorkspaceExportRecovery({ action, className, issueId, companyId, showStatus = true, onRecoveryControlChange }: {
className?: string; action: IssueRecoveryAction | null; issueId?: string; companyId?: string; showStatus?: boolean;
+ onRecoveryControlChange?: (owns: boolean) => void;
}) {
// A restart killed the copyback owner of retained saved-work recovery. Recovery
// and any accepted Retry wait on its receipt; the confirmation is the one action.
const retainedOwnerToken = action && issueId && companyId && isRetainedExport(action, issueId, companyId)
? retainedOwnerStopToken(action) : null;
if (action && issueId && companyId && retainedOwnerToken) {
- return ;
+ return <>
+ >;
}
if (action && issueId && companyId && isRetainedExport(action, issueId, companyId)) {
return ;
+ action={action} issueId={issueId} companyId={companyId} className={className}
+ onRecoveryControlChange={onRecoveryControlChange} />;
}
const ownerRecovery = action?.cause === "native_workspace_finalization_owner_unverified";
// The task's interruption names this wait; the confirmation is its one action.
if (ownerRecovery && issueId && companyId && isOwnerStopRequired(action, issueId, companyId)) {
- return ;
+ return <>
+ >;
}
- if (!showStatus) return null;
+ if (!showStatus) return ;
if (!action || (!ownerRecovery && !isNativeWorkspaceExportRepairCause(action.cause)) || action.ownerType !== "board"
- || !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return null;
+ || !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string")
+ return ;
const queued = !!action.evidence.workspaceOwnerStop || action.wakePolicy?.kind === "resume_native_run";
const automatic = queued || (ownerRecovery && asObject(action.evidence.workspaceOwnerRecovery).state === "resuming");
- return
- {automatic ? "Restoring saved work…" : "Workspace recovery needs operator assistance. The agent’s result is saved."}
-
;
+ return <>
+
+ {automatic ? "Restoring saved work…" : "Saved work is not recovered yet. The agent’s result is saved."}
+
>;
}
function isRetainedExport(action: IssueRecoveryAction, issueId: string, companyId: string) {
@@ -99,7 +115,7 @@ function PreviousServerStopConfirmation({ action, ownerToken, issueId, companyId
: "The previous server stopped while it was saving this task’s files, and Paperclip can’t verify that it stopped. The agent’s result is saved."}
{confirm.isSuccess ? null : !canConfirm
?
{accessView.kind === "loading" || accessView.kind === "reconnecting"
- ? "Checking access…" : "A company owner or administrator can confirm that the previous server stopped."}
+ ? "Checking access…" : "Paperclip is waiting to confirm that the previous server stopped."}
: !open ?
: