mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Queue approval follow-up wakes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user