From 133457c2f4c3eed9364d4a2d6dcd74932dca111c Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 19 May 2026 16:21:16 -0500 Subject: [PATCH] Surface scheduled retry cancel failures Co-Authored-By: Paperclip --- .../issue-comment-reopen-routes.test.ts | 68 +++++++++++++++++++ server/src/routes/issues.ts | 39 ++++++----- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index b0f222b1b11..ecb3db9bf8f 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -635,6 +635,40 @@ describe.sequential("issue comment reopen routes", () => { )); }); + it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "retry-run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "22222222-2222-4222-8222-222222222222", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + error: null, + errorCode: null, + }); + mockHeartbeatService.cancelRun.mockRejectedValue(new Error("cancel failed")); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "I added the missing detail; please continue." }); + + expect(res.status).toBe(500); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "issue.updated" }), + ); + }); + it("keeps ordinary in-progress POST human comments in progress when no scheduled retry exists", async () => { const issue = makeIssue("in_progress"); mockIssueService.getById.mockResolvedValue(issue); @@ -874,6 +908,40 @@ describe.sequential("issue comment reopen routes", () => { )); }); + it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "retry-run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "22222222-2222-4222-8222-222222222222", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + error: null, + errorCode: null, + }); + mockHeartbeatService.cancelRun.mockRejectedValue(new Error("cancel failed")); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "Retry window is over; please continue." }); + + expect(res.status).toBe(500); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "issue.updated" }), + ); + }); + it("rejects non-assignee agent PATCH comments on closed issues", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.addComment.mockResolvedValue({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index bc09335d96a..9b2b9da7963 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -912,11 +912,11 @@ export function issueRoutes( }); return cancelledRunId; } catch (err) { - logger.warn( + logger.error( { err, issueId: input.issue.id, runId: scheduledRetryRunId }, "failed to cancel scheduled retry superseded by issue comment", ); - return null; + throw err; } } @@ -3534,6 +3534,18 @@ export function issueRoutes( ) { updateFields.status = "todo"; } + let cancelledScheduledRetryRunId: string | null = null; + if ( + commentBody && + shouldResumeInProgressScheduledRetry && + updateFields.status === "todo" + ) { + cancelledScheduledRetryRunId = await cancelScheduledRetrySupersededByComment({ + scheduledRetryRunId: scheduledRetryForHumanComment?.runId, + issue: existing, + actor, + }); + } if (req.body.executionPolicy !== undefined) { updateFields.executionPolicy = applyActorMonitorScheduledBy( normalizeIssueExecutionPolicy(req.body.executionPolicy), @@ -3792,13 +3804,6 @@ export function issueRoutes( previous.status !== undefined && existing.status === "in_progress" && issue.status === "todo"; - const cancelledScheduledRetryRunId = scheduledRetrySupersededByComment - ? await cancelScheduledRetrySupersededByComment({ - scheduledRetryRunId: scheduledRetryForHumanComment?.runId, - issue, - actor, - }) - : null; const statusChangedFromBlockedToTodo = existing.status === "blocked" && issue.status === "todo" && @@ -5125,22 +5130,22 @@ export function issueRoutes( effectiveMoveToTodoRequested && (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers) || shouldResumeInProgressScheduledRetry) ) { + scheduledRetrySupersededByComment = shouldResumeInProgressScheduledRetry && issue.status === "in_progress"; + cancelledScheduledRetryRunId = scheduledRetrySupersededByComment + ? await cancelScheduledRetrySupersededByComment({ + scheduledRetryRunId: scheduledRetryForHumanComment?.runId, + issue, + actor, + }) + : null; const reopenedIssue = await svc.update(id, { status: "todo" }); if (!reopenedIssue) { res.status(404).json({ error: "Issue not found" }); return; } reopened = isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers); - scheduledRetrySupersededByComment = shouldResumeInProgressScheduledRetry && issue.status === "in_progress"; reopenFromStatus = reopened ? issue.status : null; currentIssue = reopenedIssue; - cancelledScheduledRetryRunId = scheduledRetrySupersededByComment - ? await cancelScheduledRetrySupersededByComment({ - scheduledRetryRunId: scheduledRetryForHumanComment?.runId, - issue: currentIssue, - actor, - }) - : null; await logActivity(db, { companyId: currentIssue.companyId,