From 114f67ff38c74f33e752d15151760fa09acbe25c Mon Sep 17 00:00:00 2001 From: Toni Cardoso Date: Mon, 25 May 2026 08:02:36 +0100 Subject: [PATCH] fix: allow board review-stage reconciliation --- .../__tests__/issue-execution-policy.test.ts | 43 +++++++++++++++++++ server/src/services/issue-execution-policy.ts | 6 ++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index c66dde8f53..61ea04b519 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -598,6 +598,49 @@ describe("issue execution policy transitions", () => { // No error — just no patch modifications expect(result.patch).toEqual({}); }); + + it("board operator can reconcile a stuck active review stage back to the return assignee", () => { + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: qaAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: reviewStageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }, + policy, + requestedStatus: "in_progress", + requestedAssigneePatch: { assigneeAgentId: coderAgentId }, + actor: { userId: boardUserId }, + commentBody: "Operator reconciliation: reviewer already requested changes but the runtime failed to patch state.", + }); + + expect(result.patch.status).toBe("in_progress"); + expect(result.patch.assigneeAgentId).toBe(coderAgentId); + expect(result.patch.executionState).toMatchObject({ + status: "changes_requested", + currentStageId: reviewStageId, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + lastDecisionOutcome: "changes_requested", + }); + expect(result.decision).toMatchObject({ + stageId: reviewStageId, + stageType: "review", + outcome: "changes_requested", + }); + }); }); describe("comment requirements", () => { diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 37b75c84c1..ce9998a86e 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -65,6 +65,10 @@ const MONITOR_INVALID_MESSAGE = "Monitor can only be scheduled on issues assigne const MONITOR_BOUNDS_EXHAUSTED_MESSAGE = "Monitor bounds are already exhausted"; export const REDACTED_ISSUE_MONITOR_EXTERNAL_REF = "[redacted]"; +function isBoardActor(actor: ActorLike) { + return Boolean(actor.userId) && !actor.agentId; +} + function normalizeMonitorNotes(notes: string | null | undefined) { if (typeof notes !== "string") return null; const trimmed = notes.trim(); @@ -690,7 +694,7 @@ function applyIssueExecutionStageTransition(input: TransitionInput): TransitionR }; } - if (principalsEqual(currentParticipant, actor)) { + if ((actor && principalsEqual(currentParticipant, actor)) || isBoardActor(input.actor)) { if (requestedStatus === "done") { if (!input.commentBody?.trim()) { throw unprocessable("Approving a review or approval stage requires a comment");