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({ export const updateIssueSchema = createIssueSchema.partial().extend({
comment: z.string().min(1).optional(), comment: z.string().min(1).optional(),
reopen: z.boolean().optional(), reopen: z.boolean().optional(),
interrupt: z.boolean().optional(),
hiddenAt: z.string().datetime().nullable().optional(), hiddenAt: z.string().datetime().nullable().optional(),
}); });

View file

@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({
const mockHeartbeatService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined), wakeup: vi.fn(async () => undefined),
reportRunActivity: 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(() => ({ 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 { Router, type Request, type Response } from "express";
import multer from "multer"; import multer from "multer";
import { z } from "zod";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
addIssueCommentSchema, addIssueCommentSchema,
@ -38,6 +39,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
const MAX_ISSUE_COMMENT_LIMIT = 500; const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
export function issueRoutes(db: Db, storage: StorageService) { export function issueRoutes(db: Db, storage: StorageService) {
const router = Router(); const router = Router();
@ -161,6 +165,30 @@ export function issueRoutes(db: Db, storage: StorageService) {
return true; 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> { async function normalizeIssueIdentifier(rawId: string): Promise<string> {
if (/^[A-Z]+-\d+$/i.test(rawId)) { if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await svc.getByIdentifier(rawId); const issue = await svc.getByIdentifier(rawId);
@ -919,7 +947,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(201).json(issue); 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 id = req.params.id as string;
const existing = await svc.getById(id); const existing = await svc.getById(id);
if (!existing) { if (!existing) {
@ -949,7 +977,45 @@ export function issueRoutes(db: Db, storage: StorageService) {
const actor = getActorInfo(req); const actor = getActorInfo(req);
const isClosed = existing.status === "done" || existing.status === "cancelled"; 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) { if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
} }
@ -1024,6 +1090,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
identifier: issue.identifier, identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}), ...(commentBody ? { source: "comment" } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
_previous: hasFieldChanges ? previous : undefined, _previous: hasFieldChanges ? previous : undefined,
}, },
}); });
@ -1050,6 +1117,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
identifier: issue.identifier, identifier: issue.identifier,
issueTitle: issue.title, issueTitle: issue.title,
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...(hasFieldChanges ? { updated: true } : {}), ...(hasFieldChanges ? { updated: true } : {}),
}, },
}); });
@ -1071,10 +1139,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
source: "assignment", source: "assignment",
triggerDetail: "system", triggerDetail: "system",
reason: "issue_assigned", reason: "issue_assigned",
payload: { issueId: issue.id, mutation: "update" }, payload: {
issueId: issue.id,
mutation: "update",
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType, requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId, 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", source: "automation",
triggerDetail: "system", triggerDetail: "system",
reason: "issue_status_changed", reason: "issue_status_changed",
payload: { issueId: issue.id, mutation: "update" }, payload: {
issueId: issue.id,
mutation: "update",
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType, requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId, 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; return;
} }
let runToInterrupt = currentIssue.executionRunId const runToInterrupt = await resolveActiveIssueRun(currentIssue);
? await heartbeat.getRun(currentIssue.executionRunId) if (runToInterrupt) {
: 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 cancelled = await heartbeat.cancelRun(runToInterrupt.id); const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
if (cancelled) { if (cancelled) {
interruptedRunId = cancelled.id; interruptedRunId = cancelled.id;

View file

@ -37,7 +37,12 @@ interface CommentThreadProps {
linkedRuns?: LinkedRunItem[]; linkedRuns?: LinkedRunItem[];
companyId?: string | null; companyId?: string | null;
projectId?: 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; issueStatus?: string;
agentMap?: Map<string, Agent>; agentMap?: Map<string, Agent>;
imageUploadHandler?: (file: File) => Promise<string>; imageUploadHandler?: (file: File) => Promise<string>;
@ -50,6 +55,7 @@ interface CommentThreadProps {
currentAssigneeValue?: string; currentAssigneeValue?: string;
suggestedAssigneeValue?: string; suggestedAssigneeValue?: string;
mentions?: MentionOption[]; mentions?: MentionOption[];
interruptAvailable?: boolean;
} }
const DRAFT_DEBOUNCE_MS = 800; const DRAFT_DEBOUNCE_MS = 800;
@ -279,9 +285,11 @@ export function CommentThread({
currentAssigneeValue = "", currentAssigneeValue = "",
suggestedAssigneeValue, suggestedAssigneeValue,
mentions: providedMentions, mentions: providedMentions,
interruptAvailable = false,
}: CommentThreadProps) { }: CommentThreadProps) {
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true); const [reopen, setReopen] = useState(true);
const [interrupt, setInterrupt] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false); const [attaching, setAttaching] = useState(false);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
@ -351,6 +359,14 @@ export function CommentThread({
setReassignTarget(effectiveSuggestedAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
}, [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} // Scroll to comment when URL hash matches #comment-{id}
useEffect(() => { useEffect(() => {
const hash = location.hash; const hash = location.hash;
@ -377,10 +393,11 @@ export function CommentThread({
setSubmitting(true); setSubmitting(true);
try { try {
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined, interrupt ? true : undefined);
setBody(""); setBody("");
if (draftKey) clearDraft(draftKey); if (draftKey) clearDraft(draftKey);
setReopen(true); setReopen(true);
setInterrupt(false);
setReassignTarget(effectiveSuggestedAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
} catch { } catch {
// Parent mutation handlers surface the failure and keep the draft intact. // Parent mutation handlers surface the failure and keep the draft intact.
@ -465,6 +482,17 @@ export function CommentThread({
/> />
Re-open Re-open
</label> </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 && ( {enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector <InlineEntitySelector
value={reassignTarget} value={reassignTarget}

View file

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