fix(issues): a Retry held by a blocked subtask names it instead of failing silently (#20109)

## 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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-08 17:18:55 -06:00
1 parent 77168d3367
commit 4420a5adcf
6 files changed
+150 -6

No files matched your search

@@ -3,8 +3,13 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import type { AnchorHTMLAttributes } from "react";
import { ExecutionBlockerNotice } from "./ExecutionBlockerNotice";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => <a href={to} {...props}>{children}</a>,
}));
const failed: TaskInterruptionPresentation = {
intentId: "intent", responsibility: "system", state: "failed", nextRetryAt: null,
canRetry: true, userAction: null, sourceRunIds: ["source"], observedRunId: "source",
@@ -47,6 +52,36 @@ describe("ordinary task interruption", () => {
expect(container.textContent).toBe("Waiting for the budget to allow new work. The task starts automatically when it does.");
expect(container.querySelector("button")).toBeNull();
});
it("names and links the readable blockers a saved Retry waits on, with no second Retry (restart chaos t05)", async () => {
const message = "Retry is waiting on a blocked task this one depends on. Retry or unblock it; this task continues when it is done. Your Retry is saved.";
const summary = (id: string, identifier: string, title: string, status: string) =>
({ id, identifier, title, status, priority: "medium", assigneeAgentId: null, assigneeUserId: null }) as never;
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
blockers={[summary("child", "CHA-10", "Part 2", "blocked"), summary("gone", "CHA-11", "Part 3", "cancelled"),
summary("done", "CHA-9", "Part 1", "done")]} />));
expect(container.textContent).toContain(message);
const links = [...container.querySelectorAll<HTMLAnchorElement>("[data-testid='retry-waiting-on'] a")].map(link => link.getAttribute("href"));
expect(links).toHaveLength(2);
expect(links[0]).toContain("CHA-10");
expect(container.textContent).toContain("Blocked: open it to Retry");
expect(container.textContent).toContain("Cancelled: remove or replace it");
expect(container.textContent).not.toContain("Part 1");
expect(container.querySelector("button")).toBeNull();
});
it("shows a locked blocker without reading its status, and a finished blocker still saving files", async () => {
const message = "Retry is waiting for a finished task this one depends on to save its files. Your Retry is saved.";
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
blockers={[{ id: "secret", identifier: "CHA-12", locked: true } as never]} />));
expect(container.querySelector("[data-testid='locked-issue-chip']")?.textContent).toContain("CHA-12");
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
blockers={[{ id: "done", identifier: "CHA-9", title: "Part 1", status: "done", priority: "medium",
assigneeAgentId: null, assigneeUserId: null } as never]} />));
expect(container.querySelector("[data-testid='retry-waiting-on'] a")?.getAttribute("href")).toContain("CHA-9");
expect(container.textContent).toContain("Saving its files");
});
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…");
+32 -4
View File
@@ -1,11 +1,14 @@
import { describeError } from "../api/errors";
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
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",
@@ -13,9 +16,16 @@ const recoveryStepText: Record<NonNullable<TaskInterruptionPresentation["recover
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 }: {
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;
@@ -47,8 +57,26 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
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) return <div role="status"
aria-label="Task status" className={cn("text-sm text-muted-foreground", className)}>{presentation.retryWait.message}</div>;
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 (