diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 414aad1c..797fa163 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -2261,6 +2261,40 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }, ); + it.each([ + "wake_assignee", + "wake_assignee_on_accept", + ] as const)("skips stranded recovery when a pending %s interaction exists", async (continuationPolicy) => { + const { companyId, agentId, issueId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + }); + + await db.insert(issueThreadInteractions).values({ + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy, + createdByAgentId: agentId, + payload: { version: 1, prompt: "Approve the plan?" }, + }); + + const heartbeat = heartbeatService(db); + const result = await heartbeat.reconcileStrandedAssignedIssues(); + + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + expect(result.skipped).toBeGreaterThanOrEqual(1); + + const issue = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("in_progress"); + }); + it("still re-enqueues stranded assigned todo recovery when an old queued wake exists", async () => { const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ status: "todo", diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 96dc2387..f8655523 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -582,6 +582,22 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return Boolean(run || deferredWake); } + async function hasPendingWakeInteraction(companyId: string, issueId: string) { + return db + .select({ id: issueThreadInteractions.id }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.companyId, companyId), + eq(issueThreadInteractions.issueId, issueId), + eq(issueThreadInteractions.status, "pending"), + inArray(issueThreadInteractions.continuationPolicy, ["wake_assignee", "wake_assignee_on_accept"]), + ), + ) + .limit(1) + .then((rows) => Boolean(rows[0])); + } + async function hasQueuedIssueWake(companyId: string, issueId: string) { return db .select({ id: agentWakeupRequests.id }) @@ -2671,6 +2687,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) continue; } + if (await hasPendingWakeInteraction(issue.companyId, issue.id)) { + result.skipped += 1; + continue; + } + if (await isAutomaticRecoverySuppressedByPauseHold(db, issue.companyId, issue.id, treeControlSvc)) { result.skipped += 1; continue;