From 3df7db0b446a77650cd64afa5c5b5a9a39a76318 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 15:21:24 -0500 Subject: [PATCH] Cancel active runs when issues are cancelled Co-Authored-By: Paperclip --- .../issue-comment-reopen-routes.test.ts | 67 +++++++++++++++++++ server/src/routes/issues.ts | 42 ++++++++++++ 2 files changed, 109 insertions(+) diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 7709274be5e..3cca0371fb6 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -957,6 +957,73 @@ describe.sequential("issue comment reopen routes", () => { ); }); + it("cancels an active run when an issue is marked cancelled", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + })); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", + }); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "cancelled" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "heartbeat.cancelled", + details: expect.objectContaining({ + source: "issue_status_cancelled", + issueId: "11111111-1111-4111-8111-111111111111", + }), + }), + ); + }); + + it("does not cancel active runs when an issue is marked done", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + })); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "done" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + it("writes decision ids into executionState and inserts the decision inside the transaction", async () => { const policy = await normalizePolicy({ stages: [ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 15432c1ac5f..4458f382ca8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1910,6 +1910,8 @@ export function issueRoutes( hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const shouldCancelActiveRunForCancelledStatus = + existing.status !== "cancelled" && updateFields.status === "cancelled"; if (resumeRequested === true && !commentBody) { res.status(400).json({ error: "Follow-up intent requires a comment" }); return; @@ -1982,6 +1984,10 @@ export function issueRoutes( } } + const runToCancelForCancelledStatus = shouldCancelActiveRunForCancelledStatus + ? await resolveActiveIssueRun(existing) + : null; + if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } @@ -2134,6 +2140,41 @@ export function issueRoutes( res.status(404).json({ error: "Issue not found" }); return; } + + let cancelledStatusRunId: string | null = null; + if (runToCancelForCancelledStatus) { + try { + const cancelled = await heartbeat.cancelRun(runToCancelForCancelledStatus.id); + if (cancelled) { + cancelledStatusRunId = cancelled.id; + await logActivity(db, { + companyId: cancelled.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancelled", + entityType: "heartbeat_run", + entityId: cancelled.id, + details: { agentId: cancelled.agentId, source: "issue_status_cancelled", issueId: existing.id }, + }); + } + } catch (err) { + logger.warn({ err, issueId: existing.id, runId: runToCancelForCancelledStatus.id }, "failed to cancel run for cancelled issue"); + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancel_failed", + entityType: "heartbeat_run", + entityId: runToCancelForCancelledStatus.id, + details: { source: "issue_status_cancelled", issueId: existing.id }, + }); + } + } + if (titleOrDescriptionChanged) { await issueReferencesSvc.syncIssue(issue.id); } @@ -2200,6 +2241,7 @@ export function issueRoutes( ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), + ...(cancelledStatusRunId ? { cancelledStatusRunId } : {}), _previous: hasFieldChanges ? previous : undefined, ...summarizeIssueReferenceActivityDetails( updateReferenceDiff