mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
fix(recovery): show the saved-work recovery step and record why it failed (#19260)
## 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f61392cd6
commit
01dd6ab336
10 files changed
+122
-14
No files matched your search
@@ -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";
|
||||
}
|
||||
@@ -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 })))!;
|
||||
|
||||
@@ -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<typeof recoveryFailureCauseSchema>;
|
||||
|
||||
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") },
|
||||
};
|
||||
}
|
||||
@@ -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.";
|
||||
|
||||
@@ -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<string, unknown>) => ({ 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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -7,6 +7,12 @@ import { useSecondTick } from "../hooks/useSecondTick";
|
||||
import { TaskProjectAccessNotice } from "./TaskProjectAccessNotice";
|
||||
import { ProviderFailureNotice } from "./ProviderFailureNotice";
|
||||
|
||||
const recoveryStepText: Record<NonNullable<TaskInterruptionPresentation["recoveryStep"]>, 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 <div role="alert" className={cn("flex items-center justify-between gap-2 text-sm text-destructive", className)}><span>{describeError(error).body}</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
|
||||
if (takingLonger) return <div role="status" className={cn("flex items-center justify-between gap-2 text-sm text-muted-foreground", className)}><span>Taking longer than usual…</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
|
||||
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 <ProviderFailureNotice failure={presentation.providerFailure} className={className}
|
||||
actions={canRetry && presentation.canRetry ? <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button> : undefined}>
|
||||
{presentation.state === "retry_scheduled" && <p>Paperclip will retry automatically.</p>}
|
||||
{presentation.state === "retry_scheduled" && <p>{step ? `${step}. ${retryIn}` : "Paperclip will retry automatically."}</p>}
|
||||
</ProviderFailureNotice>;
|
||||
}
|
||||
if (step && presentation?.state === "working") return <div role="status" aria-label="Task status"
|
||||
className={cn("text-sm text-muted-foreground", className)}>{`${step}…`}</div>;
|
||||
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 (
|
||||
<div className={cn(presentation.state === "failed" && "pt-4", className)}>
|
||||
<div role="status" aria-label="Task status" className={cn(
|
||||
@@ -43,7 +56,7 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
|
||||
)}>
|
||||
{presentation.state === "failed" && <AlertCircle className="size-5 shrink-0 text-destructive" aria-hidden="true" />}
|
||||
<span className="min-w-0 flex-1">{presentation.state === "retry_scheduled"
|
||||
? `Trying again in ${seconds > 60 ? `${Math.ceil(seconds / 60)}m` : `${seconds}s`}…`
|
||||
? step ? `${step}. ${retryIn}` : retryIn
|
||||
: presentation.retryWait ? presentation.retryWait.message
|
||||
: presentation.workspaceFailure
|
||||
? <>Couldn’t prepare the task folders. {presentation.workspaceFailure.message}</>
|
||||
|
||||
Reference in new issue
Block a user