From 01dd6ab336b9abfe4c2f6a35538c8b2a956f066a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:03:25 -0600 Subject: [PATCH] fix(recovery): show the saved-work recovery step and record why it failed (#19260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Sometimes a failed run left files in a sandbox. Then Retry first runs system recovery to save those files before the task continues. > - Recovery has steps: confirm that the previous attempt stopped, save its files, then finish. > - The task page did not show these steps. People saw "Queued" or "Trying again in…" with a retry time that kept moving. > - Each unclassified failure of the stop-confirmation step was recorded only as `provider_unavailable`, so a repeated deferral could not be diagnosed. > - This pull request shows the current recovery step on the task. It also records why each failed attempt failed, in closed labels. > - The benefit is that people can see what Retry waits for, and operators can see why recovery deferred. ## Linked Issues or Issue Description Refs #19249 **What happened?** A person clicked Retry on a task whose failed run had a retained sandbox. For about 7 minutes the task showed only "Queued", and the retry time moved forward twice. Recovery deferred twice in the stop-confirmation step. Each deferral was recorded only as `provider_unavailable` with an opaque fingerprint, so the cause could not be found. **Expected behavior** The task shows the recovery step that it waits for, such as "Checking that the previous attempt stopped. Trying again in 30s…". Each failed attempt keeps a closed-label cause, for example `sandbox_not_running` or `transport_timeout`, with an optional errno-style code and HTTP status. **Steps to reproduce** 1. Get a remote task run to fail after its sandbox was placed, so that recovery owns saved work. 2. Click Retry while the sandbox provider does not respond to the stop check. 3. The task shows "Queued" or "Trying again in…". The recovery evidence records only `provider_unavailable`. **Paperclip version or commit** master at 499712c6. **Deployment mode** Authenticated cloud deployment (staging). ## What Changed - `system-recovery-state.ts`: a failed attempt can keep an optional `failure.cause` with a closed `label`, an errno-style `code` and an HTTP `status`. The schema drops a value that does not match. - `workspace-recovery.ts`: an unclassified failure records `sandboxProviderFailureLabel(error)` as its cause, and the warning log includes the label. The cause never contains provider text. - `system-recovery.ts`: the board handoff message after exhaustion names the last cause. - `TaskInterruptionPresentation` has an optional `recoveryStep`: `checking_previous_run`, `saving_previous_files` or `finishing_recovery`. The server sets it from the recovery phase only for a scheduled or actively running system recovery. - `ExecutionBlockerNotice` shows the step. While recovery runs it shows "Saving files from the previous attempt…". While a recovery retry is scheduled it shows "Checking that the previous attempt stopped. Trying again in 30s…". Without a recovery step, the output does not change. ## Verification - Red, then green: the new tests in `system-recovery-state.test.ts`, `task-interruption-presentation.test.ts` and `ExecutionBlockerNotice.test.tsx` failed before the change and pass after it. - `workspace-recovery.test.ts` (380 tests) passes. Its exhausted unknown-failure case now expects `cause: { label: "provider_error" }`. - An ordinary scheduled retry, a working run without recovery and the existing failed states render as before. - Server typecheck passes. UI typecheck passes for `src`. The only UI errors are in Storybook support files whose generated catalog is not built locally. - `pnpm check:token-gates` passes. ## Risks - Low risk. All new fields are optional JSON fields. No database migration is necessary, and old records parse as before. - This change does not change recovery behavior, retry timing or authority. It adds presentation and diagnostics only. - The cause label uses the existing closed transport label set. An unrecognized error is recorded as `provider_error`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../shared/src/types/execution-projection.ts | 2 + .../services/system-recovery-state.test.ts | 10 +++++ server/src/services/system-recovery-state.ts | 13 ++++++- server/src/services/system-recovery.ts | 3 +- .../task-interruption-presentation.test.ts | 39 +++++++++++++++++++ .../task-interruption-presentation.ts | 21 +++++++--- .../src/services/workspace-recovery.test.ts | 2 +- server/src/services/workspace-recovery.ts | 8 +++- .../ExecutionBlockerNotice.test.tsx | 19 +++++++++ ui/src/components/ExecutionBlockerNotice.tsx | 19 +++++++-- 10 files changed, 122 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/types/execution-projection.ts b/packages/shared/src/types/execution-projection.ts index fee5ade4c8a..ce2a1e3a67a 100644 --- a/packages/shared/src/types/execution-projection.ts +++ b/packages/shared/src/types/execution-projection.ts @@ -114,4 +114,6 @@ export interface TaskInterruptionPresentation { projectAccess?: { agentId: string; projectId: string | null; ready: boolean }; sourceRunIds: string[]; observedRunId: string | null; + /** The saved-work recovery step that must finish before the task continues. */ + recoveryStep?: "checking_previous_run" | "saving_previous_files" | "finishing_recovery"; } diff --git a/server/src/services/system-recovery-state.test.ts b/server/src/services/system-recovery-state.test.ts index 96357950a6f..ad9b4e7a1b6 100644 --- a/server/src/services/system-recovery-state.test.ts +++ b/server/src/services/system-recovery-state.test.ts @@ -32,6 +32,16 @@ describe("durable system recovery budget", () => { expect(failSystemRecovery(state, new SystemRecoveryFailure("provider_unavailable", "retry_transient"), { now: 0, retryAfterAt: 31 * 60_000 }).state).toBe("exhausted"); }); + it("keeps a closed-label failure cause through serialization and drops anything else", () => { + const state = reserveSystemRecoveryAttempt(createSystemRecovery(randomUUID()), 0)!; + const failed = failSystemRecovery(state, new SystemRecoveryFailure("provider_unavailable", "retry_transient"), + { now: 0, cause: { label: "sandbox_not_running", code: "ECONNRESET", status: 503 } }); + expect(readSystemRecovery(JSON.parse(JSON.stringify({ systemRecovery: failed })))?.failure) + .toMatchObject({ code: "provider_unavailable", cause: { label: "sandbox_not_running", code: "ECONNRESET", status: 503 } }); + const unsafe = failSystemRecovery(state, new SystemRecoveryFailure("provider_unavailable", "retry_transient"), + { now: 0, cause: { label: "/home/daytona/secret", code: null, status: null } }); + expect(unsafe.failure).not.toHaveProperty("cause"); + }); it("reconciles a recorded attempt after restart without spending another retry", () => { const running = reserveSystemRecoveryAttempt(createSystemRecovery(randomUUID()), 0)!; const restarted = readSystemRecovery(JSON.parse(JSON.stringify({ systemRecovery: running })))!; diff --git a/server/src/services/system-recovery-state.ts b/server/src/services/system-recovery-state.ts index c1777a1a8f6..411f2cc43f2 100644 --- a/server/src/services/system-recovery-state.ts +++ b/server/src/services/system-recovery-state.ts @@ -36,6 +36,14 @@ export const workspaceSourceVerificationFailureSchema = z.object({ observedLeasePolicy: z.enum([...ENVIRONMENT_LEASE_POLICIES, "unrecognized"]).optional(), }); +/** Why an attempt failed, in closed labels only. Provider text never appears. */ +export const recoveryFailureCauseSchema = z.object({ + label: z.string().regex(/^[a-z][a-z0-9_]{0,47}$/), + code: z.string().regex(/^[A-Z][A-Z0-9_]{1,31}$/).nullable(), + status: z.number().int().min(100).max(599).nullable(), +}); +export type RecoveryFailureCause = z.infer; + export const systemRecoverySchema = z.object({ schema: z.literal("paperclip.system-recovery.v1"), policyVersion: z.literal(SYSTEM_RECOVERY_POLICY_VERSION), @@ -57,6 +65,7 @@ export const systemRecoverySchema = z.object({ retryClass: z.enum(recoveryRetryClasses), evidenceFingerprint: z.string(), sourceVerification: workspaceSourceVerificationFailureSchema.optional(), + cause: recoveryFailureCauseSchema.optional(), }).nullable(), preservationOperationIds: z.array(z.string().uuid()), freshAttemptRunId: z.string().uuid().nullable(), @@ -94,7 +103,7 @@ export function reserveSystemRecoveryAttempt(state: SystemRecoveryState, now = D /** The caller persists this timestamp. A restart must not recalculate jitter. */ export function failSystemRecovery( state: SystemRecoveryState, failure: SystemRecoveryFailure, - options: { now?: number; retryAfterAt?: number; evidenceFingerprint?: string } = {}, + options: { now?: number; retryAfterAt?: number; evidenceFingerprint?: string; cause?: RecoveryFailureCause } = {}, ): SystemRecoveryState { const now = options.now ?? Date.now(); const firstFailureAt = state.firstFailureAt ?? now; @@ -106,12 +115,14 @@ export function failSystemRecovery( const retryable = ["retry_transient", "recheck_evidence"].includes(failure.retryClass); const retry = retryable && retryNumber <= SYSTEM_RECOVERY_MAX_RETRIES && nextAttemptAt < deadlineAt; const sourceVerification = workspaceSourceVerificationFailureSchema.safeParse(failure.sourceVerification); + const cause = recoveryFailureCauseSchema.safeParse(options.cause); return { ...state, revision: state.revision + 1, firstFailureAt, deadlineAt, providerCooldownAt: options.retryAfterAt ?? null, state: retry ? failure.retryClass === "recheck_evidence" ? "awaiting_evidence" : "scheduled" : "exhausted", nextAttemptAt: retry ? nextAttemptAt : null, failure: { code: failure.code, retryClass: failure.retryClass, ...(sourceVerification.success ? { sourceVerification: sourceVerification.data } : {}), + ...(cause.success ? { cause: cause.data } : {}), evidenceFingerprint: options.evidenceFingerprint ?? createHash("sha256").update(`${state.sourceRunId}:${failure.code}`).digest("hex") }, }; } diff --git a/server/src/services/system-recovery.ts b/server/src/services/system-recovery.ts index bfc8e36f0fb..5b93c297570 100644 --- a/server/src/services/system-recovery.ts +++ b/server/src/services/system-recovery.ts @@ -48,7 +48,8 @@ export async function adoptSystemRecovery(db: Db, source: WorkspaceRecoverySourc } export function exhaustedSystemRecoveryAction(state: SystemRecoveryState): string { - const reason = state.failure?.code ? ` (last failure: ${state.failure.code})` : ""; + 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."; diff --git a/server/src/services/task-interruption-presentation.test.ts b/server/src/services/task-interruption-presentation.test.ts index 0b491a1d008..1a01b402c14 100644 --- a/server/src/services/task-interruption-presentation.test.ts +++ b/server/src/services/task-interruption-presentation.test.ts @@ -174,3 +174,42 @@ describe("task project-access interruption", () => { .toMatchObject({ state: "resolved" }); }); }); + +describe("saved-work recovery step", () => { + const now = Date.parse("2026-01-01T12:00:00Z"); + const failed = { ...run, errorCode: "setup_failed", resultJson: null }; + const system = (phase: string, state: string, nextAttemptAt: number | null) => ({ + schema: "paperclip.system-recovery.v1", policyVersion: 1, sourceRunId: "33333333-3333-4333-8333-333333333333", revision: 3, + generationId: "44444444-4444-4444-8444-444444444444", intentId: null, phase, state, attemptId: null, attempts: 2, + retriesUsed: 1, firstFailureAt: now - 60_000, nextAttemptAt, deadlineAt: now + 600_000, providerCooldownAt: null, + failure: null, preservationOperationIds: [], freshAttemptRunId: null }); + const action = (evidence: Record) => ({ evidence }) as never; + + it("names the step a scheduled recovery will retry", () => { + expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false, + action: action({ systemRecovery: system("verify_execution", "scheduled", now + 30_000) }) }, now)) + .toMatchObject({ state: "retry_scheduled", recoveryStep: "checking_previous_run" }); + }); + + it("names the step a running recovery is on", () => { + expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false, + action: action({ systemRecovery: system("preserve_files", "running", null), systemRecoveryActiveAt: now - 1_000 }) }, now)) + .toMatchObject({ state: "working", recoveryStep: "saving_previous_files" }); + }); + + it("names recovery over a pending chat preparation and keeps failed preparation authoritative", () => { + const wait = { id: "retry", requestedAt: new Date(now), sourceRunId: failed.id, preparationStatus: "issued", preparationRetryAt: now + 250 }; + const running = action({ systemRecovery: system("preserve_files", "running", null), systemRecoveryActiveAt: now - 1_000 }); + expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false, action: running }, now)) + .toMatchObject({ state: "working", recoveryStep: "saving_previous_files" }); + expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, preparationStatus: "failed" }, + paused: false, approval: false, action: running }, now)).toMatchObject({ state: "failed" }); + expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false }, now)) + .toMatchObject({ state: "retry_scheduled", nextRetryAt: new Date(now + 250).toISOString() }); + }); + + it("does not name a step for an ordinary scheduled retry", () => { + expect(projectTaskInterruption(issue, { run: { ...failed, scheduledRetryAt: new Date(now + 30_000) }, agent, + paused: false, approval: false }, now)).not.toHaveProperty("recoveryStep"); + }); +}); diff --git a/server/src/services/task-interruption-presentation.ts b/server/src/services/task-interruption-presentation.ts index 91e074fd518..0878f2dad04 100644 --- a/server/src/services/task-interruption-presentation.ts +++ b/server/src/services/task-interruption-presentation.ts @@ -108,18 +108,27 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user", userAction: "project_access", projectAccess }; } + const recoveryStep: TaskInterruptionPresentation["recoveryStep"] = !system ? undefined + : system.phase === "verify_execution" ? "checking_previous_run" + : system.phase === "preserve_files" ? "saving_previous_files" : "finishing_recovery"; + const activeObservation = action?.evidence.systemRecoveryActiveAt; + const recoveryRunning = system?.state === "running" && typeof activeObservation === "number" + && activeObservation > now - 45_000; + const recoveryScheduled = Boolean(system?.nextAttemptAt && system.nextAttemptAt > now); if (retryWait?.sourceRunId === run.id && retryWait.preparationStatus) { const deadline = retryWait.preparationRetryAt == null ? NaN : Number(retryWait.preparationRetryAt); - if (["issued", "processing"].includes(retryWait.preparationStatus) && Number.isFinite(deadline)) + const pending = ["issued", "processing"].includes(retryWait.preparationStatus) && Number.isFinite(deadline); + // Saved-work recovery gates a pending preparation, so it names the wait. + // Failed or cancelled preparation stays authoritative. + if (pending && !recoveryRunning && !recoveryScheduled) return { ...base, state: "retry_scheduled", nextRetryAt: new Date(deadline).toISOString() }; - return { ...base, state: "failed", canRetry: retryWait.preparationStatus !== "cancelled" }; + if (!pending) return { ...base, state: "failed", canRetry: retryWait.preparationStatus !== "cancelled" }; } const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime(); if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled", - nextRetryAt: new Date(retryAt).toISOString(), canRetry: true }; - const activeObservation = action?.evidence.systemRecoveryActiveAt; - if (system?.state === "running" && typeof activeObservation === "number" && - activeObservation > now - 45_000) return { ...base, state: "working" }; + nextRetryAt: new Date(retryAt).toISOString(), canRetry: true, + ...(system?.nextAttemptAt && recoveryStep ? { recoveryStep } : {}) }; + if (recoveryRunning) return { ...base, state: "working", ...(recoveryStep ? { recoveryStep } : {}) }; if (retryWait?.sourceRunId === run.id && retryWait.executionWaitReason) { const reason = Object.hasOwn(retryWaitMessages, retryWait.executionWaitReason) ? retryWait.executionWaitReason : "execution_recovery"; // Keep the latest input receipt so message submission can settle independently. diff --git a/server/src/services/workspace-recovery.test.ts b/server/src/services/workspace-recovery.test.ts index 635091541d0..659533fe842 100644 --- a/server/src/services/workspace-recovery.test.ts +++ b/server/src/services/workspace-recovery.test.ts @@ -4298,7 +4298,7 @@ describe("automatic retained workspace recovery", () => { expect(lease.metadata).not.toHaveProperty("sandboxStopAndRetain"); expect(readSystemRecovery((await action(f.actionId)).evidence)).toMatchObject({ state: "exhausted", attempts, failure: failure === "disk_full" ? { code: "source_unavailable", retryClass: "use_alternative_strategy" } - : { code: "provider_unavailable", retryClass: "retry_transient" } }); + : { code: "provider_unavailable", retryClass: "retry_transient", cause: { label: "provider_error", code: null, status: null } } }); expect(f.runtime.resumeRunLease).toHaveBeenCalledTimes(attempts); expect(await readFile(path.join(f.remote, "saved.txt"), "utf8")).toBe("finished work\n"); }, 60_000); diff --git a/server/src/services/workspace-recovery.ts b/server/src/services/workspace-recovery.ts index 955a20a222b..a2f98a733bd 100644 --- a/server/src/services/workspace-recovery.ts +++ b/server/src/services/workspace-recovery.ts @@ -10,7 +10,7 @@ import { beginIdleTrackedWork } from "./task-admission.js"; import { writeEnvironmentCleanup } from "./environment-cleanup-notifications.js"; import { resumePreservedResultFinalization } from "./preserved-result-finalization.js"; import { reclaimInactiveNativeProviderCache } from "./native-runtime/retained-provider-cache.js"; -import { REMOTE_WORKSPACE_SANDBOX_DISK_FULL } from "./remote-workspace-transport.js"; +import { REMOTE_WORKSPACE_SANDBOX_DISK_FULL, sandboxProviderFailureLabel } from "./remote-workspace-transport.js"; import { and, asc, eq, gt, inArray, ne, notInArray, or, sql } from "drizzle-orm"; import { agentWakeupRequests, environmentLeases, executionWorkspaces, heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, workspaceOperations, type Db } from "@paperclipai/db"; import { remoteWorkspaceRunReferenceSchema, type Environment, type EnvironmentLease } from "@paperclipai/shared"; @@ -420,12 +420,16 @@ export async function recoverRetainedWorkspace(input: Source & { db: Db; environ ? new SystemRecoveryFailure("source_unavailable", "use_alternative_strategy") : new SystemRecoveryFailure(current!.phase === "verify_execution" ? "provider_unavailable" : "export_interrupted", "retry_transient"); const retryAfterAt = (error as { retryAfterAt?: unknown })?.retryAfterAt; + // A generic transient code says only that the step can be retried. Keep + // why it failed in closed labels so a repeated deferral is diagnosable. + const cause = error instanceof SystemRecoveryFailure ? undefined : sandboxProviderFailureLabel(error); await updateSystemRecovery(db, input, current!, failSystemRecovery(current!, failure, { ...(typeof retryAfterAt === "number" && Number.isFinite(retryAfterAt) ? { retryAfterAt } : {}), + ...(cause ? { cause } : {}), })); if (failure.retryClass === "recheck_evidence" && ["agent_stop_unconfirmed", "copyback_stop_unconfirmed"].includes(failure.code)) await recheckStoppedWorkspaceRecovery(db, input, false); - logger.warn({ err: error, runId: input.runId, failureCode: failure.code, phase: current!.phase }, "Workspace preservation deferred; source retained"); + logger.warn({ err: error, runId: input.runId, failureCode: failure.code, failureCause: cause?.label, phase: current!.phase }, "Workspace preservation deferred; source retained"); } finally { clearInterval(observer); await observing; } }; const owned = await withNativeWorkspaceFinalizationOwnership({ db, companyId: input.companyId, runId: input.runId, diff --git a/ui/src/components/ExecutionBlockerNotice.test.tsx b/ui/src/components/ExecutionBlockerNotice.test.tsx index b4ac75f8575..6386dbe7e32 100644 --- a/ui/src/components/ExecutionBlockerNotice.test.tsx +++ b/ui/src/components/ExecutionBlockerNotice.test.tsx @@ -41,6 +41,25 @@ describe("ordinary task interruption", () => { it.each(["working", "resolved"] as const)("does not duplicate the thread indicator for %s", async state => { await render({ ...failed, state }); expect(container.textContent).toBe(""); }); + it("names the saved-work recovery step instead of a bare queue", async () => { + await render({ ...failed, state: "working", canRetry: false, recoveryStep: "saving_previous_files" }); + expect(container.textContent).toBe("Saving files from the previous attempt…"); + expect(container.querySelector("button")).toBeNull(); + }); + it("names the step a scheduled recovery will retry", async () => { + vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-20T00:00:00Z")); + await render({ ...failed, state: "retry_scheduled", nextRetryAt: "2026-10-20T00:00:30Z", recoveryStep: "checking_previous_run" }); + expect(container.textContent).toContain("Checking that the previous attempt stopped. Trying again in 30s…"); + expect(container.querySelector("button")?.textContent).toBe("Retry"); + }); + it("names the recovery step inside a provider failure notice", async () => { + vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-20T00:00:00Z")); + await render({ ...failed, state: "retry_scheduled", nextRetryAt: "2026-10-20T00:00:30Z", recoveryStep: "checking_previous_run", + providerFailure: { schema: "paperclip.provider-failure.v1", provider: "openai", phase: "startup", category: "overloaded", + retryability: "unknown", message: "Try later." } }); + expect(container.textContent).toContain("Checking that the previous attempt stopped. Trying again in 30s…"); + expect(container.textContent).not.toContain("Paperclip will retry automatically."); + }); it("leaves genuine user decisions to their specific action surface", async () => { await render({ ...failed, responsibility: "user", state: "waiting_for_user", userAction: "approval", canRetry: false }); expect(container.textContent).toBe(""); diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index 31209cc4e97..583fe53bca2 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -7,6 +7,12 @@ import { useSecondTick } from "../hooks/useSecondTick"; import { TaskProjectAccessNotice } from "./TaskProjectAccessNotice"; import { ProviderFailureNotice } from "./ProviderFailureNotice"; +const recoveryStepText: Record, string> = { + checking_previous_run: "Checking that the previous attempt stopped", + saving_previous_files: "Saving files from the previous attempt", + finishing_recovery: "Finishing recovery of saved work", +}; + /** One ordinary interruption surface. Technical records remain in diagnostics. */ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className, issueId, agentName, projectName }: { presentation: TaskInterruptionPresentation | null | undefined; @@ -26,15 +32,22 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi canRetry={canRetry && presentation.canRetry} onRetry={onRetry} error={error} className={className} />; if (error && !optimistic && !takingLonger) return
{describeError(error).body}{canRetry && }
; if (takingLonger) return
Taking longer than usual…{canRetry && }
; + const step = !optimistic && presentation?.responsibility === "system" && presentation.recoveryStep + && ["working", "retry_scheduled"].includes(presentation.state) ? recoveryStepText[presentation.recoveryStep] : null; + const seconds = Math.max(0, Math.ceil((Date.parse(presentation?.nextRetryAt ?? "") - Date.now()) / 1000)); + const retryIn = `Trying again in ${seconds > 60 ? `${Math.ceil(seconds / 60)}m` : `${seconds}s`}…`; if (!optimistic && !presentation?.retryWait && presentation?.providerFailure && ["failed", "retry_scheduled", "waiting_for_user"].includes(presentation.state)) { + // Saved-work recovery is what the task waits for, so name it over the + // generic automatic-retry line while keeping the provider explanation. return Retry : undefined}> - {presentation.state === "retry_scheduled" &&

Paperclip will retry automatically.

} + {presentation.state === "retry_scheduled" &&

{step ? `${step}. ${retryIn}` : "Paperclip will retry automatically."}

}
; } + if (step && presentation?.state === "working") return
{`${step}…`}
; if (optimistic || !presentation || presentation.responsibility !== "system" || !["failed", "retry_scheduled"].includes(presentation.state)) return null; - const seconds = Math.max(0, Math.ceil((Date.parse(presentation.nextRetryAt ?? "") - Date.now()) / 1000)); return (
{presentation.state === "failed" &&