Skip to content

Commit df66219

Browse files
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 <noreply@paperclip.ing>
1 parent 8813a50 commit df66219

5 files changed

Lines changed: 160 additions & 45 deletions

File tree

ui/src/api/agents.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { agentsApi } from "./agents";
3+
import { api } from "./client";
4+
5+
afterEach(() => vi.restoreAllMocks());
6+
7+
describe("agentsApi.retryFailedRun", () => {
8+
it.each(["failed", "timed_out", "cancelled", "interrupted"])(
9+
"reports a replayed %s retry instead of silently succeeding",
10+
async (status) => {
11+
const post = vi.spyOn(api, "post").mockResolvedValue({ id: "previous-retry", status });
12+
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
13+
.rejects.toThrow("The previous retry has already stopped");
14+
expect(post).toHaveBeenCalledOnce();
15+
},
16+
);
17+
18+
it.each(["queued", "running", "succeeded"])("accepts a %s successor without dispatching again", async (status) => {
19+
const post = vi.spyOn(api, "post").mockResolvedValue({ id: "successor", status });
20+
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
21+
.resolves.toEqual({ runId: "successor", issueId: null });
22+
expect(post).toHaveBeenCalledOnce();
23+
});
24+
25+
it("preserves a durable chat retry that is waiting for dispatch", async () => {
26+
vi.spyOn(api, "post").mockResolvedValue({ actionId: "retry-action", status: "queued", runId: null, issueId: "issue-1" });
27+
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
28+
.resolves.toEqual({ runId: null, issueId: "issue-1" });
29+
});
30+
31+
it("reports a skipped wake", async () => {
32+
vi.spyOn(api, "post").mockResolvedValue({ skipped: true, message: "Task execution is paused." });
33+
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
34+
.rejects.toThrow("Task execution is paused.");
35+
});
36+
});

