Enforce execution-policy stage handoffs

This commit is contained in:
dotta 2026-04-08 08:05:35 -05:00
parent 9eaf72ab31
commit ec75cabcd8
8 changed files with 949 additions and 138 deletions

View file

@ -696,7 +696,14 @@ export function shouldResetTaskSessionForWake(
if (contextSnapshot?.forceFreshSession === true) return true;
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return true;
if (
wakeReason === "issue_assigned" ||
wakeReason === "execution_review_requested" ||
wakeReason === "execution_approval_requested" ||
wakeReason === "execution_changes_requested"
) {
return true;
}
return false;
}
@ -714,6 +721,9 @@ function describeSessionResetReason(
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested";
if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested";
if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested";
return null;
}
@ -867,9 +877,8 @@ async function buildPaperclipWakePayload(input: {
}
| null;
}) {
const executionStage = parseObject(input.contextSnapshot.executionStage);
const commentIds = extractWakeCommentIds(input.contextSnapshot);
if (commentIds.length === 0) return null;
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
const issueSummary =
input.issueSummary ??
@ -886,23 +895,27 @@ async function buildPaperclipWakePayload(input: {
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
.then((rows) => rows[0] ?? null)
: null);
if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null;
const commentRows = await input.db
.select({
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
inArray(issueComments.id, commentIds),
),
);
const commentRows =
commentIds.length === 0
? []
: await input.db
.select({
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
inArray(issueComments.id, commentIds),
),
);
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
const comments: Array<Record<string, unknown>> = [];
@ -959,6 +972,7 @@ async function buildPaperclipWakePayload(input: {
priority: issueSummary.priority,
}
: null,
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
commentIds,
latestCommentId: commentIds[commentIds.length - 1] ?? null,
comments,

View file

@ -36,6 +36,7 @@ type TransitionInput = {
type TransitionResult = {
patch: Record<string, unknown>;
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
workflowControlledAssignment?: boolean;
};
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
@ -198,14 +199,36 @@ function buildChangesRequestedState(previous: IssueExecutionState, currentStage:
};
}
function buildPendingStagePatch(input: {
patch: Record<string, unknown>;
previous: IssueExecutionState | null;
policy: IssueExecutionPolicy;
stage: IssueExecutionStage;
participant: IssueExecutionStagePrincipal;
returnAssignee: IssueExecutionStagePrincipal | null;
}) {
input.patch.status = "in_review";
Object.assign(input.patch, patchForPrincipal(input.participant));
input.patch.executionState = buildPendingState({
previous: input.previous,
stage: input.stage,
stageIndex: input.policy.stages.findIndex((candidate) => candidate.id === input.stage.id),
participant: input.participant,
returnAssignee: input.returnAssignee,
});
}
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
const patch: Record<string, unknown> = {};
const existingState = parseIssueExecutionState(input.issue.executionState);
const currentAssignee = assigneePrincipal(input.issue);
const actor = actorPrincipal(input.actor);
const requestedAssigneePatchProvided =
input.requestedAssigneePatch.assigneeAgentId !== undefined || input.requestedAssigneePatch.assigneeUserId !== undefined;
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
const requestedStatus = input.requestedStatus;
const activeStage = currentStage && existingState?.status === PENDING_STATUS ? currentStage : null;
if (!input.policy) {
if (existingState) {
@ -228,90 +251,121 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
return { patch };
}
if (currentStage && input.issue.status === "in_review") {
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
if (requestedStatus && requestedStatus !== "in_review") {
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
}
return { patch };
if (activeStage) {
const currentParticipant =
existingState?.currentParticipant ??
selectStageParticipant(activeStage, {
exclude: existingState?.returnAssignee ?? null,
});
if (!currentParticipant) {
throw unprocessable(`No eligible ${activeStage.type} participant is configured for this issue`);
}
if (requestedStatus === "done") {
if (!input.commentBody?.trim()) {
throw unprocessable("Approving a review or approval stage requires a comment");
}
const approvedState = buildCompletedState(existingState, currentStage);
const nextStage = nextPendingStage(
input.policy,
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
);
if (principalsEqual(currentParticipant, actor)) {
if (requestedStatus === "done") {
if (!input.commentBody?.trim()) {
throw unprocessable("Approving a review or approval stage requires a comment");
}
const approvedState = buildCompletedState(existingState, activeStage);
const nextStage = nextPendingStage(
input.policy,
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
);
if (!nextStage) {
patch.executionState = approvedState;
if (!nextStage) {
patch.executionState = approvedState;
return {
patch,
decision: {
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
};
}
const participant = selectStageParticipant(nextStage, {
preferred: explicitAssignee,
exclude: existingState?.returnAssignee ?? null,
});
if (!participant) {
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
}
buildPendingStagePatch({
patch,
previous: approvedState,
policy: input.policy,
stage: nextStage,
participant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
});
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
const participant = selectStageParticipant(nextStage, {
preferred: explicitAssignee,
exclude: existingState?.returnAssignee ?? null,
});
if (!participant) {
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
if (requestedStatus && requestedStatus !== "in_review") {
if (!input.commentBody?.trim()) {
throw unprocessable("Requesting changes requires a comment");
}
if (!existingState?.returnAssignee) {
throw unprocessable("This execution stage has no return assignee");
}
patch.status = "in_progress";
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
patch.executionState = buildChangesRequestedState(existingState, activeStage);
return {
patch,
decision: {
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "changes_requested",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
patch.status = "in_review";
Object.assign(patch, patchForPrincipal(participant));
patch.executionState = buildPendingState({
previous: approvedState,
stage: nextStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
participant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
});
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
};
}
if (requestedStatus && requestedStatus !== "in_review") {
if (!input.commentBody?.trim()) {
throw unprocessable("Requesting changes requires a comment");
}
if (!existingState?.returnAssignee) {
throw unprocessable("This execution stage has no return assignee");
}
patch.status = "in_progress";
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
patch.executionState = buildChangesRequestedState(existingState, currentStage);
if (
input.issue.status !== "in_review" ||
!principalsEqual(currentAssignee, currentParticipant) ||
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) ||
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant))
) {
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: activeStage,
participant: currentParticipant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
});
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "changes_requested",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
return { patch };
}
if (requestedStatus !== "done") {
const shouldStartWorkflow =
requestedStatus === "done" ||
requestedStatus === "in_review" ||
(input.issue.status === "in_review" && existingState == null);
if (!shouldStartWorkflow) {
return { patch };
}
@ -333,14 +387,16 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
}
patch.status = "in_review";
Object.assign(patch, patchForPrincipal(participant));
patch.executionState = buildPendingState({
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: pendingStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
participant,
returnAssignee,
});
return { patch };
return {
patch,
workflowControlledAssignment: true,
};
}