fix(server): let board users cancel issues with an active review stage (#10655)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server enforces an issue execution policy. It gates status
changes while a review or approval stage is active.
> - A board user could not cancel a task while an agent reviewer held
the active stage. The API returned "Only the active reviewer or approver
can advance the current execution stage".
> - Board users own the board. They must always be able to edit and
cancel any task.
> - This pull request adds a board override to the execution stage
transition. A board cancel clears the pending stage state and proceeds
instead of raising an error.
> - The benefit is that board users can always stop work, even while a
review is pending or the stored stage state has drifted.

## Linked Issues or Issue Description

No public GitHub issue exists for this bug. Description follows
`bug_report.yml`:

**What happened?**

A board user set a task to `cancelled` while the task had an active
reviewer stage held by an agent. The PATCH failed with "Task Update
Failed... only the assigned approver or reviewer...". The same failure
occurred when the stored stage state had drifted: the server silently
forced the task back to `in_review` instead of honoring the cancel.

**Expected behavior**

A board user can always edit and cancel any task. A board cancel must
clear the pending review stage and apply the requested status.

**Steps to reproduce**

1. Create a task assigned to agent A with agent B configured as reviewer
in the execution policy.
2. Let agent A hand the task off so the review stage becomes active.
3. As the board user, set the task status to Cancelled.
4. The update fails with the reviewer-only error.

Related work: #5487 touches the execution-policy approver UI. It does
not address the board cancel path.

## What Changed

- `server/src/routes/issues.ts`: the issue PATCH route now passes
`allowBoardOverride` when the actor is a board user.
- `server/src/services/issue-execution-policy.ts`: when
`allowBoardOverride` is set and the requested status is not `in_review`
or `in_progress`, the transition clears `executionState` and proceeds.
This applies both while a stage decision is pending and when the stage
state has drifted, so a board cancel is no longer rejected or silently
flipped back to `in_review`.
- Reviewer gating is unchanged for everyone else: a board user who is
the active participant still uses the normal approve / request-changes
flow, and non-participant agents still receive the 422 guard.
- Assignee-only board updates on an `in_review` task keep the stage
state coherent: reassigning to an eligible stage participant re-pends
the stage with them as the current participant, while reassigning to a
non-participant (or unassigning) dissolves the review back to
`in_progress` instead of persisting an `in_review` issue with no
execution state or an ineligible participant.
- New unit tests and route tests cover board cancellation of an active
review stage and of a drifted pending review, plus reviewer swap,
non-participant reassignment, and unassignment during an active review.

## Verification

- In `server/`: `pnpm exec vitest run
src/__tests__/issue-execution-policy.test.ts
src/__tests__/issue-execution-policy-routes.test.ts` — 2 files, 73/73
tests pass on top of current `master`.
- In `server/`: `pnpm run typecheck` passes.

## Risks

- Low risk. The override branch runs only for board actors and only for
target statuses other than `in_review` and `in_progress`. Cancelling
clears `executionState`, so a later reopen starts from a fresh stage
state. Agent-facing flows and reviewer gating are unchanged.

## Model Used

- Claude Fable 5 (Anthropic), model ID `claude-fable-5`, running in
Claude Code (Claude Agent SDK) with extended thinking and agentic tool
use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-08-01 15:43:13 -07:00 committed by GitHub
parent ada47be764
commit 0f12721ee9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 446 additions and 0 deletions

View File

@ -433,6 +433,234 @@ describe("issue execution policy routes", () => {
expect(mockIssueApprovalService.listApprovalsForIssue).not.toHaveBeenCalled();
});
it("allows a board user to cancel an active agent review task", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
},
],
})!;
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1008",
title: "Active review",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: "11111111-1111-4111-8111-111111111111",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "cancelled" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
status: "cancelled",
executionState: null,
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("allows a board user to cancel a drifted pending agent review task", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
},
],
})!;
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "blocked",
assigneeAgentId: "44444444-4444-4444-8444-444444444444",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1009",
title: "Drifted active review",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: "11111111-1111-4111-8111-111111111111",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "cancelled" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
status: "cancelled",
executionState: null,
actorAgentId: null,
actorUserId: "local-board",
}),
);
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
expect(updatePatch.status).toBe("cancelled");
expect(updatePatch.assigneeAgentId).toBeUndefined();
expect(updatePatch.assigneeUserId).toBeUndefined();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("keeps the review stage pending when a board user reassigns to an eligible participant", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [
{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
{ type: "agent", agentId: "55555555-5555-4555-8555-555555555555" },
],
},
],
})!;
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1010",
title: "Reassigned review",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: "11111111-1111-4111-8111-111111111111",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ assigneeAgentId: "55555555-5555-4555-8555-555555555555" });
expect(res.status).toBe(200);
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
expect(updatePatch.status).toBe("in_review");
expect(updatePatch.assigneeAgentId).toBe("55555555-5555-4555-8555-555555555555");
expect(updatePatch.assigneeUserId).toBeNull();
expect(updatePatch.executionState).toMatchObject({
status: "pending",
currentStageId: "11111111-1111-4111-8111-111111111111",
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "55555555-5555-4555-8555-555555555555" },
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
});
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("dissolves the review when a board user reassigns an in_review task to a non-participant", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
},
],
})!;
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1011",
title: "Reassigned away from review",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: "11111111-1111-4111-8111-111111111111",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ assigneeAgentId: "55555555-5555-4555-8555-555555555555" });
expect(res.status).toBe(200);
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
expect(updatePatch.status).toBe("in_progress");
expect(updatePatch.executionState).toBeNull();
expect(updatePatch.assigneeAgentId).toBe("55555555-5555-4555-8555-555555555555");
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [

View File

@ -569,6 +569,181 @@ describe("issue execution policy transitions", () => {
).toThrow("Only the active reviewer or approver can advance");
});
it("board override can cancel an active review without recording an approval decision", () => {
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: "cancelled",
requestedAssigneePatch: {},
actor: { userId: boardUserId },
allowBoardOverride: true,
commentBody: "Cancelling this task",
});
expect(result.patch).toEqual({ executionState: null });
expect(result.decision).toBeUndefined();
expect(result.workflowControlledAssignment).toBeUndefined();
});
it("board override can cancel a drifted pending review without rebuilding the pending stage", () => {
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "blocked",
assigneeAgentId: coderAgentId,
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: "cancelled",
requestedAssigneePatch: {},
actor: { userId: boardUserId },
allowBoardOverride: true,
commentBody: "Cancelling this drifted task",
});
expect(result.patch).toEqual({ executionState: null });
expect(result.decision).toBeUndefined();
expect(result.workflowControlledAssignment).toBeUndefined();
});
it("board override reassignment to an eligible participant re-pends the stage", () => {
const multiReviewerPolicy = makePolicy([
{
type: "review",
participants: [
{ type: "agent", agentId: qaAgentId },
{ type: "agent", agentId: ctoAgentId },
],
},
]);
const multiReviewerStageId = multiReviewerPolicy.stages[0].id;
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionPolicy: multiReviewerPolicy,
executionState: {
status: "pending",
currentStageId: multiReviewerStageId,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
},
policy: multiReviewerPolicy,
requestedAssigneePatch: { assigneeAgentId: ctoAgentId },
actor: { userId: boardUserId },
allowBoardOverride: true,
commentBody: "Swapping the reviewer",
});
expect(result.patch.status).toBe("in_review");
expect(result.patch.assigneeAgentId).toBe(ctoAgentId);
expect(result.patch.assigneeUserId).toBeNull();
expect(result.patch.executionState).toMatchObject({
status: "pending",
currentStageId: multiReviewerStageId,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: ctoAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
});
expect(result.decision).toBeUndefined();
});
it("board override reassignment to a non-participant dissolves the review", () => {
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,
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
actor: { userId: boardUserId },
allowBoardOverride: true,
commentBody: "Handing the task back",
});
expect(result.patch).toEqual({ executionState: null, status: "in_progress" });
expect(result.decision).toBeUndefined();
expect(result.workflowControlledAssignment).toBeUndefined();
});
it("board override unassignment dissolves the review instead of stranding in_review", () => {
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,
requestedAssigneePatch: { assigneeAgentId: null, assigneeUserId: null },
actor: { userId: boardUserId },
allowBoardOverride: true,
commentBody: "Unassigning the reviewer",
});
expect(result.patch).toEqual({ executionState: null, status: "in_progress" });
expect(result.decision).toBeUndefined();
expect(result.workflowControlledAssignment).toBeUndefined();
});
it("non-participant can still post non-advancing updates", () => {
const result = applyIssueExecutionPolicyTransition({
issue: {

View File

@ -8005,6 +8005,7 @@ export function issueRoutes(
agentId: actor.agentId ?? null,
userId: actor.actorType === "user" ? actor.actorId : null,
},
allowBoardOverride: req.actor.type === "board",
commentBody,
reviewRequest: reviewRequest === undefined ? undefined : reviewRequest,
monitorExplicitlyUpdated: req.body.executionPolicy !== undefined && monitorChanged,

View File

@ -49,6 +49,7 @@ type TransitionInput = {
requestedStatus?: string;
requestedAssigneePatch: RequestedAssigneePatch;
actor: ActorLike;
allowBoardOverride?: boolean;
commentBody?: string | null;
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
monitorExplicitlyUpdated?: boolean;
@ -837,6 +838,16 @@ function applyIssueExecutionStageTransition(input: TransitionInput): TransitionR
};
}
if (
input.allowBoardOverride &&
requestedStatus &&
requestedStatus !== "in_review" &&
requestedStatus !== "in_progress"
) {
patch.executionState = null;
return { patch };
}
if (requestedStatus && requestedStatus !== "in_review") {
if (!input.commentBody?.trim()) {
throw unprocessable(`Requesting changes requires a comment. ${STAGE_DECISION_COMMENT_HINT}`);
@ -898,6 +909,37 @@ function applyIssueExecutionStageTransition(input: TransitionInput): TransitionR
!principalsEqual(currentAssignee, currentParticipant) ||
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant);
if (input.allowBoardOverride && attemptedStageAdvance) {
if (requestedStatus !== undefined && requestedStatus !== "in_review") {
patch.executionState = null;
return { patch };
}
// Assignee-only override: the issue stays in_review, so clearing the
// execution state would strand it with no participant or return
// assignment. Re-pend the stage when the board's chosen assignee is an
// eligible stage participant; otherwise (unassign or a non-participant)
// dissolve the review — storing an ineligible participant would be
// silently replaced by the stage-membership repair on the next
// transition.
if (explicitAssignee && stageHasParticipant(activeStage, explicitAssignee)) {
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: activeStage,
participant: explicitAssignee,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
reviewRequest: effectiveReviewRequest,
});
return { patch };
}
patch.executionState = null;
if (input.issue.status === "in_review") {
patch.status = "in_progress";
}
return { patch };
}
if (attemptedStageAdvance && !stageStateDrifted) {
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
}