Files
PaperClipAI/ui/src/components/ExecutionBlockerNotice.tsx
T
976cdc77ed fix(recovery): expose settled holds and their preserved decisions (#16169)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task recovery preserves work when execution or tool outcomes are
uncertain.
> - Automatic recovery can settle its record while keeping replay
blocked.
> - The recovery-actions API omitted those records unless they held
workspace files.
> - Operators and agent tools could therefore see an empty recovery
history for a task that still had an execution hold.
> - This pull request lists those holds and shows their matching
decision in the existing stopped-run notice.
> - Existing stop verification, continuation, and file repair gates
remain in force.

## Linked Issues or Issue Description

**What happened?**

A cancelled or failed task can retain a settled recovery record with
`automaticRecovery.replay: blocked`. The task remains held, but the
recovery-actions API returns no action. The board notice also lacks that
record's decision.

**Expected behavior**

List settled execution holds that still block replay. Let an authorized
board user inspect the current action and run in Recovery details.
Preserve the existing guarded continuation and required-file repair
paths.

**Steps to reproduce**

1. Stop a run whose outcome requires inspection.
2. Let automatic recovery settle its record with replay blocked.
3. Read the task's recovery actions and open its stopped-run notice.
4. Before this change, the action list is empty unless workspace
recovery is required.

**Paperclip version or commit**

This PR includes current master through `66424362a85`.

**Deployment mode**

Authenticated and local deployments with persisted execution recovery
holds.

The original API correction and route regression are by Tao Xie
(@TaoXieSZ). This update preserves those commits and adds the board
inspection path, current documentation, and regression coverage.

Related: #14840 describes controller lease loss. This change exposes
recovery records; it does not change controller lease renewal.

## What Changed

- Include settled execution reconciliation holds with replay blocked in
the company-scoped recovery-actions response.
- Read the matching action from the existing stopped-run notice. Show
its decision under Recovery details, including when active recovery is
empty.
- Reject mismatched historical records in the notice. Bind history to
the current action/run and refresh visible decisions every five seconds.
Keep history read errors visible beside the current guidance.
- Preserve existing run inspection, new-message continuation, and
required-file repair controls. No new reconciliation form is added.
- Update the execution documentation and both recovery capability maps.

## Verification

- Recovery route suite with real embedded PostgreSQL: 80 passed, none
skipped.
- Notice and retry-client suites: 36 passed.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- `pnpm check:token-gates`: passed.
- `node scripts/check-maps.mjs`: passed. This checks structure only.
- `git diff --check`: passed.
- Full `pnpm test:run` was started. It is not yet a full local pass.
Initial database tests were affected by missing packaged macOS library
aliases; the aliases were restored in this worktree's dependencies, and
the complete recovery route suite then passed.
- All CI checks passed on `09e4461e4d3a0960a4dc8bc270a2a10c628bf524`,
including the full test matrix, typecheck, build and browser checks.
Optional Storybook jobs were skipped.
- Apex: 5/5 on the same head. Both review threads are resolved; the
reviewer confirmed the existing guarded continuation design.

## Risks

- The API response now includes settled records that still block replay.
Consumers must distinguish a settled record from permission to continue.
- Recovery history is supplemental. A failed history read keeps the
current blocker and guidance visible.
- The UI binds the decision to both the current action and run. It does
not certify tool outcomes or grant execution authority.
- No schema, dependency, telemetry, or runtime admission changes.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, tool use, and code
execution, assisted with the board inspection and verification
follow-up. The exact serving snapshot ID and context window size are not
exposed in this session. The original API work is retained from Tao
Xie's contribution.

## 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 (targeted suites;
full-suite limits above)
- [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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Devin Foley <devin@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-14 17:32:35 -07:00

100 lines
5.4 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionBlocker, Issue } from "@paperclipai/shared";
import { WorkspaceFileRecovery } from "./WorkspaceFileRecovery";
import { agentsApi } from "../api/agents";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { Link } from "../lib/router";
export function ExecutionBlockerNotice({ companyId, issueId, issueStatus, canManage = false, blocker, onRetried, className }: {
className?: string;
companyId: string;
issueId: string;
issueStatus?: Issue["status"];
canManage?: boolean;
blocker: ExecutionBlocker;
onRetried: () => void;
}) {
const queryClient = useQueryClient();
const { data: recovery, error: recoveryError } = useQuery({
queryKey: queryKeys.issues.recoveryActions(issueId, blocker.recoveryActionId ?? null, blocker.runId ?? null),
queryFn: () => issuesApi.recoveryActions(issueId),
enabled: canManage && Boolean(blocker.recoveryActionId) && !["done", "cancelled"].includes(issueStatus ?? ""),
refetchInterval: 5_000,
});
const hold = recovery?.actions.find(action => action.id === blocker.recoveryActionId &&
(action.evidence.runId ?? action.evidence.sourceRunId) === blocker.runId);
const replayBlocked = (hold?.evidence.automaticRecovery as { replay?: unknown } | undefined)?.replay === "blocked";
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={cn("task-context-notice flex flex-wrap items-center justify-between gap-2", className)}>
<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>}
{blocker.workspaceRepairRequired ? (
<>
{/* The stored next action is an operator runbook step; lead with what happened. This hold also covers
unsafe archives and local runs, so do not claim retries or a retained sandbox here. */}
<p>
Paperclip could not restore the agent's workspace files after its last run. A board operator must
recover the files before the agent can continue.
</p>
<details>
<summary className="cursor-pointer">Technical details</summary>
<p>{blocker.nextAction}</p>
</details>
</>
) : <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>}
{canManage && hold && <details className="w-full">
<summary className="cursor-pointer">Recovery details</summary>
{replayBlocked &&
<p>Paperclip stopped automatic retries to avoid repeating work with an unknown outcome. Inspect the run before requesting continuation.</p>}
{hold.resolutionNote && <p>{hold.resolutionNote}</p>}
</details>}
{recoveryError && <p role="alert" className="w-full text-destructive">Unable to load recovery details: {recoveryError.message}</p>}
{issueStatus && <WorkspaceFileRecovery key={`${blocker.recoveryActionId}:${blocker.runId}`} issueId={issueId}
issueStatus={issueStatus} blocker={blocker} canManage={canManage} onRecorded={onRetried} />}
</div>
);
}