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>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-21 13:31:43 -07:00
1 parent 8813a50105
commit df66219780
5 files changed
+160 -45

No files matched your search

+36
View File
@@ -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.");
});
});
+10 -1
View File
@@ -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(
+67 -3
View File
@@ -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(
<TaskChatThread
comments={[{
id: "failure-comment",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: "agent-1",
authorUserId: null,
authorType: "agent",
presentation: null,
metadata: null,
body: "The startup handshake timed out.",
runId: "latest-attempt",
createdAt: new Date("2026-08-25T18:01:02.000Z"),
updatedAt: new Date("2026-08-25T18:01:02.000Z"),
}]}
onAdd={async () => {}}
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<HTMLButtonElement>('[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,
+45 -41
View File
@@ -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({
+2
View File
@@ -1562,6 +1562,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
tone: "success",
});
}
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.runs(issueId),
});