Queue approval follow-up wakes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-19 21:45:59 -05:00
1 parent 60cce909f9
commit c632f8afcd
2 files changed
+127 -6

No files matched your search

@@ -236,6 +236,115 @@ describe("heartbeat comment wake batching", () => {
}
});
it("defers approval-approved wakes for a running issue so the assignee resumes after the run", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CEO",
role: "ceo",
status: "running",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "running",
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Hire an agent",
status: "blocked",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: runId,
executionAgentNameKey: "ceo",
executionLockedAt: new Date(),
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const followupRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_approved",
payload: {
issueId,
approvalId: "approval-1",
approvalStatus: "approved",
},
contextSnapshot: {
issueId,
taskId: issueId,
approvalId: "approval-1",
approvalStatus: "approved",
wakeReason: "approval_approved",
},
requestedByActorType: "user",
requestedByActorId: "local-board",
});
expect(followupRun).toBeNull();
const deferred = await db
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
expect(deferred).not.toBeNull();
expect(deferred?.reason).toBe("issue_execution_deferred");
expect(deferred?.payload).toMatchObject({
issueId,
approvalId: "approval-1",
approvalStatus: "approved",
});
expect((deferred?.payload as Record<string, unknown>)._paperclipWakeContext).toMatchObject({
issueId,
taskId: issueId,
approvalId: "approval-1",
approvalStatus: "approved",
wakeReason: "approval_approved",
});
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
expect(runs[0]?.id).toBe(runId);
});
it("batches deferred comment wakes and forwards the ordered batch to the next run", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
+18 -6
View File
@@ -99,6 +99,7 @@ const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000;
const execFile = promisify(execFileCallback);
const ACTIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"] as const;
const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const;
const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set(["approval_approved"]);
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
@@ -1155,6 +1156,15 @@ function shouldAutoCheckoutIssueForWake(input: {
return true;
}
function shouldQueueFollowupForRunningIssueWake(input: {
contextSnapshot: Record<string, unknown> | null | undefined;
wakeCommentId: string | null;
}) {
if (input.wakeCommentId) return true;
const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason);
return Boolean(wakeReason && RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP.has(wakeReason));
}
function isCheckoutConflictError(error: unknown): boolean {
return error instanceof HttpError && error.status === 409 && error.message === "Issue checkout conflict";
}
@@ -4741,12 +4751,12 @@ export function heartbeatService(db: Db) {
normalizeAgentNameKey(executionAgent?.name);
const isSameExecutionAgent =
Boolean(executionAgentNameKey) && executionAgentNameKey === agentNameKey;
const shouldQueueFollowupForCommentWake =
Boolean(wakeCommentId) &&
const shouldQueueFollowupForRunningWake =
shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }) &&
activeExecutionRun.status === "running" &&
isSameExecutionAgent;
if (isSameExecutionAgent && !shouldQueueFollowupForCommentWake) {
if (isSameExecutionAgent && !shouldQueueFollowupForRunningWake) {
const mergedContextSnapshot = mergeCoalescedContextSnapshot(
activeExecutionRun.contextSnapshot,
enrichedContextSnapshot,
@@ -4922,12 +4932,14 @@ export function heartbeatService(db: Db) {
const sameScopeRunningRun = activeRuns.find(
(candidate) => candidate.status === "running" && isSameTaskScope(runTaskKey(candidate), taskKey),
);
const shouldQueueFollowupForCommentWake =
Boolean(wakeCommentId) && Boolean(sameScopeRunningRun) && !sameScopeQueuedRun;
const shouldQueueFollowupForRunningWake =
Boolean(sameScopeRunningRun) &&
!sameScopeQueuedRun &&
shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId });
const coalescedTargetRun =
sameScopeQueuedRun ??
(shouldQueueFollowupForCommentWake ? null : sameScopeRunningRun ?? null);
(shouldQueueFollowupForRunningWake ? null : sameScopeRunningRun ?? null);
if (coalescedTargetRun) {
const mergedContextSnapshot = mergeCoalescedContextSnapshot(