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

@ -56,13 +56,149 @@ import {
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
parseIssueExecutionState,
} from "../services/issue-execution-policy.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
type ExecutionStageWakeContext = {
wakeRole: "reviewer" | "approver" | "executor";
stageId: string | null;
stageType: ParsedExecutionState["currentStageType"];
currentParticipant: ParsedExecutionState["currentParticipant"];
returnAssignee: ParsedExecutionState["returnAssignee"];
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
right: ParsedExecutionState["currentParticipant"] | null,
) {
if (!left || !right || left.type !== right.type) return false;
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
}
function buildExecutionStageWakeContext(input: {
state: ParsedExecutionState;
wakeRole: ExecutionStageWakeContext["wakeRole"];
allowedActions: string[];
}): ExecutionStageWakeContext {
return {
wakeRole: input.wakeRole,
stageId: input.state.currentStageId,
stageType: input.state.currentStageType,
currentParticipant: input.state.currentParticipant,
returnAssignee: input.state.returnAssignee,
lastDecisionOutcome: input.state.lastDecisionOutcome,
allowedActions: input.allowedActions,
};
}
function buildExecutionStageWakeup(input: {
issueId: string;
previousState: ParsedExecutionState | null;
nextState: ParsedExecutionState | null;
interruptedRunId: string | null;
requestedByActorType: "user" | "agent";
requestedByActorId: string;
}) {
const { issueId, previousState, nextState, interruptedRunId } = input;
if (!nextState) return null;
if (nextState.status === "pending") {
const agentId =
nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null;
const stageChanged =
previousState?.status !== "pending" ||
previousState?.currentStageId !== nextState.currentStageId ||
!executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null);
if (!agentId || !stageChanged) return null;
const reason =
nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested";
const executionStage = buildExecutionStageWakeContext({
state: nextState,
wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer",
allowedActions: ["approve", "request_changes"],
});
return {
agentId,
wakeup: {
source: "assignment" as const,
triggerDetail: "system" as const,
reason,
payload: {
issueId,
mutation: "update",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: reason,
source: "issue.execution_stage",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
},
};
}
if (nextState.status === "changes_requested") {
const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null;
const becameChangesRequested =
previousState?.status !== "changes_requested" ||
previousState?.lastDecisionId !== nextState.lastDecisionId ||
!executionPrincipalsEqual(previousState?.returnAssignee ?? null, nextState.returnAssignee ?? null);
if (!agentId || !becameChangesRequested) return null;
const executionStage = buildExecutionStageWakeContext({
state: nextState,
wakeRole: "executor",
allowedActions: ["address_changes", "resubmit"],
});
return {
agentId,
wakeup: {
source: "assignment" as const,
triggerDetail: "system" as const,
reason: "execution_changes_requested",
payload: {
issueId,
mutation: "update",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "execution_changes_requested",
source: "issue.execution_stage",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
},
};
}
return null;
}
export function issueRoutes(
db: Db,
storage: StorageService,
@ -1110,24 +1246,6 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, existing.companyId);
const assigneeWillChange =
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
const isAgentReturningIssueToCreator =
req.actor.type === "agent" &&
!!req.actor.agentId &&
existing.assigneeAgentId === req.actor.agentId &&
req.body.assigneeAgentId === null &&
typeof req.body.assigneeUserId === "string" &&
!!existing.createdByUserId &&
req.body.assigneeUserId === existing.createdByUserId;
if (assigneeWillChange) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId);
}
}
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
@ -1224,6 +1342,27 @@ export function issueRoutes(
}
Object.assign(updateFields, transition.patch);
const nextAssigneeAgentId =
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
const nextAssigneeUserId =
updateFields.assigneeUserId === undefined ? existing.assigneeUserId : (updateFields.assigneeUserId as string | null);
const assigneeWillChange =
nextAssigneeAgentId !== existing.assigneeAgentId || nextAssigneeUserId !== existing.assigneeUserId;
const isAgentReturningIssueToCreator =
req.actor.type === "agent" &&
!!req.actor.agentId &&
existing.assigneeAgentId === req.actor.agentId &&
nextAssigneeAgentId === null &&
typeof nextAssigneeUserId === "string" &&
!!existing.createdByUserId &&
nextAssigneeUserId === existing.createdByUserId;
if (assigneeWillChange && !transition.workflowControlledAssignment) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId);
}
}
let issue;
try {
if (transition.decision && decisionId) {
@ -1414,6 +1553,16 @@ export function issueRoutes(
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
const previousExecutionState = parseIssueExecutionState(existing.executionState);
const nextExecutionState = parseIssueExecutionState(issue.executionState);
const executionStageWakeup = buildExecutionStageWakeup({
issueId: issue.id,
previousState: previousExecutionState,
nextState: nextExecutionState,
interruptedRunId,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
void (async () => {
@ -1427,7 +1576,9 @@ export function issueRoutes(
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
};
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
if (executionStageWakeup) {
addWakeup(executionStageWakeup.agentId, executionStageWakeup.wakeup);
} else if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
addWakeup(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",