mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board's Retry is the one action a person takes when a task stops; it must either start work or say why it can't yet > - Restart chaos t05 left a parent task blocked on a blocked subtask. The board clicked Retry twice; each was accepted and nothing happened > - The Retry was saved and the queue deferred it behind the unresolved blocker, which is correct. But the task still looked failed with Retry offered, so the wait was invisible > - This pull request presents a saved Retry that waits only on unfinished blockers as waiting, names the blocked subtask and links it > - The benefit is that the board sees what to act on (the subtask's own Retry) instead of clicking a Retry that cannot run ## Linked Issues or Issue Description Refs #19831 (durable ownership plan). Restart chaos wave `w-20261108T200620-s9701`, task t05 (claude, M1, K4). **Problem:** parent CHA-5 was blocked by subtask CHA-10, which was itself blocked on a board recovery action. The board pressed Retry on CHA-5 at 20:27:46 and 20:40:46. Each `POST /issues/:id/retry` returned 202, and the saved Retry stayed `deferred_issue_execution` behind CHA-10, so no run started. The task's interruption presentation kept saying `failed` with `canRetry: true` and gave no reason. ## What Changed - `server/src/services/task-interruption-presentation.ts`: - For tasks with a saved (deferred, user-requested) Retry, the loader reads the queue's own dependency readiness (`listDependencyReadiness`). That counts cancelled blockers and done blockers whose workspace has not finalized, like the queue does. - When the saved Retry for the latest run is held only by unfinished blockers (no execution wait, no preparation), the task is presented as `working` with `canRetry: false` and `retryWait: { reason: "dependency_blocked", message }`. - The message has three forms: - a blocked blocker: "Retry or unblock it"; - a cancelled blocker: "Remove or replace that dependency to continue", because the queue keeps it unresolved; - otherwise: waiting for the blockers to finish. - The presentation carries no blocker names or ids. Issue reads return it to restricted agents too. - The Retry response (`POST /issues/:id/retry`) already returns this presentation, so the API says the Retry is waiting on dependencies as soon as it is saved. The blockers themselves are in the issue's `blockedBy`, which issue reads already filter per viewer. - `ui/src/components/ExecutionBlockerNotice.tsx` takes `blockers` (the issue's `blockedBy`, from `IssueDetail`). For a `dependency_blocked` wait it lists each readable unfinished blocker with a link and what it needs: "Blocked: open it to Retry" or "Cancelled: remove or replace it". Tokens only; `pnpm check:token-gates` is clean. - No shared type change, and no code comments added. ## Verification Red/green: - `server/src/__tests__/task-interruption-query.test.ts` "explains a saved Retry held by unfinished blockers instead of offering Retry again (restart chaos t05)". It sets up a parent with a failed run, a saved Retry deferred, and a blocked subtask that blocks it. - On master: `{ state: "failed", canRetry: true }`, no reason. - With this PR: `working`, `canRetry: false`, `dependency_blocked`. The presentation has no blocker title or identifier. - A cancelled blocker gets the remove-or-replace message. Once the blocker is done, the dependency wait is gone. - `ui/src/components/ExecutionBlockerNotice.test.tsx`: the notice links the blocked and the cancelled blockers with their actions, skips the done one, and offers no second Retry. Regression: `task-interruption-query` (25 tests) and `ExecutionBlockerNotice` (16 tests) pass, as do the presentation unit tests, workspace-recovery admission, gates and copyback tests, and `heartbeat-dependency-scheduling`. `tsc --noEmit` is clean for `server` and `ui`. ## Risks - One more batched read (dependency readiness plus blocker statuses), only for tasks in the page that have a saved Retry. - Presentation only. Admission is unchanged: the queue still runs the saved Retry when the blockers are done. - A Retry saved before a blocker was added shows the same wait, which is accurate, because the queue holds it too. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code, with tool use (shell, file editing). ## 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 (#20069) - [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 (code comments; no user docs affected) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending) - [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>
100 lines
6.4 KiB
TypeScript
100 lines
6.4 KiB
TypeScript
import { describeError } from "../api/errors";
|
||
import type { IssueRelationIssueSummary, TaskInterruptionPresentation } from "@paperclipai/shared";
|
||
import { AlertCircle } from "lucide-react";
|
||
import { Link } from "@/lib/router";
|
||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||
import { Button } from "./ui/button";
|
||
import { cn } from "../lib/utils";
|
||
import { useSecondTick } from "../hooks/useSecondTick";
|
||
import { TaskProjectAccessNotice } from "./TaskProjectAccessNotice";
|
||
import { ProviderFailureNotice } from "./ProviderFailureNotice";
|
||
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
|
||
|
||
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",
|
||
};
|
||
|
||
const blockerAction: Record<string, string> = {
|
||
blocked: "Blocked: open it to Retry",
|
||
cancelled: "Cancelled: remove or replace it",
|
||
done: "Saving its files",
|
||
};
|
||
|
||
/** One ordinary interruption surface. Technical records remain in diagnostics. */
|
||
export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className, issueId, agentName, projectName, blockers }: {
|
||
presentation: TaskInterruptionPresentation | null | undefined;
|
||
blockers?: IssueRelationIssueSummary[];
|
||
canRetry: boolean;
|
||
onRetry: () => void;
|
||
optimistic?: boolean;
|
||
takingLonger?: boolean;
|
||
error?: Error | null;
|
||
className?: string;
|
||
issueId?: string;
|
||
agentName?: string;
|
||
projectName?: string;
|
||
}) {
|
||
useSecondTick(presentation?.state === "retry_scheduled");
|
||
if (presentation?.projectAccess && !optimistic && !takingLonger) return <TaskProjectAccessNotice
|
||
access={presentation.projectAccess} issueId={issueId} agentName={agentName} projectName={projectName}
|
||
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>{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>;
|
||
// A task that has not started yet names the admission hold it waits for.
|
||
if (!optimistic && presentation?.state === "working" && presentation.retryWait) {
|
||
const dependencies = presentation.retryWait.reason === "dependency_blocked" ? blockers ?? [] : [];
|
||
const unfinished = dependencies.filter(blocker => isLockedIssueStub(blocker) || blocker.status !== "done");
|
||
const waitingOn = unfinished.length ? unfinished : dependencies;
|
||
return <div role="status" aria-label="Task status" className={cn("text-sm text-muted-foreground", className)}>
|
||
<p>{presentation.retryWait.message}</p>
|
||
{waitingOn.length ? <ul className="mt-1 space-y-0.5" data-testid="retry-waiting-on">
|
||
{waitingOn.map(blocker => isLockedIssueStub(blocker)
|
||
? <li key={blocker.id} className="flex min-w-0 items-center gap-2"><LockedIssueChip identifier={blocker.identifier} /></li>
|
||
: <li key={blocker.id} className="flex min-w-0 items-center gap-2">
|
||
<Link to={createIssueDetailPath(blocker.identifier ?? blocker.id)}
|
||
className="font-mono text-xs text-foreground underline-offset-2 hover:underline">
|
||
{blocker.identifier ?? "Task"}
|
||
</Link>
|
||
<span className="min-w-0 truncate">{blocker.title}</span>
|
||
<span className="shrink-0 text-xs">{blockerAction[blocker.status] ?? blocker.status.replace(/_/g, " ")}</span>
|
||
</li>)}
|
||
</ul> : null}
|
||
</div>;
|
||
}
|
||
if (optimistic || !presentation || presentation.responsibility !== "system" ||
|
||
!["failed", "retry_scheduled"].includes(presentation.state)) return null;
|
||
return (
|
||
<div className={cn(presentation.state === "failed" && "pt-4", className)}>
|
||
<div role="status" aria-label="Task status" className={cn(
|
||
"flex items-center justify-between gap-3 text-sm",
|
||
presentation.state === "failed" ? "rounded-lg bg-destructive/10 p-4 text-foreground" : "text-muted-foreground",
|
||
)}>
|
||
{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"
|
||
? step ? `${step}. ${retryIn}` : retryIn
|
||
: presentation.retryWait ? presentation.retryWait.message
|
||
: presentation.workspaceFailure
|
||
? <>Couldn’t prepare the task folders. {presentation.workspaceFailure.message}</>
|
||
: "Couldn’t finish this attempt. Your message is saved."}</span>
|
||
{canRetry && presentation.canRetry && <Button variant="outline" size="sm" className="shrink-0" onClick={onRetry}>Retry</Button>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|