Skip to content

Commit 024032d

Browse files
committed
feat(execution): execution_approved wake reason + self-approval fallback
- Emit an 'execution_approved' wake for the return-assignee agent when the execution workflow completes, so the executor gets a clean acknowledgement cycle instead of silently leaving the issue done - Update renderPaperclipWakePrompt to produce a distinct prompt when lastDecisionOutcome === 'approved' (tell the agent the work was accepted and to wrap up handoff notes) - Add 'execution_approved' to shouldResetTaskSessionForWake and describeSessionResetReason so the new wake type resets the task session consistently with other workflow transitions - Fix selectStageParticipant to fall back to the full participant pool when excluding the return assignee would produce an empty set — lets an executor self-approve when the execution policy lists only that participant on the stage - Add a regression test for the self-approval fallback
1 parent 9a8d219 commit 024032d

5 files changed

Lines changed: 164 additions & 11 deletions

File tree

packages/adapter-utils/src/server-utils.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -689,11 +689,19 @@ export function renderPaperclipWakePrompt(
689689
"",
690690
);
691691
} else if (executionStage.wakeRole === "executor") {
692-
lines.push(
693-
"You are waking because changes were requested in the execution workflow.",
694-
"Address the requested changes on this issue and resubmit when the work is ready.",
695-
"",
696-
);
692+
if (executionStage.lastDecisionOutcome === "approved") {
693+
lines.push(
694+
"Your submitted work on this issue was APPROVED and the execution workflow is complete.",
695+
"Acknowledge the approval, wrap up any remaining handoff notes, and do not continue executor work on this issue.",
696+
"",
697+
);
698+
} else {
699+
lines.push(
700+
"You are waking because changes were requested in the execution workflow.",
701+
"Address the requested changes on this issue and resubmit when the work is ready.",
702+
"",
703+
);
704+
}
697705
}
698706
}
699707

server/src/__tests__/issue-execution-policy.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,4 +1261,79 @@ describe("issue execution policy transitions", () => {
12611261
});
12621262
});
12631263
});
1264+
1265+
describe("single-participant stage coinciding with return assignee", () => {
1266+
it("starts workflow by falling back to the sole participant even if it is the return assignee", () => {
1267+
const policy = makePolicy([
1268+
{ type: "approval", participants: [{ type: "agent", agentId: coderAgentId }] },
1269+
]);
1270+
const result = applyIssueExecutionPolicyTransition({
1271+
issue: {
1272+
status: "in_progress",
1273+
assigneeAgentId: coderAgentId,
1274+
assigneeUserId: null,
1275+
executionPolicy: policy,
1276+
executionState: null,
1277+
},
1278+
policy,
1279+
requestedStatus: "done",
1280+
requestedAssigneePatch: {},
1281+
actor: { agentId: coderAgentId },
1282+
commentBody: "Self-approval allowed because policy lists only this participant",
1283+
});
1284+
1285+
expect(result.patch).toMatchObject({
1286+
status: "in_review",
1287+
assigneeAgentId: coderAgentId,
1288+
executionState: {
1289+
status: "pending",
1290+
currentStageType: "approval",
1291+
currentParticipant: { type: "agent", agentId: coderAgentId },
1292+
},
1293+
});
1294+
});
1295+
1296+
it("advances to a next stage whose sole participant is the return assignee", () => {
1297+
const policy = makePolicy([
1298+
{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] },
1299+
{ type: "approval", participants: [{ type: "agent", agentId: coderAgentId }] },
1300+
]);
1301+
const reviewStageId = policy.stages[0].id;
1302+
const result = applyIssueExecutionPolicyTransition({
1303+
issue: {
1304+
status: "in_review",
1305+
assigneeAgentId: qaAgentId,
1306+
assigneeUserId: null,
1307+
executionPolicy: policy,
1308+
executionState: {
1309+
status: "pending",
1310+
currentStageId: reviewStageId,
1311+
currentStageIndex: 0,
1312+
currentStageType: "review",
1313+
currentParticipant: { type: "agent", agentId: qaAgentId },
1314+
returnAssignee: { type: "agent", agentId: coderAgentId },
1315+
completedStageIds: [],
1316+
lastDecisionId: null,
1317+
lastDecisionOutcome: null,
1318+
},
1319+
},
1320+
policy,
1321+
requestedStatus: "done",
1322+
requestedAssigneePatch: {},
1323+
actor: { agentId: qaAgentId },
1324+
commentBody: "QA signoff",
1325+
});
1326+
1327+
expect(result.patch).toMatchObject({
1328+
status: "in_review",
1329+
assigneeAgentId: coderAgentId,
1330+
executionState: {
1331+
status: "pending",
1332+
currentStageType: "approval",
1333+
currentParticipant: { type: "agent", agentId: coderAgentId },
1334+
},
1335+
});
1336+
expect(result.decision?.outcome).toBe("approved");
1337+
});
1338+
});
12641339
});

