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 <user@example.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user