From df662197809ca7e05b4eb49479eefb474fc261de Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 21 Sep 2026 13:31:43 -0700 Subject: [PATCH] fix(ui): retry the latest failed task attempt (#13765) ## Thinking Path > - Paperclip manages work done by AI agents. > - The task thread lets an operator retry a failed run. > - Legacy runs with transcript output did not get a failure marker. > - The thread could therefore offer Try again for an older failure. > - The server correctly reused that failure's existing retry, even when it had already failed. > - This change keeps the latest failure actionable and reports stopped retry responses to the operator. ## Linked Issues or Issue Description **What happened?** Try again could return success without starting work. An initial setup failure had an empty transcript. Its later retry produced output and failed. Only the initial failure had a retry marker, so the button kept requesting the initial failure's already-failed successor. **Expected behavior** Try again targets the latest failed attempt. An already-stopped retry response shows an error and refreshes the task's run state. **Steps to reproduce** 1. Start a legacy adapter task that fails before producing transcript output. 2. Retry it. Let this attempt produce output and a final failure comment before it fails. 3. Click Try again in the task thread. 4. Before this fix, the click targets the original failure and replays the stopped successor. **Paperclip version or commit** Reproduced against `1483bb8bcf`; the regression is also present on the branch base `8813a50105`. **Deployment mode** Authenticated server with a legacy adapter. The bug is in the shared task UI and retry API client. Related: #11650 adds a different recovery-notice action. This change fixes failed-run markers and retry response handling. Searches found no duplicate of this failure case. ## What Changed - Render legacy failure markers even when the run has a transcript or final comment. - Keep later cancelled automatic retries from replacing the failed run's retry action. - Reject already-stopped retry responses in the API client so existing error feedback appears. - Refresh task run queries after both successful and failed retry requests. - Add regression coverage for failed and timed-out attempts, execution gates with output, and retry response states. ## Verification - Before the fix, the new regression tests failed: two selected the original failure, and four accepted a stopped successor as success. - Targeted task-thread, retry API, marker, and issue-page tests: 279 passed. - `pnpm --filter @paperclipai/ui typecheck`: passed. - `pnpm --filter @paperclipai/ui build`: passed. - `pnpm check:token-gates`: passed. - Full UI suite: 6,529 tests passed across 626 files. - [CI run 35650385023](https://github.com/paperclipai/paperclip/actions/runs/35650385023): all 53 checks passed, including full workspace build, typecheck, unit/integration suites, and browser tests. The redundant local full-workspace test run was stopped after CI passed; it is not counted as a completed local pass. - Greptile: 5/5 on `608ee58c99`, with no review threads or unresolved comments. The branch is mergeable. - `pnpm -r typecheck` and `pnpm build` were attempted. Both stop at the Runner's Rust checks because this host has no `cargo`. The full workspace checks passed in CI. ## Risks Low risk. This changes UI presentation and response handling only. The server's exact-retry idempotency, authorization, execution ownership, and recovery gates remain in place. No schema changes or live task mutations. Existing documentation describes this retry action; the fix restores that behavior. ## Model Used OpenAI GPT-6 (Codex), with reasoning, repository tools, and code execution. ## 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 checks; full-workspace limits described above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (existing behavior restored; no documentation change needed) - [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: Paperclip --- ui/src/api/agents.test.ts | 36 ++++++++++ ui/src/api/agents.ts | 11 ++- ui/src/components/TaskChatThread.test.tsx | 70 +++++++++++++++++- ui/src/components/TaskChatThread.tsx | 86 ++++++++++++----------- ui/src/pages/IssueDetail.tsx | 2 + 5 files changed, 160 insertions(+), 45 deletions(-) create mode 100644 ui/src/api/agents.test.ts diff --git a/ui/src/api/agents.test.ts b/ui/src/api/agents.test.ts new file mode 100644 index 0000000000..a1ac8e8704 --- /dev/null +++ b/ui/src/api/agents.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { agentsApi } from "./agents"; +import { api } from "./client"; + +afterEach(() => vi.restoreAllMocks()); + +describe("agentsApi.retryFailedRun", () => { + it.each(["failed", "timed_out", "cancelled", "interrupted"])( + "reports a replayed %s retry instead of silently succeeding", + async (status) => { + const post = vi.spyOn(api, "post").mockResolvedValue({ id: "previous-retry", status }); + await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1")) + .rejects.toThrow("The previous retry has already stopped"); + expect(post).toHaveBeenCalledOnce(); + }, + ); + + it.each(["queued", "running", "succeeded"])("accepts a %s successor without dispatching again", async (status) => { + const post = vi.spyOn(api, "post").mockResolvedValue({ id: "successor", status }); + await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1")) + .resolves.toEqual({ runId: "successor", issueId: null }); + expect(post).toHaveBeenCalledOnce(); + }); + + it("preserves a durable chat retry that is waiting for dispatch", async () => { + vi.spyOn(api, "post").mockResolvedValue({ actionId: "retry-action", status: "queued", runId: null, issueId: "issue-1" }); + await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1")) + .resolves.toEqual({ runId: null, issueId: "issue-1" }); + }); + + it("reports a skipped wake", async () => { + vi.spyOn(api, "post").mockResolvedValue({ skipped: true, message: "Task execution is paused." }); + await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1")) + .rejects.toThrow("Task execution is paused."); + }); +}); diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index c25bbabd91..5bde9d5e01 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -259,7 +259,16 @@ export const agentsApi = { reason: "retry_failed_run", failedRunId, }); - if ("id" in result) return { runId: result.id, issueId: null }; + if ("id" in result) { + // Exact retries are idempotent: a repeated click can return a successor + // that has already stopped. It does not mean a new attempt was queued. + if (["failed", "timed_out", "cancelled", "interrupted"].includes(result.status)) { + throw new Error( + "The previous retry has already stopped. Refresh the task and retry its latest failed run.", + ); + } + return { runId: result.id, issueId: null }; + } if ("actionId" in result) { if (result.status === "failed" || result.status === "cancelled") { throw new Error( diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 6ea39c5c90..d8c060cfa7 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -2175,10 +2175,74 @@ describe("TaskChatThread runtime transcript selection", () => { }, ); - it.each(["active execution", "pending decision", "recovery hold"] as const)( - "does not promise legacy Retry during %s and restores it when the gate clears", - (gate) => { + it.each(["failed", "timed_out"] as const)( + "retries the latest legacy %s attempt even when it has a transcript and final comment", + async (status) => { const onRetryFailedRun = vi.fn(); + transcriptState.transcriptByRun.set("latest-attempt", [{ + kind: "assistant", + ts: "2026-08-25T18:01:01.000Z", + text: "Starting the requested revision.", + }]); + const run = { + runtimeMode: "legacy" as const, + adapterType: "claude_local", + agentId: "agent-1", + agentName: "Direct agent", + startedAt: null, + }; + render( + {}} + issueStatus="todo" + onRetryFailedRun={onRetryFailedRun} + linkedRuns={[ + { ...run, runId: "original-failure", status: "failed", + createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z" }, + { ...run, runId: "latest-attempt", status, errorCode: "acpx_handshake_timeout", + createdAt: "2026-08-25T18:01:00.000Z", startedAt: "2026-08-25T18:01:00.000Z", + finishedAt: "2026-08-25T18:01:02.000Z", + resultJson: { presentationDecision: { commentId: "failure-comment" } } }, + { ...run, runId: "cancelled-automatic-retry", status: "cancelled", + errorCode: "execution_reconciliation_required", + createdAt: "2026-08-25T18:02:00.000Z", finishedAt: "2026-08-25T18:02:02.000Z" }, + ]} + />, + ); + + expect(container.textContent).toContain("The startup handshake timed out."); + const buttons = container.querySelectorAll('[data-testid="task-chat-run-failed-try-again"]'); + expect(buttons).toHaveLength(1); + flushSync(() => buttons[0]!.click()); + await Promise.resolve(); + expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("latest-attempt"); + }, + ); + + it.each(["active execution", "pending decision", "recovery hold"].flatMap( + (gate) => [false, true].map((hasTranscript) => ({ gate, hasTranscript })), + ))( + "does not promise legacy Retry during $gate (transcript: $hasTranscript) and restores it when the gate clears", + ({ gate, hasTranscript }) => { + const onRetryFailedRun = vi.fn(); + if (hasTranscript) transcriptState.transcriptByRun.set("legacy-failed", [{ + kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Starting the task.", + }]); const failedRun = { runId: "legacy-failed", runtimeMode: "legacy" as const, diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 6687365efa..7047ab1418 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1625,6 +1625,50 @@ export function TaskChatThread(props: TaskChatThreadProps) { }, }); } + // A failed run still needs its own retry target when it produced output + // or a final comment. Otherwise the thread keeps retrying an older run. + const sourceHasLegacyStop = !sourceIsPaperclipRunner && ( + source.status === "failed" || source.status === "timed_out" || + (source.status === "cancelled" && entries.length === 0) + ); + if (sourceHasLegacyStop) { + settledRunIds.add(source.id); + const code = meta?.errorCode ?? "native_runner_process_exited"; + const retryDetail = meta?.scheduledRetryAt + ? "Retry scheduled automatically." + : canRetryFailedRun + ? "You can retry this message now." + : "Your message is preserved."; + const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id); + const detail = aiRequest + ? aiRequest.status === "pending" + ? "The selected AI account is unavailable. Fix it in the connection card." + : "This run stopped because its AI account was unavailable." + : source.status === "cancelled" + ? code === "execution_reconciliation_required" + ? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details." + : "Execution was stopped before returning an answer." + : code === "provider_frame_too_large" + ? `Provider output exceeded the safe limit. ${retryDetail}` + : code.startsWith("workspace_git_scan_") + ? `Workspace setup failed before the agent started. ${retryDetail}` + : `The runner stopped before returning an answer (${code}). ${retryDetail}`; + const id = `${source.id}:failure`; + entriesWithFailures.push({ + ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt), + order: 3, + id, + item: { + id, + kind: "marker", + variant: "interrupted", + label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed", + runId: source.status === "cancelled" ? undefined : source.id, + tone: source.status === "cancelled" ? "neutral" : "error", + detail, + }, + }); + } if (entries.length === 0) { if (sourceIsPaperclipRunner && sourceYielded) { settledRunIds.add(source.id); @@ -1675,47 +1719,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { }); settledRunIds.add(source.id); settledReplyRunIds.add(source.id); - } else if ( - !sourceIsPaperclipRunner && - (source.status === "failed" || source.status === "timed_out" || source.status === "cancelled") - ) { - settledRunIds.add(source.id); - const code = meta?.errorCode ?? "native_runner_process_exited"; - const retryDetail = meta?.scheduledRetryAt - ? "Retry scheduled automatically." - : canRetryFailedRun - ? "You can retry this message now." - : "Your message is preserved."; - const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id); - const detail = aiRequest - ? aiRequest.status === "pending" - ? "The selected AI account is unavailable. Fix it in the connection card." - : "This run stopped because its AI account was unavailable." - : source.status === "cancelled" - ? code === "execution_reconciliation_required" - ? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details." - : "Execution was stopped before returning an answer." - : code === "provider_frame_too_large" - ? `Provider output exceeded the safe limit. ${retryDetail}` - : code.startsWith("workspace_git_scan_") - ? `Workspace setup failed before the agent started. ${retryDetail}` - : `The runner stopped before returning an answer (${code}). ${retryDetail}`; - const id = `${source.id}:failure`; - entriesWithFailures.push({ - ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt), - order: 3, - id, - item: { - id, - kind: "marker", - variant: "interrupted", - label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed", - runId: source.status === "cancelled" ? undefined : source.id, - tone: source.status === "cancelled" ? "neutral" : "error", - detail, - }, - }); - } else if (!sourceHasNativeStop && !lastCommentIdByRun.has(source.id)) { + } else if (!sourceHasNativeStop && !sourceHasLegacyStop && !lastCommentIdByRun.has(source.id)) { settledRunIds.add(source.id); const id = `${source.id}:terminal-notice`; entriesWithFailures.push({ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 5576069196..eb55512cff 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1562,6 +1562,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ tone: "success", }); } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId), });