Add explicit issue follow-up intent

This commit is contained in:
Dotta committed 2026-04-24 13:39:10 -05:00
1 parent 28006f997c
commit 2abdf92478
4 files changed
+131 -5

No files matched your search

+1
View File
@@ -267,6 +267,7 @@ export interface IssueComment {
authorAgentId: string | null;
authorUserId: string | null;
body: string;
followUpRequested?: boolean;
createdAt: Date;
updatedAt: Date;
}
+2
View File
@@ -171,6 +171,7 @@ export const updateIssueSchema = createIssueSchema.partial().extend({
comment: z.string().min(1).optional(),
reviewRequest: issueReviewRequestSchema.optional().nullable(),
reopen: z.boolean().optional(),
resume: z.boolean().optional(),
interrupt: z.boolean().optional(),
hiddenAt: z.string().datetime().nullable().optional(),
});
@@ -188,6 +189,7 @@ export type CheckoutIssue = z.infer<typeof checkoutIssueSchema>;
export const addIssueCommentSchema = z.object({
body: z.string().min(1),
reopen: z.boolean().optional(),
resume: z.boolean().optional(),
interrupt: z.boolean().optional(),
});
@@ -61,6 +61,9 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
const mockIssueTreeControlService = vi.hoisted(() => ({
getActivePauseHoldGate: vi.fn(async () => null),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
@@ -128,6 +131,7 @@ vi.mock("../services/index.js", () => ({
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
issueTreeControlService: () => mockIssueTreeControlService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
@@ -214,6 +218,7 @@ describe.sequential("issue comment reopen routes", () => {
mockInstanceSettingsService.get.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset();
mockRoutineService.syncRunStatusForIssue.mockReset();
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDb.transaction.mockReset();
@@ -241,6 +246,7 @@ describe.sequential("issue comment reopen routes", () => {
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue(null);
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
+122 -5
View File
@@ -49,6 +49,7 @@ import {
ISSUE_LIST_MAX_LIMIT,
issueReferenceService,
issueService,
issueTreeControlService,
clampIssueListLimit,
documentService,
logActivity,
@@ -182,7 +183,7 @@ function isClosedIssueStatus(status: string | null | undefined): status is "done
return status === "done" || status === "cancelled";
}
function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: {
function shouldImplicitlyMoveCommentedIssueToTodo(input: {
issueStatus: string | null | undefined;
assigneeAgentId: string | null | undefined;
actorType: "agent" | "user";
@@ -196,6 +197,10 @@ function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: {
return true;
}
function isExplicitResumeCapableStatus(status: string | null | undefined) {
return status === "done" || status === "blocked" || status === "todo" || status === "in_progress";
}
function queueResolvedInteractionContinuationWakeup(input: {
heartbeat: ReturnType<typeof heartbeatService>;
issue: { id: string; assigneeAgentId: string | null; status: string };
@@ -404,6 +409,7 @@ export function issueRoutes(
const documentsSvc = documentService(db);
const issueReferencesSvc = issueReferenceService(db);
const routinesSvc = routineService(db);
const treeControlSvc = issueTreeControlService(db);
const feedbackExportService = opts?.feedbackExportService;
const upload = multer({
storage: multer.memoryStorage(),
@@ -621,6 +627,90 @@ export function issueRoutes(
return true;
}
async function assertExplicitResumeIntentAllowed(
req: Request,
res: Response,
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
) {
if (issue.status === "cancelled") {
res.status(409).json({
error: "Cancelled issues must be restored through the dedicated restore flow",
details: {
issueId: issue.id,
status: issue.status,
securityPrinciples: ["Complete Mediation", "Fail Securely"],
},
});
return false;
}
if (!isExplicitResumeCapableStatus(issue.status)) {
res.status(409).json({
error: "Issue is not resumable through comment follow-up intent",
details: { issueId: issue.id, status: issue.status },
});
return false;
}
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
if (activePauseHold) {
res.status(409).json({
error: "Issue follow-up blocked by active subtree pause hold",
details: {
issueId: issue.id,
holdId: activePauseHold.holdId,
rootIssueId: activePauseHold.rootIssueId,
mode: activePauseHold.mode,
securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"],
},
});
return false;
}
if (issue.status === "blocked") {
const readiness = await svc.getDependencyReadiness(issue.id);
if (readiness.unresolvedBlockerCount > 0) {
res.status(409).json({
error: "Issue follow-up blocked by unresolved blockers",
details: {
issueId: issue.id,
unresolvedBlockerIssueIds: readiness.unresolvedBlockerIssueIds,
},
});
return false;
}
}
if (req.actor.type !== "agent") return true;
const actorAgentId = req.actor.agentId;
if (!actorAgentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
if (!issue.assigneeAgentId) {
res.status(409).json({
error: "Issue follow-up requires an assigned agent",
details: { issueId: issue.id, actorAgentId },
});
return false;
}
if (issue.assigneeAgentId === actorAgentId) return true;
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
return true;
}
res.status(403).json({
error: "Agent cannot request follow-up for another agent's issue",
details: {
issueId: issue.id,
assigneeAgentId: issue.assigneeAgentId,
actorAgentId,
},
});
return false;
}
async function resolveActiveIssueRun(issue: {
id: string;
assigneeAgentId: string | null;
@@ -1794,17 +1884,27 @@ export function issueRoutes(
comment: commentBody,
reviewRequest,
reopen: reopenRequested,
resume: resumeRequested,
interrupt: interruptRequested,
hiddenAt: hiddenAtRaw,
...updateFields
} = req.body;
if (resumeRequested === true && !commentBody) {
res.status(400).json({ error: "Follow-up intent requires a comment" });
return;
}
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") {
if (!(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
}
await assertIssueEnvironmentSelection(existing.companyId, updateFields.executionWorkspaceSettings?.environmentId);
const requestedAssigneeAgentId =
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
const effectiveMoveToTodoRequested =
reopenRequested ||
explicitMoveToTodoRequested ||
(!!commentBody &&
shouldImplicitlyMoveCommentedIssueToTodoForAgent({
shouldImplicitlyMoveCommentedIssueToTodo({
issueStatus: existing.status,
assigneeAgentId: requestedAssigneeAgentId,
actorType: actor.actorType,
@@ -1817,6 +1917,10 @@ export function issueRoutes(
isBlocked && effectiveMoveToTodoRequested
? (await svc.getDependencyReadiness(existing.id)).unresolvedBlockerCount > 0
: false;
if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) {
res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" });
return;
}
let interruptedRunId: string | null = null;
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
const isAgentWorkUpdate =
@@ -2072,6 +2176,7 @@ export function issueRoutes(
...updateFields,
identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
_previous: hasFieldChanges ? previous : undefined,
@@ -2214,6 +2319,7 @@ export function issueRoutes(
bodySnippet: comment.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...(hasFieldChanges ? { updated: true } : {}),
@@ -3145,12 +3251,18 @@ export function issueRoutes(
const actor = getActorInfo(req);
const reopenRequested = req.body.reopen === true;
const resumeRequested = req.body.resume === true;
const interruptRequested = req.body.interrupt === true;
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") {
if (!(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
}
const isClosed = isClosedIssueStatus(issue.status);
const isBlocked = issue.status === "blocked";
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
const effectiveMoveToTodoRequested =
reopenRequested ||
shouldImplicitlyMoveCommentedIssueToTodoForAgent({
explicitMoveToTodoRequested ||
shouldImplicitlyMoveCommentedIssueToTodo({
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
@@ -3160,6 +3272,10 @@ export function issueRoutes(
isBlocked && effectiveMoveToTodoRequested
? (await svc.getDependencyReadiness(issue.id)).unresolvedBlockerCount > 0
: false;
if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) {
res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" });
return;
}
let reopened = false;
let reopenFromStatus: string | null = null;
let interruptedRunId: string | null = null;
@@ -3252,6 +3368,7 @@ export function issueRoutes(
bodySnippet: comment.body.slice(0, 120),
identifier: currentIssue.identifier,
issueTitle: currentIssue.title,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...summarizeIssueReferenceActivityDetails({