diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index deaf5271bc5..82cdd65d92b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -446,14 +446,14 @@ { "idx": 63, "version": "7", - "when": 1776726000000, + "when": 1776780001000, "tag": "0063_issue_thread_interactions", "breakpoints": true }, { "idx": 64, "version": "7", - "when": 1776727800000, + "when": 1776780002000, "tag": "0064_issue_thread_interaction_idempotency", "breakpoints": true } diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 77c45a05056..cb89012f3ef 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -515,6 +515,39 @@ describe("issue thread interaction routes", () => { expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); + it("does not emit an accept-only continuation wake for rejected suggested tasks", async () => { + mockInteractionService.rejectInteraction.mockResolvedValueOnce({ + id: "interaction-5", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "rejected", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-5", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: { + version: 1, + rejectionReason: "Not now", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-5/reject") + .send({ reason: "Not now" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + it("allows agent-authored interaction creation and stamps the active run id", async () => { const app = await createApp({ type: "agent", diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 527f81941f8..ca58841da9c 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -166,6 +166,18 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const listed = await interactionsSvc.listForIssue(issueId); expect(listed).toHaveLength(1); expect(listed[0]?.status).toBe("accepted"); + + await expect(interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + })).rejects.toThrow("Interaction has already been resolved"); + + const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId }); + expect(childrenAfterDuplicateAccept).toHaveLength(1); }); it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index d75bd343fea..31c24dcb0ee 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -208,7 +208,10 @@ function queueResolvedInteractionContinuationWakeup(input: { input.interaction.continuationPolicy !== "wake_assignee" && input.interaction.continuationPolicy !== "wake_assignee_on_accept" ) return; - if (input.interaction.kind === "request_confirmation" && input.interaction.status !== "accepted") return; + if ( + input.interaction.continuationPolicy === "wake_assignee_on_accept" + && input.interaction.status !== "accepted" + ) return; if (input.interaction.status === "expired") return; if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 4175e3f361d..fc4caeeb425 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -805,6 +805,26 @@ export function issueThreadInteractionService(db: Db) { const createdWakeTargets: IssueWakeTarget[] = []; await db.transaction(async (tx) => { + const resolvedAt = new Date(); + const [claimed] = await tx + .update(issueThreadInteractions) + .set({ + status: "accepted", + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt, + updatedAt: resolvedAt, + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!claimed) { + throw conflict("Interaction has already been resolved"); + } + for (const task of orderedTasks) { const parentIssueId = task.parentClientKey ? createdByClientKey.get(task.parentClientKey)?.issueId ?? null @@ -850,15 +870,11 @@ export function issueThreadInteractionService(db: Db) { const [updated] = await tx .update(issueThreadInteractions) .set({ - status: "accepted", result: { version: 1, createdTasks: [...createdByClientKey.values()], ...(skippedClientKeys.length > 0 ? { skippedClientKeys } : {}), }, - resolvedByAgentId: actor.agentId ?? null, - resolvedByUserId: actor.userId ?? null, - resolvedAt: new Date(), updatedAt: new Date(), }) .where(eq(issueThreadInteractions.id, interactionId)) @@ -889,7 +905,7 @@ export function issueThreadInteractionService(db: Db) { const current = await getPendingInteractionForResolution({ issue, interactionId }); switch (current.kind) { case "suggest_tasks": - return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor); + return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current); case "request_confirmation": return rejectRequestConfirmation({ issue, @@ -907,8 +923,9 @@ export function issueThreadInteractionService(db: Db) { interactionId: string, input: RejectIssueThreadInteraction, actor: InteractionActor, + currentRow?: IssueThreadInteractionRow, ) => { - const current = await db + const current = currentRow ?? await db .select() .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, interactionId)) @@ -938,9 +955,16 @@ export function issueThreadInteractionService(db: Db) { resolvedAt: new Date(), updatedAt: new Date(), }) - .where(eq(issueThreadInteractions.id, interactionId)) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) .returning(); + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + await touchIssue(db, issue.id); return hydrateInteraction(updated); },