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

@@ -65,6 +65,7 @@ type ProjectionState = {
ownerStopRequired?: boolean;
/** The latest wake for a task with no run yet, when admission refused it. */
admissionWait?: { id: string; status: string; reason: string | null; agentId: string };
retryDependencies?: RetryDependencies;
};
// A task read reports the change that made a task fail, not every later read of
@@ -122,6 +123,15 @@ const retryWaitMessages: Record<string, string> = {
retry_source_changed: "Retry cannot continue because its saved message changed. Send a new message to continue from saved work.",
};
type RetryDependencies = { count: number; blocked: boolean; cancelled: boolean; savingFilesOnly: boolean };
function dependencyRetryWaitMessage(dependencies: RetryDependencies): string {
if (dependencies.cancelled) return "Retry is waiting on a cancelled task this one depends on. Remove or replace that dependency to continue. Your Retry is saved.";
if (dependencies.blocked) return "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.";
if (dependencies.savingFilesOnly) return "Retry is waiting for a finished task this one depends on to save its files. Your Retry is saved.";
return "Retry is waiting for the tasks this one depends on to finish. Your Retry is saved.";
}
// A task that never started can wait for a temporary admission hold. The
// assigned-task liveness sweep admits it once the hold clears; no Retry needed.
const admissionWaitMessages: Record<string, string> = {
@@ -238,6 +248,10 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
if (nativeResumeAt && !system && now - nativeResumeAt < NATIVE_RESUME_DISPATCH_GRACE_MS)
return { ...base, state: "working" };
if (recoveryRunning) return { ...base, state: "working", ...(recoveryStep ? { recoveryStep } : {}) };
const dependencies = input.retryDependencies;
if (retryWait?.sourceRunId === run.id && !retryWait.executionWaitReason && !retryWait.preparationStatus && dependencies?.count)
return { ...base, intentId: retryWait.id, state: "working",
retryWait: { reason: "dependency_blocked", message: dependencyRetryWaitMessage(dependencies) } };
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.
@@ -431,6 +445,25 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
const byIssue = <T extends { issueId: string }>(rows: T[]) => new Map(rows.map(row => [row.issueId, row]));
const runMap = byIssue(runs), waitMap = new Map<string, typeof waits[number]>();
const retryWaitMap = byIssue(waits.filter(wait => wait.sourceRunId));
const retryIssueIds = [...retryWaitMap.keys()];
const retryDependencyMap = new Map<string, RetryDependencies>();
if (retryIssueIds.length) {
const { issueService } = await import("./issues.js");
const readiness = await issueService(db).listDependencyReadiness(companyId, retryIssueIds);
const blockerIds = [...new Set([...readiness.values()].flatMap(row => row.unresolvedBlockerIssueIds))];
const statusRows = blockerIds.length ? await db.select({ id: issues.id, status: issues.status }).from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.id, blockerIds))) : [];
const statusById = new Map(statusRows.map(row => [row.id, row.status]));
for (const [waitingIssueId, row] of readiness) {
const ids = [...new Set(row.unresolvedBlockerIssueIds)];
if (!ids.length) continue;
const statuses = ids.map(id => statusById.get(id));
const saving = new Set(row.pendingFinalizeBlockerIssueIds);
retryDependencyMap.set(waitingIssueId, { count: ids.length,
blocked: statuses.includes("blocked"), cancelled: statuses.includes("cancelled"),
savingFilesOnly: ids.every(id => saving.has(id)) });
}
}
for (const wait of waits) {
const current = waitMap.get(wait.issueId);
if (!current || wait.requestedAt > current.requestedAt ||
@@ -484,6 +517,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
ownerStopRequired: ownerStopRequired.has(`${issue.id}:${facts.run?.id}`),
admissionWait: admissionWaitMap.get(issue.id),
retryDependencies: retryDependencyMap.get(issue.id),
}, now);
result.set(issue.id, presentation);
const run = facts.run;