From 402f0bf0a201bc69dd6516d3e23502bb1c630ee4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 16 Jul 2026 13:55:25 +0000 Subject: [PATCH] fix human reopen with pending finalize blockers Co-Authored-By: Paperclip --- .../issue-comment-reopen-routes.test.ts | 90 +++++++++++++++++++ server/src/routes/issues.ts | 11 ++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 85bd66d9074..48aa3ac4949 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -325,6 +325,7 @@ describe.sequential("issue comment reopen routes", () => { blockerIssueIds: [], unresolvedBlockerIssueIds: [], unresolvedBlockerCount: 0, + pendingFinalizeBlockerIssueIds: [], allBlockersDone: true, isDependencyReady: true, }); @@ -853,6 +854,60 @@ describe.sequential("issue comment reopen routes", () => { )); }); + it("moves blocked issues with only pending-finalize done blockers back to todo via POST human comments", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.getDependencyReadiness.mockResolvedValue({ + issueId: "11111111-1111-4111-8111-111111111111", + blockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + unresolvedBlockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + unresolvedBlockerCount: 1, + pendingFinalizeBlockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + allBlockersDone: false, + isDependencyReady: false, + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("blocked"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "please continue while finalization recovers" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { status: "todo" }, + ); + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ reason: "issue_reopened_via_comment" }), + )); + }); + + it("does not move assigned blocked issues to todo via POST agent comments", async () => { + const assigneeAgentId = "22222222-2222-4222-8222-222222222222"; + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + body: "agent progress note", + createdAt: new Date(), + updatedAt: new Date(), + authorAgentId: assigneeAgentId, + authorUserId: null, + }); + + const res = await request(await installActor(createApp(), agentActor(assigneeAgentId))) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "agent progress note" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.getDependencyReadiness).not.toHaveBeenCalled(); + }); + it("moves in-progress issues with a scheduled retry back to todo via POST human comments", async () => { const issue = { ...makeIssue("in_progress"), @@ -1329,6 +1384,41 @@ describe.sequential("issue comment reopen routes", () => { )); }); + it("moves blocked issues with only pending-finalize done blockers back to todo via PATCH human comments", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.getDependencyReadiness.mockResolvedValue({ + issueId: "11111111-1111-4111-8111-111111111111", + blockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + unresolvedBlockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + unresolvedBlockerCount: 1, + pendingFinalizeBlockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + allBlockersDone: false, + isDependencyReady: false, + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("blocked"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "please continue while finalization recovers" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + status: "todo", + actorAgentId: null, + actorUserId: "local-board", + }), + ); + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ reason: "issue_reopened_via_comment" }), + )); + }); + it("moves in-progress issues with a scheduled retry back to todo via the PATCH comment path", async () => { const issue = { ...makeIssue("in_progress"), diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f414934ec71..9b904463932 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1771,6 +1771,13 @@ function shouldHumanCommentResumeInProgressScheduledRetry(input: { return typeof input.assigneeAgentId === "string" && input.assigneeAgentId.length > 0; } +function hasUnresolvedBlockerEdges(readiness: { + unresolvedBlockerCount: number; + pendingFinalizeBlockerIssueIds?: string[]; +}) { + return readiness.unresolvedBlockerCount > (readiness.pendingFinalizeBlockerIssueIds?.length ?? 0); +} + function isExplicitResumeCapableStatus(status: string | null | undefined) { return status === "done" || status === "blocked" || status === "todo" || status === "in_progress"; } @@ -7859,7 +7866,7 @@ export function issueRoutes( : null; const hasUnresolvedFirstClassBlockers = isBlocked && effectiveMoveToTodoRequested - ? (await svc.getDependencyReadiness(existing.id)).unresolvedBlockerCount > 0 + ? hasUnresolvedBlockerEdges(await svc.getDependencyReadiness(existing.id)) : false; if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) { res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" }); @@ -10097,7 +10104,7 @@ export function issueRoutes( shouldResumeInProgressScheduledRetry); const hasUnresolvedFirstClassBlockers = isBlocked && effectiveMoveToTodoRequested - ? (await svc.getDependencyReadiness(issue.id)).unresolvedBlockerCount > 0 + ? hasUnresolvedBlockerEdges(await svc.getDependencyReadiness(issue.id)) : false; if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) { res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" });