diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index f17a4cdb0db..4402393073c 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -966,6 +966,18 @@ A new user message can continue a terminal native run whose process fields were The task thread exposes the guarded Retry action for failed or timed-out conversation runs and native preparation cancelled before provider startup when the never-started proof is verified. The server projects this eligibility on the recovery notice and rechecks it on Retry. Cleanup quarantine retains its inspection path, and non-conversation reconciliation gates remain enforced. Pending decisions, active execution, pause, budget, dependency, and ownership gates remain in force. A refused Retry reports its reason inline and remains available for another attempt. When a newer linked-human GitHub message was saved but its admission was skipped by a budget or agent-availability gate, ordinary task Retry selects that unstarted message before considering the older failed run. The existing chat task-Retry owner snapshots the complete pending batch, including eligible skipped input, into a new admission generation bound to the canonical processed deliveries, comments, principal, session generation, admitted instruction snapshots, and task Retry intent. It retains the original skipped receipt and failed result unchanged. A terminal failed publication of that old result also remains history for a distinct selected request; pending or uncertain publication still holds work, and the old output is never replayed. Board Retry refusals expose only stable allowlisted authority categories, never source payloads, identifiers, or credential fingerprints. Admission rechecks source integrity and current access, then uses the normal new-user-turn continuation proof: a committed failed result can remain history only after controller, successor, process, workspace, and exact remote-termination guards pass. Unknown ownership stays deferred; Retry cannot certify it. The existing chat retry queue owns delivery and bounded infrastructure retries; a committed receipt settles a lost acknowledgment without another execution. An accepted saved-input receipt discharges only its matching unadmitted-input gate, allowing later genuine conversation or tree-resume wakes without replaying the skipped request. +A deliberate Retry also binds an already completed file-preservation record to +that intent. Other recovery holds for the same source run can then use its saved +snapshot when selecting a fresh workspace. This binding preserves the recovery +generation, attempt budget, saved files, and stop checks; it does not repeat file +recovery or replay the failed turn. An accepted Retry that remains deferred shows +its persisted admission reason through a fixed public message. The task view +performs bounded follow-up reads after the click so asynchronous admission can +explain its wait without a page reload or idle polling. +Newer saved input keeps its own receipt while the existing Retry explanation stays +visible. Terminal waits direct the user to send new input and hide the Retry action +that cannot continue that attempt. + A continuation cancelled by the queued-run gate while waiting for review did not start a provider. Its matching dispatch-gate receipt and unclaimed execution fields keep this deliberate wait out of unknown-action reconciliation. Pending review and decision gates still apply. If an older server already created a recovery hold for this exact state, Retry or a new user message can request one fresh turn after the server verifies there is no provider, coordinator, or unfinished cleanup. Saved user input is reconsidered through the same admission path; the cancelled turn is not replayed. Other legacy queued-run gate cancellations also stay out of unknown-action reconciliation when the terminal run has no retained execution authority, session, workspace recovery, resources, or execution events. The same proof removes the effect of an old false recovery hold only for its exact company, task, and run; the audit row remains. Started or conflicting work retains its hold. Review waits retain their guarded Retry path. New work still passes the current approval, dependency, budget, pause, and ownership gates. diff --git a/packages/shared/src/types/execution-projection.ts b/packages/shared/src/types/execution-projection.ts index 0dd0c3a8898..fd156eafc6f 100644 --- a/packages/shared/src/types/execution-projection.ts +++ b/packages/shared/src/types/execution-projection.ts @@ -101,6 +101,8 @@ export interface ExecutionReconciliation { export interface TaskInterruptionPresentation { providerFailure?: import("../provider-failure.generated.js").ProviderFailure; workspaceFailure?: { message: string }; + /** A persisted Retry admission wait, normalized to public text by the server. */ + retryWait?: { reason: string; message: string }; intentId: string | null; responsibility: "system" | "user"; state: "working" | "retry_scheduled" | "failed" | "waiting_for_user" | "resolved"; diff --git a/server/src/services/task-interruption-presentation.test.ts b/server/src/services/task-interruption-presentation.test.ts index f9d67e6d7e9..f6cd4681ddc 100644 --- a/server/src/services/task-interruption-presentation.test.ts +++ b/server/src/services/task-interruption-presentation.test.ts @@ -7,6 +7,28 @@ const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: n resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } }; const agent = { status: "idle", pauseReason: null }; +it("explains a persisted Retry wait without exposing the saved payload", () => { + const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null }; + const wait = { id: "retry", requestedAt: new Date(), sourceRunId: failed.id, + executionWaitReason: "workspace_repair_required" }; + const presentation = projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false }); + expect(presentation).toMatchObject({ state: "failed", canRetry: true, + retryWait: { reason: "workspace_repair_required", message: "Retry is waiting for Paperclip to recover the saved files. Your message is saved." } }); + expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, sourceRunId: "another-run" }, paused: false, approval: false })) + .not.toHaveProperty("retryWait"); + expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, executionWaitReason: "secret-payload" }, paused: false, approval: false })) + .toMatchObject({ retryWait: { reason: "execution_recovery", message: "Retry is waiting for execution checks. Your message is saved." } }); + expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: true, approval: false })) + .toMatchObject({ state: "waiting_for_user", userAction: "resume" }); +}); + +it.each(["cleanup_quarantined", "cancelled_by_operator"])("does not offer Retry for terminal Retry waits: %s", reason => { + const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null }; + expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false, + wait: { id: "retry", requestedAt: new Date(), sourceRunId: failed.id, executionWaitReason: reason } })) + .toMatchObject({ state: "failed", canRetry: false, retryWait: { reason } }); +}); + it("preserves the actual workspace preparation error without changing retry authority", () => { const failedRun = { ...run, errorCode: "workspace_validation_failed", error: "The selected project has no repository source for a remote workspace.", resultJson: null }; diff --git a/server/src/services/task-interruption-presentation.ts b/server/src/services/task-interruption-presentation.ts index d84e5c5f25d..31e7901538d 100644 --- a/server/src/services/task-interruption-presentation.ts +++ b/server/src/services/task-interruption-presentation.ts @@ -19,10 +19,12 @@ type ProjectionIssue = Pick & Partial>; +type ProjectionWait = { id: string; requestedAt: Date; sourceRunId?: string | null; executionWaitReason?: string | null }; type ProjectionState = { run?: ProjectionRun; action?: typeof issueRecoveryActions.$inferSelect; - wait?: { id: string; requestedAt: Date }; + wait?: ProjectionWait; + retryWait?: ProjectionWait; agent?: Pick; projectAccessAllowed?: boolean; projectAccessProjectId?: string | null; @@ -34,9 +36,27 @@ type ProjectionState = { }; function boundedJsonString(value: SQL, limit: number) { - return sql`case when jsonb_typeof(${value}) = 'string' then left(${value} #>> '{}', ${limit}) else null end`; + return sql`case when jsonb_typeof(${value}) = 'string' then left(${value} #>> '{}', ${limit}) else null end`; } +// Persisted wait messages can contain diagnostics. Ordinary task views expose +// only these stable categories, never arbitrary payloads or source identifiers. +const retryWaitMessages: Record = { + workspace_repair_required: "Retry is waiting for Paperclip to recover the saved files. Your message is saved.", + remote_cleanup: "Retry is waiting for the previous environment to stop. Your message is saved.", + local_cleanup: "Retry is waiting for the previous execution to finish cleanup. Your message is saved.", + cleanup_pending: "Retry is waiting for the previous execution to finish cleanup. Your message is saved.", + execution_settling: "Retry is waiting for the previous execution to stop. Your message is saved.", + controller_settling: "Retry is waiting for the previous execution to finish recovery. Your message is saved.", + process_identity_missing: "Retry is waiting for Paperclip to verify that the previous execution stopped. Your message is saved.", + process_running: "Retry is waiting for the previous execution to stop. Your message is saved.", + execution_active: "Retry is waiting for the current run to finish. Your message is saved.", + decision_pending: "Retry is waiting for a pending approval or question to be resolved. Your message is saved.", + finalizing_saved_result: "Paperclip is finishing the saved result before Retry can continue.", + cleanup_quarantined: "Retry cannot continue this attempt. Send a new message after the previous execution has stopped.", + cancelled_by_operator: "This attempt was stopped. Send a new message to continue from saved work.", +}; + /** Recognize old persisted failures without displaying arbitrary run error text. */ function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined { const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary."; @@ -51,6 +71,7 @@ function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | * locks. The same projection is used in detail, lists and attention surfaces. */ export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null { const { run, wait, action, agent, project, company } = input; + const retryWait = input.retryWait ?? wait; if (!run) return null; const system = readSystemRecovery(action?.evidence); const base: TaskInterruptionPresentation = { intentId: wait?.id ?? null, responsibility: "system", @@ -90,6 +111,13 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio const activeObservation = action?.evidence.systemRecoveryActiveAt; if (system?.state === "running" && typeof activeObservation === "number" && activeObservation > now - 45_000) return { ...base, state: "working" }; + 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. + return { ...base, state: "failed", + canRetry: !["cleanup_quarantined", "cancelled_by_operator"].includes(reason), retryWait: { reason, + message: retryWaitMessages[reason] ?? "Retry is waiting for execution checks. Your message is saved." } }; + } if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted") return { ...base, state: "working" }; return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) }; @@ -118,6 +146,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i const latestRuns = db.select({ id: candidates.id }).from(candidates) .orderBy(desc(candidates.createdAt), desc(candidates.id)).limit(1).as("latest_interruption_runs"); const wakeIssue = sql`${agentWakeupRequests.payload}->>'issueId'`; + const isRetryWait = sql`coalesce(jsonb_typeof(${agentWakeupRequests.payload}#>'{taskRetry,sourceRunId}') = 'string', false)`; const agentIds = selected.flatMap(issue => issue.assigneeAgentId ? [issue.assigneeAgentId] : []); const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []); const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows] = await Promise.all([ @@ -148,10 +177,14 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i }).from(requested).innerJoinLateral(latestRuns, sql`true`).innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, latestRuns.id))) .leftJoin(agents, and(eq(agents.companyId, heartbeatRuns.companyId), eq(agents.id, heartbeatRuns.agentId))) .orderBy(requestedIssueId), - db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt }) + // Keep at most two candidates per task: latest Retry and latest other input. + // A new message must not hide an existing Retry's admission explanation. + db.selectDistinctOn([wakeIssue, isRetryWait], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt, + sourceRunId: boundedJsonString(sql`${agentWakeupRequests.payload}#>'{taskRetry,sourceRunId}'`, 160), + executionWaitReason: boundedJsonString(sql`${agentWakeupRequests.payload}#>'{executionWait,reason}'`, 80) }) .from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), inArray(wakeIssue, ids), eq(agentWakeupRequests.status, "deferred_issue_execution"), eq(agentWakeupRequests.requestedByActorType, "user"))) - .orderBy(wakeIssue, desc(agentWakeupRequests.requestedAt)), + .orderBy(wakeIssue, isRetryWait, desc(agentWakeupRequests.requestedAt), desc(agentWakeupRequests.id)), agentIds.length ? db.select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason }).from(agents) .where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))) : [], projectIds.length ? db.select({ id: projects.id, pausedAt: projects.pausedAt, pauseReason: projects.pauseReason }).from(projects) @@ -173,7 +206,13 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i sql`${issueRecoveryActions.evidence} ? 'systemRecovery'`, )).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : []; const byIssue = (rows: T[]) => new Map(rows.map(row => [row.issueId, row])); - const runMap = byIssue(runs), waitMap = byIssue(waits); + const runMap = byIssue(runs), waitMap = new Map(); + const retryWaitMap = byIssue(waits.filter(wait => wait.sourceRunId)); + for (const wait of waits) { + const current = waitMap.get(wait.issueId); + if (!current || wait.requestedAt > current.requestedAt || + (wait.requestedAt.getTime() === current.requestedAt.getTime() && wait.id > current.id)) waitMap.set(wait.issueId, wait); + } // Recheck current authority, including standard trust, root-issue grants and // project policy. Do not reuse the failed run's frozen policy for a new retry. // Only denied tasks need these reads; deduplicate projects and batch per agent. @@ -205,7 +244,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i const required = requirements.get(issue.id); const denied = required?.projectIds.filter(projectId => currentAccess.get(`${issue.assigneeAgentId}:${projectId}`) !== true); const presentation = projectTaskInterruption(issue, { - run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id), + run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id), retryWait: retryWaitMap.get(issue.id), projectAccessAllowed: denied?.length === 0, projectAccessProjectId: denied?.includes(issue.projectId ?? "") ? issue.projectId : null, workspaceSelectionAvailable: required?.available ?? false, diff --git a/server/src/services/task-retry.ts b/server/src/services/task-retry.ts index d1fb8fd9b6e..31180616cbb 100644 --- a/server/src/services/task-retry.ts +++ b/server/src/services/task-retry.ts @@ -331,8 +331,11 @@ export async function requestTaskRetry(input: { if (!busy) { for (const action of records) { const previous = readSystemRecovery(action.evidence); - if (!previous || previous.state === "complete") continue; - if (previous.state === "running") { + if (!previous) continue; + // A completed preservation still owns its snapshot evidence. Bind the + // new intent so sibling holds can select that same continuation, without + // repeating recovery or renewing its attempt budget. + if (previous.state === "complete" || previous.state === "running") { await updateSystemRecovery(db, { companyId: issue.companyId, issueId: issue.id, runId: latest.id, actionId: action.id }, previous, { ...previous, intentId: active?.id ?? id }); continue; diff --git a/server/src/services/workspace-recovery.test.ts b/server/src/services/workspace-recovery.test.ts index 3768ae1528b..4b7f5390a5c 100644 --- a/server/src/services/workspace-recovery.test.ts +++ b/server/src/services/workspace-recovery.test.ts @@ -27,7 +27,7 @@ import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; import { workspaceOperationService } from "./workspace-operations.js"; import { instanceSettingsService } from "./instance-settings.js"; -import { projectTaskInterruption } from "./task-interruption-presentation.js"; +import { projectTaskInterruption, taskInterruptionPresentation } from "./task-interruption-presentation.js"; import * as nativeWorkspaceSync from "./native-runtime/native-workspace-sync.js"; import { GitShallowMetadataError } from "@paperclipai/adapter-utils/git-workspace-sync"; import { readSystemRecovery } from "./system-recovery-state.js"; @@ -1674,6 +1674,66 @@ describe("automatic retained workspace recovery", () => { } }, 30_000); + it.each(["stopped", "unverified_stop"])("retries a recovered snapshot with sibling holds once: %s", async state => { + const f = await seed("native", false); + await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: { + schema: "paperclip.native-workspace-sync/v3", state: "prepared", descriptorSha256: "a".repeat(64), + baselineSha256: "b".repeat(64), finalHostSha256: null, workspaceId: f.issueId, + leaseId: f.lease.id, providerLeaseId: f.providerLeaseId, remoteCwd: f.remote, resourceDisposition: "keep_running", + } } }).where(eq(heartbeatRuns.id, f.runId)); + await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, + kind: "active_run_watchdog", status: "resolved", ownerType: "board", outcome: "blocked", + cause: "provider_failure_meaning_unverified", fingerprint: randomUUID(), nextAction: "Inspect original failure", + evidence: { runId: f.runId, automaticRecovery: { replay: "blocked" } } }); + const source = await run(f.runId); + await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: source.agentId, status: "running" }); + await recoverRetainedWorkspace(f.input); + const recovered = readSystemRecovery((await action(f.actionId)).evidence)!; + expect(recovered.state).toBe("complete"); + expect(recovered.intentId).toBeNull(); + if (state === "unverified_stop") await db.update(environmentLeases).set({ metadata: f.lease.metadata }) + .where(eq(environmentLeases.id, f.lease.id)); + const receipt = await requestTaskRetry({ db, companyId: f.companyId, issueId: f.issueId, + actorId: "board", actorSource: "local_implicit", idempotencyKey: randomUUID(), observedRunId: f.runId }); + const service = heartbeatService(db); + await Promise.all([service.resumeTaskRetry(f.companyId, receipt.requestId!), service.resumeTaskRetry(f.companyId, receipt.requestId!)]); + const turns = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued"))); + if (state === "unverified_stop") { + expect(turns).toHaveLength(0); + const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.requestId!)); + expect(wake.payload?.executionWait).toMatchObject({ reason: "remote_cleanup" }); + const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId)); + expect(await taskInterruptionPresentation(db, task)).toMatchObject({ state: "failed", canRetry: true, + retryWait: { reason: "remote_cleanup", message: "Retry is waiting for the previous environment to stop. Your message is saved." } }); + const commentId = randomUUID(), messageWakeId = randomUUID(); + await db.insert(issueComments).values({ id: commentId, companyId: f.companyId, issueId: f.issueId, + authorType: "user", authorUserId: "board", body: "Keep my saved message while the previous environment stops." }); + await db.insert(agentWakeupRequests).values({ id: messageWakeId, companyId: f.companyId, agentId: wake.agentId, + source: "automation", reason: "issue_commented", status: "deferred_issue_execution", + requestedByActorType: "user", requestedByActorId: "board", requestedAt: new Date(wake.requestedAt.getTime() + 1), + payload: { issueId: f.issueId, commentId } }); + expect(await taskInterruptionPresentation(db, task)).toMatchObject({ intentId: messageWakeId, + retryWait: { reason: "remote_cleanup" } }); + await db.update(agentWakeupRequests).set({ status: "coalesced" }).where(eq(agentWakeupRequests.id, receipt.requestId!)); + const newInput = await taskInterruptionPresentation(db, task); + expect(newInput).toMatchObject({ state: "working", intentId: messageWakeId }); + expect(newInput).not.toHaveProperty("retryWait"); + return; + } + expect(turns).toHaveLength(1); + expect(turns[0].contextSnapshot).toMatchObject({ forceFreshSession: true, previousRunId: f.runId, taskRetryRequestId: receipt.requestId }); + expect(await readFreshWorkspaceReadiness(db, turns[0])).toMatchObject({ mode: "fresh_isolated_workspace", sourceRunId: f.runId }); + const fresh = path.join(root, randomUUID()); await mkdir(fresh); + const attached = await attachPreservedWorkspaceSnapshots({ db, companyId: f.companyId, issueId: f.issueId, + sourceRunIds: [turns[0].id], cwd: fresh }); + expect(await readFile(path.join(attached[0].path, "files", "saved.txt"), "utf8")).toBe("finished work\n"); + expect(await readFile(path.join(f.host, "newer.txt"), "utf8")).toBe("newer local work\n"); + const final = readSystemRecovery((await action(f.actionId)).evidence)!; + expect(final).toMatchObject({ generationId: recovered.generationId, attempts: recovered.attempts, + retriesUsed: recovered.retriesUsed, preservationOperationIds: recovered.preservationOperationIds, intentId: receipt.requestId }); + expect((await run(f.runId)).status).toBe("failed"); + }, 30_000); + it.each(["copyback_unknown", "source_unavailable", "executor_unknown"])("ordinary task Retry evaluates fresh isolation independently: %s", async failure => { const f = await seed("legacy", false); const source = await run(f.runId); diff --git a/ui/src/components/ExecutionBlockerNotice.test.tsx b/ui/src/components/ExecutionBlockerNotice.test.tsx index bf16c89ff5c..b4ac75f8575 100644 --- a/ui/src/components/ExecutionBlockerNotice.test.tsx +++ b/ui/src/components/ExecutionBlockerNotice.test.tsx @@ -70,6 +70,17 @@ describe("ordinary task interruption", () => { await render({ ...failed, workspaceFailure, state: "working" }); expect(container.textContent).toBe(""); }); + it("shows why an accepted Retry is waiting and keeps another attempt available", async () => { + const retryWait = { reason: "remote_cleanup", message: "Retry is waiting for the previous environment to stop. Your message is saved." }; + await render({ ...failed, retryWait }); + expect(container.textContent).toContain(retryWait.message); + expect(container.textContent).not.toContain("Couldn’t finish this attempt."); + await act(async () => container.querySelector("button")!.click()); + expect(retry).toHaveBeenCalledOnce(); + await render({ ...failed, retryWait }, { canRetry: false }); + expect(container.textContent).toContain(retryWait.message); + expect(container.querySelector("button")).toBeNull(); + }); it("shows an unknown provider's original explanation as text and preserves read-only access", async () => { const providerFailure = { schema: "paperclip.provider-failure.v1" as const, provider: "new-provider", phase: "startup" as const, category: "unknown" as const, retryability: "unknown" as const, httpStatus: 429, diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index 48b0ac8444b..2c13d9e6975 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -25,7 +25,7 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi canRetry={canRetry && presentation.canRetry} onRetry={onRetry} error={error} className={className} />; if (error && !optimistic && !takingLonger) return
{error.message}{canRetry && }
; if (takingLonger) return
Taking longer than usual…{canRetry && }
; - if (!optimistic && presentation?.providerFailure && ["failed", "retry_scheduled", "waiting_for_user"].includes(presentation.state)) { + if (!optimistic && !presentation?.retryWait && presentation?.providerFailure && ["failed", "retry_scheduled", "waiting_for_user"].includes(presentation.state)) { return Retry : undefined}> {presentation.state === "retry_scheduled" &&

Paperclip will retry automatically.

} @@ -43,6 +43,7 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi {presentation.state === "failed" &&