Add issue comment interrupt support

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 10:34:36 -05:00
parent cfb7dd4818
commit 4226e15128
5 changed files with 179 additions and 35 deletions

View file

@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({
comment: z.string().min(1).optional(),
reopen: z.boolean().optional(),
interrupt: z.boolean().optional(),
hiddenAt: z.string().datetime().nullable().optional(),
});

View file

@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockAgentService = vi.hoisted(() => ({
@ -143,4 +146,46 @@ describe("issue comment reopen routes", () => {
}),
);
});
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),
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(createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
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_comment_interrupt",
issueId: "11111111-1111-4111-8111-111111111111",
}),
}),
);
});
});

View file

@ -1,5 +1,6 @@
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import {
addIssueCommentSchema,
@ -38,6 +39,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
export function issueRoutes(db: Db, storage: StorageService) {
const router = Router();
@ -161,6 +165,30 @@ export function issueRoutes(db: Db, storage: StorageService) {
return true;
}
async function resolveActiveIssueRun(issue: {
id: string;
assigneeAgentId: string | null;
executionRunId?: string | null;
}) {
let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) {
const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
const activeIssueId =
activeRun &&
activeRun.contextSnapshot &&
typeof activeRun.contextSnapshot === "object" &&
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
: null;
if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) {
runToInterrupt = activeRun;
}
}
return runToInterrupt?.status === "running" ? runToInterrupt : null;
}
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await svc.getByIdentifier(rawId);
@ -919,7 +947,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(201).json(issue);
});
router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => {
router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
@ -949,7 +977,45 @@ export function issueRoutes(db: Db, storage: StorageService) {
const actor = getActorInfo(req);
const isClosed = existing.status === "done" || existing.status === "cancelled";
const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
const {
comment: commentBody,
reopen: reopenRequested,
interrupt: interruptRequested,
hiddenAt: hiddenAtRaw,
...updateFields
} = req.body;
let interruptedRunId: string | null = null;
if (interruptRequested) {
if (!commentBody) {
res.status(400).json({ error: "Interrupt is only supported when posting a comment" });
return;
}
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
return;
}
const runToInterrupt = await resolveActiveIssueRun(existing);
if (runToInterrupt) {
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
if (cancelled) {
interruptedRunId = 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_comment_interrupt", issueId: existing.id },
});
}
}
}
if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
}
@ -1024,6 +1090,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
_previous: hasFieldChanges ? previous : undefined,
},
});
@ -1050,6 +1117,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
identifier: issue.identifier,
issueTitle: issue.title,
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...(hasFieldChanges ? { updated: true } : {}),
},
});
@ -1071,10 +1139,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId: issue.id, mutation: "update" },
payload: {
issueId: issue.id,
mutation: "update",
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.update" },
contextSnapshot: {
issueId: issue.id,
source: "issue.update",
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
}
@ -1083,10 +1159,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: { issueId: issue.id, mutation: "update" },
payload: {
issueId: issue.id,
mutation: "update",
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.status_change" },
contextSnapshot: {
issueId: issue.id,
source: "issue.status_change",
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
}
@ -1379,28 +1463,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
let runToInterrupt = currentIssue.executionRunId
? await heartbeat.getRun(currentIssue.executionRunId)
: null;
if (
(!runToInterrupt || runToInterrupt.status !== "running") &&
currentIssue.assigneeAgentId
) {
const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId);
const activeIssueId =
activeRun &&
activeRun.contextSnapshot &&
typeof activeRun.contextSnapshot === "object" &&
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
: null;
if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) {
runToInterrupt = activeRun;
}
}
if (runToInterrupt && runToInterrupt.status === "running") {
const runToInterrupt = await resolveActiveIssueRun(currentIssue);
if (runToInterrupt) {
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
if (cancelled) {
interruptedRunId = cancelled.id;

View file

@ -37,7 +37,12 @@ interface CommentThreadProps {
linkedRuns?: LinkedRunItem[];
companyId?: string | null;
projectId?: string | null;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
onAdd: (
body: string,
reopen?: boolean,
reassignment?: CommentReassignment,
interrupt?: boolean,
) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
imageUploadHandler?: (file: File) => Promise<string>;
@ -50,6 +55,7 @@ interface CommentThreadProps {
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
interruptAvailable?: boolean;
}
const DRAFT_DEBOUNCE_MS = 800;
@ -279,9 +285,11 @@ export function CommentThread({
currentAssigneeValue = "",
suggestedAssigneeValue,
mentions: providedMentions,
interruptAvailable = false,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [interrupt, setInterrupt] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
@ -351,6 +359,14 @@ export function CommentThread({
setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]);
const interruptVisible = interruptAvailable && body.trim().length > 0;
useEffect(() => {
if (!interruptVisible && interrupt) {
setInterrupt(false);
}
}, [interruptVisible, interrupt]);
// Scroll to comment when URL hash matches #comment-{id}
useEffect(() => {
const hash = location.hash;
@ -377,10 +393,11 @@ export function CommentThread({
setSubmitting(true);
try {
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined, interrupt ? true : undefined);
setBody("");
if (draftKey) clearDraft(draftKey);
setReopen(true);
setInterrupt(false);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
// Parent mutation handlers surface the failure and keep the draft intact.
@ -465,6 +482,17 @@ export function CommentThread({
/>
Re-open
</label>
{interruptVisible && (
<label className="flex items-center gap-1.5 text-xs text-red-700 dark:text-red-300 cursor-pointer select-none">
<input
type="checkbox"
checked={interrupt}
onChange={(e) => setInterrupt(e.target.checked)}
className="rounded border-red-300 accent-red-600"
/>
Interrupt
</label>
)}
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}

View file

@ -275,6 +275,8 @@ export function IssueDetail() {
});
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const hasRunningIssueRun =
activeRun?.status === "running" || (liveRuns ?? []).some((run) => run.status === "running");
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
[location.state, location.search],
@ -505,8 +507,8 @@ export function IssueDetail() {
});
const addComment = useMutation({
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen),
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen, interrupt),
onMutate: async ({ body, reopen }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
@ -572,10 +574,12 @@ export function IssueDetail() {
mutationFn: ({
body,
reopen,
interrupt,
reassignment,
}: {
body: string;
reopen?: boolean;
interrupt?: boolean;
reassignment: CommentReassignment;
}) =>
issuesApi.update(issueId!, {
@ -583,6 +587,7 @@ export function IssueDetail() {
assigneeAgentId: reassignment.assigneeAgentId,
assigneeUserId: reassignment.assigneeUserId,
...(reopen ? { status: "todo" } : {}),
...(interrupt ? { interrupt } : {}),
}),
onMutate: async ({ body, reopen, reassignment }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
@ -1171,12 +1176,13 @@ export function IssueDetail() {
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
onAdd={async (body, reopen, reassignment) => {
interruptAvailable={hasRunningIssueRun}
onAdd={async (body, reopen, reassignment, interrupt) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment, interrupt });
return;
}
await addComment.mutateAsync({ body, reopen });
await addComment.mutateAsync({ body, reopen, interrupt });
}}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);