Cancel active runs when issues are cancelled

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-24 15:21:24 -05:00
1 parent 25c9e92ca4
commit 3df7db0b44
2 files changed
+109

No files matched your search

@@ -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<string, unknown>) => ({
...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<string, unknown>) => ({
...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: [
+42
View File
@@ -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