diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 417d47d86a4..03ef7a93927 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -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)._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(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2a39ad5c509..d3c68c4be52 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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 | 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(