ui/src/api/agents.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,16 @@ export const agentsApi = {
259259
reason: "retry_failed_run",
260260
failedRunId,
261261
});
262-
if ("id" in result) return { runId: result.id, issueId: null };
262+
if ("id" in result) {
263+
// Exact retries are idempotent: a repeated click can return a successor
264+
// that has already stopped. It does not mean a new attempt was queued.
265+
if (["failed", "timed_out", "cancelled", "interrupted"].includes(result.status)) {
266+
throw new Error(
267+
"The previous retry has already stopped. Refresh the task and retry its latest failed run.",
268+
);
269+
}
270+
return { runId: result.id, issueId: null };
271+
}
263272
if ("actionId" in result) {
264273
if (result.status === "failed" || result.status === "cancelled") {
265274
throw new Error(

ui/src/components/TaskChatThread.test.tsx

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2175,10 +2175,74 @@ describe("TaskChatThread runtime transcript selection", () => {
21752175
},
21762176
);
21772177

2178-
it.each(["active execution", "pending decision", "recovery hold"] as const)(
2179-
"does not promise legacy Retry during %s and restores it when the gate clears",
2180-
(gate) => {
2178+
it.each(["failed", "timed_out"] as const)(
2179+
"retries the latest legacy %s attempt even when it has a transcript and final comment",
2180+
async (status) => {
2181+
const onRetryFailedRun = vi.fn();
2182+
transcriptState.transcriptByRun.set("latest-attempt", [{
2183+
kind: "assistant",
2184+
ts: "2026-08-25T18:01:01.000Z",
2185+
text: "Starting the requested revision.",
2186+
}]);
2187+
const run = {
2188+
runtimeMode: "legacy" as const,
2189+
adapterType: "claude_local",
2190+
agentId: "agent-1",
2191+
agentName: "Direct agent",
2192+
startedAt: null,
2193+
};
2194+
render(
2195+
<TaskChatThread
2196+
comments={[{
2197+
id: "failure-comment",
2198+
companyId: "company-1",
2199+
issueId: "issue-1",
2200+
authorAgentId: "agent-1",
2201+
authorUserId: null,
2202+
authorType: "agent",
2203+
presentation: null,
2204+
metadata: null,
2205+
body: "The startup handshake timed out.",
2206+
runId: "latest-attempt",
2207+
createdAt: new Date("2026-08-25T18:01:02.000Z"),
2208+
updatedAt: new Date("2026-08-25T18:01:02.000Z"),
2209+
}]}
2210+
onAdd={async () => {}}
2211+
issueStatus="todo"
2212+
onRetryFailedRun={onRetryFailedRun}
2213+
linkedRuns={[
2214+
{ ...run, runId: "original-failure", status: "failed",
2215+
createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z",
2216+
finishedAt: "2026-08-25T18:00:02.000Z" },
2217+
{ ...run, runId: "latest-attempt", status, errorCode: "acpx_handshake_timeout",
2218+
createdAt: "2026-08-25T18:01:00.000Z", startedAt: "2026-08-25T18:01:00.000Z",
2219+
finishedAt: "2026-08-25T18:01:02.000Z",
2220+
resultJson: { presentationDecision: { commentId: "failure-comment" } } },
2221+
{ ...run, runId: "cancelled-automatic-retry", status: "cancelled",
2222+
errorCode: "execution_reconciliation_required",
2223+
createdAt: "2026-08-25T18:02:00.000Z", finishedAt: "2026-08-25T18:02:02.000Z" },
2224+
]}
2225+
/>,
2226+
);
2227+
2228+
expect(container.textContent).toContain("The startup handshake timed out.");
2229+
const buttons = container.querySelectorAll<HTMLButtonElement>('[data-testid="task-chat-run-failed-try-again"]');
2230+
expect(buttons).toHaveLength(1);
2231+
flushSync(() => buttons[0]!.click());
2232+
await Promise.resolve();
2233+
expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("latest-attempt");
2234+
},
2235+
);
2236+
2237+
it.each(["active execution", "pending decision", "recovery hold"].flatMap(
2238+
(gate) => [false, true].map((hasTranscript) => ({ gate, hasTranscript })),
2239+
))(
2240+
"does not promise legacy Retry during $gate (transcript: $hasTranscript) and restores it when the gate clears",
2241+
({ gate, hasTranscript }) => {
21812242
const onRetryFailedRun = vi.fn();
2243+
if (hasTranscript) transcriptState.transcriptByRun.set("legacy-failed", [{
2244+
kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Starting the task.",
2245+
}]);
21822246
const failedRun = {
21832247
runId: "legacy-failed",
21842248
runtimeMode: "legacy" as const,

ui/src/components/TaskChatThread.tsx

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1625,6 +1625,50 @@ export function TaskChatThread(props: TaskChatThreadProps) {
16251625
},
16261626
});
16271627
}
1628+
// A failed run still needs its own retry target when it produced output
1629+
// or a final comment. Otherwise the thread keeps retrying an older run.
1630+
const sourceHasLegacyStop = !sourceIsPaperclipRunner && (
1631+
source.status === "failed" || source.status === "timed_out" ||
1632+
(source.status === "cancelled" && entries.length === 0)
1633+
);
1634+
if (sourceHasLegacyStop) {
1635+
settledRunIds.add(source.id);
1636+
const code = meta?.errorCode ?? "native_runner_process_exited";
1637+
const retryDetail = meta?.scheduledRetryAt
1638+
? "Retry scheduled automatically."
1639+
: canRetryFailedRun
1640+
? "You can retry this message now."
1641+
: "Your message is preserved.";
1642+
const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id);
1643+
const detail = aiRequest
1644+
? aiRequest.status === "pending"
1645+
? "The selected AI account is unavailable. Fix it in the connection card."
1646+
: "This run stopped because its AI account was unavailable."
1647+
: source.status === "cancelled"
1648+
? code === "execution_reconciliation_required"
1649+
? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details."
1650+
: "Execution was stopped before returning an answer."
1651+
: code === "provider_frame_too_large"
1652+
? `Provider output exceeded the safe limit. ${retryDetail}`
1653+
: code.startsWith("workspace_git_scan_")
1654+
? `Workspace setup failed before the agent started. ${retryDetail}`
1655+
: `The runner stopped before returning an answer (${code}). ${retryDetail}`;
1656+
const id = `${source.id}:failure`;
1657+
entriesWithFailures.push({
1658+
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
1659+
order: 3,
1660+
id,
1661+
item: {
1662+
id,
1663+
kind: "marker",
1664+
variant: "interrupted",
1665+
label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
1666+
runId: source.status === "cancelled" ? undefined : source.id,
1667+
tone: source.status === "cancelled" ? "neutral" : "error",
1668+
detail,
1669+
},
1670+
});
1671+
}
16281672
if (entries.length === 0) {
16291673
if (sourceIsPaperclipRunner && sourceYielded) {
16301674
settledRunIds.add(source.id);
@@ -1675,47 +1719,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
16751719
});
16761720
settledRunIds.add(source.id);
16771721
settledReplyRunIds.add(source.id);
1678-
} else if (
1679-
!sourceIsPaperclipRunner &&
1680-
(source.status === "failed" || source.status === "timed_out" || source.status === "cancelled")
1681-
) {
1682-
settledRunIds.add(source.id);
1683-
const code = meta?.errorCode ?? "native_runner_process_exited";
1684-
const retryDetail = meta?.scheduledRetryAt
1685-
? "Retry scheduled automatically."
1686-
: canRetryFailedRun
1687-
? "You can retry this message now."
1688-
: "Your message is preserved.";
1689-
const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id);
1690-
const detail = aiRequest
1691-
? aiRequest.status === "pending"
1692-
? "The selected AI account is unavailable. Fix it in the connection card."
1693-
: "This run stopped because its AI account was unavailable."
1694-
: source.status === "cancelled"
1695-
? code === "execution_reconciliation_required"
1696-
? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details."
1697-
: "Execution was stopped before returning an answer."
1698-
: code === "provider_frame_too_large"
1699-
? `Provider output exceeded the safe limit. ${retryDetail}`
1700-
: code.startsWith("workspace_git_scan_")
1701-
? `Workspace setup failed before the agent started. ${retryDetail}`
1702-
: `The runner stopped before returning an answer (${code}). ${retryDetail}`;
1703-
const id = `${source.id}:failure`;
1704-
entriesWithFailures.push({
1705-
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
1706-
order: 3,
1707-
id,
1708-
item: {
1709-
id,
1710-
kind: "marker",
1711-
variant: "interrupted",
1712-
label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
1713-
runId: source.status === "cancelled" ? undefined : source.id,
1714-
tone: source.status === "cancelled" ? "neutral" : "error",
1715-
detail,
1716-
},
1717-
});
1718-
} else if (!sourceHasNativeStop && !lastCommentIdByRun.has(source.id)) {
1722+
} else if (!sourceHasNativeStop && !sourceHasLegacyStop && !lastCommentIdByRun.has(source.id)) {
17191723
settledRunIds.add(source.id);
17201724
const id = `${source.id}:terminal-notice`;
17211725
entriesWithFailures.push({

ui/src/pages/IssueDetail.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1562,6 +1562,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
15621562
tone: "success",
15631563
});
15641564
}
1565+
},
1566+
onSettled: () => {
15651567
queryClient.invalidateQueries({
15661568
queryKey: queryKeys.issues.runs(issueId),
15671569
});

0 commit comments

Comments
 (0)