Files
PaperClipAI/ui/src/components/ExecutionBlockerNotice.tsx
T
Devin FoleyandPaperclip f1c44b7b56 Preserve remote work when required workspace restore fails (#15479)
Persist exact source-retention obligations before run finalization and protect them across cancellation, restart and task changes. Require board-authorized repair evidence without replaying old work or changing current task ownership, state or locks. Hide an unavailable retry action and document operator recovery and retention costs.

Validated with 674 scoped regressions, 224 combined integration tests, full typecheck/build, independent safety reviews and green CI with Greptile 5/5. No historical file recovery is claimed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 13:29:44 -07:00

63 lines
3.2 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionBlocker } from "@paperclipai/shared";
import { agentsApi } from "../api/agents";
import { activityApi } from "../api/activity";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "./ui/button";
import { Link } from "../lib/router";
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried }: {
companyId: string;
issueId: string;
blocker: ExecutionBlocker;
onRetried: () => void;
}) {
const queryClient = useQueryClient();
const { data: runs, error: runsError } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
});
const failedRun = runs?.find(run => run.runId === blocker.runId &&
["failed", "timed_out"].includes(run.status));
const modelRejected = failedRun?.errorCode === "native_provider_model_rejected";
const requiresInspection = blocker.cause === "native_continuation_requires_reconciliation" ||
blocker.cause === "native_session_cleanup_quarantined";
const retry = useMutation({
mutationFn: () => agentsApi.retryFailedRun(blocker.agentId!, blocker.runId!, companyId),
onSuccess: () => {
onRetried();
for (const queryKey of [queryKeys.issues.detail(issueId), queryKeys.issues.runs(issueId),
queryKeys.issues.liveRuns(issueId), queryKeys.issues.activeRun(issueId)]) {
void queryClient.invalidateQueries({ queryKey });
}
},
});
return (
<div role="status" aria-label="Task recovery" className="mx-(--sz-execution-blocker-inline) my-(--sz-execution-blocker-block) flex flex-wrap items-center justify-between execution-blocker-notice border border-border bg-muted text-foreground">
<div className="min-w-0 flex-1 break-words">
<p>{modelRejected ? "Model unavailable." : "Recovery needed."}{blocker.runError ? ` ${blocker.runError}` : ""}</p>
{modelRejected && <p>Choose a supported model or clear the task's model override, then retry.</p>}
<p>{blocker.nextAction}</p>
{Boolean(blocker.savedMessageCount) && (
<p>{blocker.savedMessageCount} saved {blocker.savedMessageCount === 1 ? "message is" : "messages are"} waiting for recovery.</p>
)}
</div>
{blocker.agentId && blocker.runId && (
<Button variant="outline" size="sm" asChild>
<Link to={`/agents/${blocker.agentId}/runs/${blocker.runId}`}>Inspect run</Link>
</Button>
)}
{!blocker.workspaceRepairRequired && (!requiresInspection || blocker.canRetry) && blocker.agentId && blocker.runId &&
((blocker.cause === "legacy_execution_requires_reconciliation" && failedRun) || blocker.canContinue || blocker.canRetry) && (
<Button variant="outline" size="sm" disabled={retry.isPending} onClick={() => retry.mutate()}>
{retry.isPending ? "Starting…" : blocker.canContinue ? "Continue" : "Retry"}
</Button>
)}
{retry.isError && (
<p role="alert" className="w-full text-destructive">{retry.error.message}</p>
)}
{runsError && <p role="alert" className="w-full text-destructive">{runsError.message}</p>}
</div>
);
}