From fecc41d4fde9934b543f6193553ed069ec3c3d9a Mon Sep 17 00:00:00 2001 From: Sherman Lye <11371028+ming0627@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:31:33 +0800 Subject: [PATCH] fix(recovery): skip stranded-issue recovery when pending wake interaction exists (#4854) ## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents and their work. > - Recovery logic is part of that control plane because it decides when agent work is truly stranded versus intentionally waiting. > - Issues can pause on human-gated thread interactions such as `request_confirmation`, `ask_user_questions`, and `suggest_tasks`. > - `reconcileStrandedAssignedIssues()` was treating some of those waiting issues as stranded because it did not check for pending wake-style interactions. > - That mismatch created false-positive recovery cascades on work that was correctly paused for human input. > - This pull request adds the missing guard and locks it in with focused regression coverage. > - The benefit is safer recovery behavior: real stranded work is still recovered, while human-gated work stays stable and inspectable. ## Linked Issues or Issue Description - Refs #7403 - Searched open GitHub PRs/issues for the same recovery-interaction bug before merge prep; no duplicate open PRs found. ## What Changed - Added `hasPendingWakeInteraction(companyId, issueId)` in `server/src/services/recovery/service.ts` to detect pending thread interactions with continuation policies `wake_assignee` and `wake_assignee_on_accept`. - Inserted that guard into `reconcileStrandedAssignedIssues()` immediately after the active-execution-path check so human-gated issues are skipped instead of escalated. - Added a parameterized regression test in `server/src/__tests__/heartbeat-process-recovery.test.ts` that covers both continuation-policy values and verifies recovery does not fire. - Appended the maintainer cross-reference section required by merge prep. ## Verification - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/heartbeat-process-recovery.test.ts -t "skips stranded recovery when a pending" --pool=forks --isolate` - Greptile Summary comment on the latest head reports `Confidence Score: 5/5`. - Remote Paperclip CI is running on head `4702684c213b5018e6918cb6176e7ef40f440ebf`. ## Risks - Low risk. The production change is a read-only early exit in an existing recovery sweep. - The main behavioral shift is intentional: issues with pending wake-style interactions will no longer enter stranded recovery until the human gate clears. - If there is a hidden interaction state we should also treat as waiting, it would need an explicit follow-up rather than falling through this guard. > Checked `ROADMAP.md`; this is a focused bug fix, not overlapping roadmap feature work. ## Model Used - Original PR authoring: Claude Code using Claude Opus 4.6. - Merge prep, rebase, verification, PR-body repair, and Greptile follow-up: OpenAI Codex via the Paperclip ACPX local adapter (exact model ID not exposed in this workspace), with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Cross-references and status (maintainer) Refs #7403 Co-authored-by: Sherman Lye --- .../heartbeat-process-recovery.test.ts | 34 +++++++++++++++++++ server/src/services/recovery/service.ts | 21 ++++++++++++ 2 files changed, 55 insertions(+) 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;