server/src/routes/issues.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,45 @@ function buildExecutionStageWakeup(input: {
372372
};
373373
}
374374

375+
if (nextState.status === "completed") {
376+
const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null;
377+
const becameCompleted =
378+
previousState?.status !== "completed" ||
379+
previousState?.lastDecisionId !== nextState.lastDecisionId;
380+
if (!agentId || !becameCompleted) return null;
381+
382+
const executionStage = buildExecutionStageWakeContext({
383+
state: nextState,
384+
wakeRole: "executor",
385+
allowedActions: ["acknowledge_completion"],
386+
});
387+
388+
return {
389+
agentId,
390+
wakeup: {
391+
source: "assignment" as const,
392+
triggerDetail: "system" as const,
393+
reason: "execution_approved",
394+
payload: {
395+
issueId,
396+
mutation: "update",
397+
executionStage,
398+
...(interruptedRunId ? { interruptedRunId } : {}),
399+
},
400+
requestedByActorType: input.requestedByActorType,
401+
requestedByActorId: input.requestedByActorId,
402+
contextSnapshot: {
403+
issueId,
404+
taskId: issueId,
405+
wakeReason: "execution_approved",
406+
source: "issue.execution_stage",
407+
executionStage,
408+
...(interruptedRunId ? { interruptedRunId } : {}),
409+
},
410+
},
411+
};
412+
}
413+
375414
return null;
376415
}
377416

server/src/services/heartbeat.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1326,7 +1326,8 @@ export function shouldResetTaskSessionForWake(
13261326
wakeReason === "issue_assigned" ||
13271327
wakeReason === "execution_review_requested" ||
13281328
wakeReason === "execution_approval_requested" ||
1329-
wakeReason === "execution_changes_requested"
1329+
wakeReason === "execution_changes_requested" ||
1330+
wakeReason === "execution_approved"
13301331
) {
13311332
return true;
13321333
}
@@ -1401,6 +1402,7 @@ function describeSessionResetReason(
14011402
if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested";
14021403
if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested";
14031404
if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested";
1405+
if (wakeReason === "execution_approved") return "wake reason is execution_approved";
14041406
return null;
14051407
}
14061408

server/src/services/issue-execution-policy.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -403,10 +403,26 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
403403
};
404404
}
405405

406-
const participant = selectStageParticipant(nextStage, {
406+
let participant = selectStageParticipant(nextStage, {
407407
preferred: explicitAssignee,
408408
exclude: existingState?.returnAssignee ?? null,
409409
});
410+
// Fall back to the return assignee when the next stage has no other
411+
// eligible participants and cannot be auto-skipped — the policy
412+
// lists only the return assignee on this stage, so self-approval is
413+
// intentional. Skippable stages (e.g. review with only the return
414+
// assignee) are handled by the start-workflow skip loop on the
415+
// next transition instead.
416+
if (
417+
!participant &&
418+
!canAutoSkipPendingStage({
419+
stage: nextStage,
420+
returnAssignee: existingState?.returnAssignee ?? null,
421+
requestedStatus,
422+
})
423+
) {
424+
participant = selectStageParticipant(nextStage, { preferred: explicitAssignee });
425+
}
410426
if (!participant) {
411427
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
412428
}
@@ -502,13 +518,26 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
502518

503519
const returnAssignee = existingState?.returnAssignee ?? currentAssignee;
504520
const skippedStageIds = [...(existingState?.completedStageIds ?? [])];
521+
const selectPreferred =
522+
existingState?.status === CHANGES_REQUESTED_STATUS
523+
? explicitAssignee ?? existingState.currentParticipant ?? null
524+
: explicitAssignee;
505525
let participant = selectStageParticipant(pendingStage, {
506-
preferred:
507-
existingState?.status === CHANGES_REQUESTED_STATUS
508-
? explicitAssignee ?? existingState.currentParticipant ?? null
509-
: explicitAssignee,
526+
preferred: selectPreferred,
510527
exclude: returnAssignee,
511528
});
529+
// When the pending stage's only eligible participant is the return
530+
// assignee and the stage cannot be auto-skipped (e.g. an approval stage),
531+
// fall back to self-assignment — policies that list only one participant
532+
// on a non-skippable stage imply self-approval is intentional.
533+
if (
534+
!participant &&
535+
!canAutoSkipPendingStage({ stage: pendingStage, returnAssignee, requestedStatus })
536+
) {
537+
participant = selectStageParticipant(pendingStage, {
538+
preferred: selectPreferred,
539+
});
540+
}
512541
while (!participant && canAutoSkipPendingStage({ stage: pendingStage, returnAssignee, requestedStatus })) {
513542
skippedStageIds.push(pendingStage.id);
514543
pendingStage = nextPendingStage(

0 commit comments

Comments
 (0)