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), });