diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 22412b2f1b8..f4fac0ee087 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1502,6 +1502,49 @@ describe("IssueDetail", () => { await flushReact(); expect(mockIssuesApi.update).toHaveBeenLastCalledWith("PAP-1", { status: "cancelled" }); + expect(mockHeartbeatsApi.cancel).toHaveBeenCalledTimes(2); + expect(mockHeartbeatsApi.cancel.mock.invocationCallOrder[1]) + .toBeLessThan(mockIssuesApi.update.mock.invocationCallOrder[1]); + }); + + it("reports partial success when run finalization stops the run but task status update fails", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue({ + status: "in_progress", + assigneeAgentId: "agent-1", + executionRunId: "run-active-1", + })); + mockIssuesApi.update.mockRejectedValue(new Error("Status write failed")); + mockHeartbeatsApi.cancel.mockResolvedValue(undefined); + mockAgentsApi.list.mockResolvedValue([createAgent()]); + mockAuthApi.getSession.mockResolvedValue({ + session: { userId: "user-1" }, + user: { id: "user-1" }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const stopAndDoneButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Stop and done"); + expect(stopAndDoneButton).toBeTruthy(); + + await act(async () => { + stopAndDoneButton!.click(); + }); + await flushReact(); + + expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1"); + expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ + title: "Run stopped; task update failed", + body: "Run was stopped, but updating the task failed: Status write failed", + tone: "error", + })); }); it("passes planning work mode to the issue chat thread", async () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 69225d15b95..a286121c18c 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -166,6 +166,24 @@ import { type IssueTreeControlMode, } from "@paperclipai/shared"; +type StopAndFinalizeRunError = Error & { + runCancelledBeforeStatusUpdateFailed?: boolean; +}; + +function createRunCancelledStatusUpdateError(err: unknown): StopAndFinalizeRunError { + const message = err instanceof Error + ? `Run was stopped, but updating the task failed: ${err.message}` + : "Run was stopped, but updating the task failed. Retry the task status update."; + const error = new Error(message) as StopAndFinalizeRunError; + error.runCancelledBeforeStatusUpdateFailed = true; + return error; +} + +function didRunCancelBeforeStatusUpdateFail(err: unknown): err is StopAndFinalizeRunError { + return err instanceof Error && + (err as StopAndFinalizeRunError).runCancelledBeforeStatusUpdateFailed === true; +} + type CommentReassignment = IssueCommentReassignment; type ActionableIssueThreadInteraction = | SuggestTasksInteraction @@ -1978,7 +1996,11 @@ export function IssueDetail() { const stopAndFinalizeRun = useMutation({ mutationFn: async ({ runId, status }: { runId: string; status: "cancelled" | "done" }) => { await heartbeatsApi.cancel(runId); - return issuesApi.update(issueId!, { status }); + try { + return await issuesApi.update(issueId!, { status }); + } catch (err) { + throw createRunCancelledStatusUpdateError(err); + } }, onSuccess: ({ comment: _comment, ...nextIssue }, { status }) => { const issueRefs = new Set([issueId!, nextIssue.id]); @@ -1993,15 +2015,18 @@ export function IssueDetail() { }); }, onError: (err, { status }) => { + const runWasStopped = didRunCancelBeforeStatusUpdateFail(err); pushToast({ - title: status === "done" ? "Stop and done failed" : "Stop and cancel failed", + title: runWasStopped + ? "Run stopped; task update failed" + : status === "done" ? "Stop and done failed" : "Stop and cancel failed", body: err instanceof Error ? err.message : "Unable to stop the run and update the task", tone: "error", }); }, - onSettled: () => { + onSettled: (_data, err) => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); - invalidateIssueRunState(); + if (err) invalidateIssueRunState(); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); } @@ -3136,7 +3161,7 @@ export function IssueDetail() { stopAndFinalizeRun.variables?.status === "cancelled", disabled: stopAndFinalizeRun.isPending, onSelect: (runId) => - stopAndFinalizeRun.mutateAsync({ runId, status: "cancelled" }).then(() => undefined), + stopAndFinalizeRun.mutateAsync({ runId, status: "cancelled" }).then(() => undefined, () => undefined), }, { id: "done", @@ -3147,7 +3172,7 @@ export function IssueDetail() { stopAndFinalizeRun.variables?.status === "done", disabled: stopAndFinalizeRun.isPending, onSelect: (runId) => - stopAndFinalizeRun.mutateAsync({ runId, status: "done" }).then(() => undefined), + stopAndFinalizeRun.mutateAsync({ runId, status: "done" }).then(() => undefined, () => undefined), }, ], [ stopAndFinalizeRun.isPending,