fix(recovery): preserve hand-back wake liveness (#10562)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The heartbeat service delivers issue work to assigned agents.
> - Recovery can hand an issue back to its agent while the recovery run
is still active.
> - The hand-back wake can merge into that active run and disappear when
the run exits.
> - The stranded-work scan also treats the successful recovery run as
proof that the handed-back issue is live.
> - This pull request keeps the hand-back wake for follow-up delivery
and lets the scan repair a lost wake.
> - The benefit is that an assigned issue continues after recovery
without manual operator action.

## Linked Issues or Issue Description

No public issue exists. This is related to the wake reconciliation work
in #8943.

**What happened?**

A recovery action could hand an assigned issue back from `blocked` to
`todo`. The `issue_recovery_action_restored` wake then merged into the
recovery run that made the change. The wake disappeared when that run
exited. The stranded-work scan did not repair the issue because it
treated the successful recovery run as current liveness.

**Expected behavior**

Paperclip must dispatch the hand-back wake after the recovery run exits.
If that delivery is lost, the stranded-work scan must enqueue the
assigned `todo` issue again.

**Steps to reproduce**

1. Start a recovery run for an assigned blocked issue.
2. Resolve a recovery action with the `handed_back` outcome.
3. Move the issue to `todo` while the recovery run is still active.
4. Observe that the wake merges into the active run and no new run
starts after it exits.
5. Run the stranded-work scan and observe that the successful latest run
prevents repair.

**Paperclip version or commit**

`131d476a7e`

**Deployment mode**

Local dev (`pnpm dev`). The defect is in the core server and is not
deployment-specific.

**Agent adapter(s) involved**

Not adapter-specific. This is a core heartbeat and recovery defect.

## What Changed

- Added `issue_recovery_action_restored` to the wake reasons that
require follow-up delivery when an issue run is active.
- Made the stranded-work scan detect a resolved hand-back that occurred
during or after the latest successful run.
- Added focused regression tests for the heartbeat coalescing seam and
the stranded-work repair shape.
- Documented the hand-back liveness guarantee in execution semantics
section 9.1.

## Verification

- `pnpm exec vitest run
server/src/__tests__/heartbeat-comment-wake-batching.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts` passed: 104
tests.
- `pnpm --filter @paperclipai/server typecheck` passed.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `pnpm test:run` passed the server shard (3,095 passed, 2 skipped) and
UI shard (3,182 passed). One unrelated CLI test failed because the agent
environment exports static AWS credentials. `env -u AWS_ACCESS_KEY_ID -u
AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN pnpm exec vitest run
cli/src/__tests__/secrets.test.ts` passed all 8 tests.
- `git diff --check` passed.
- All GitHub checks passed on commit `8b380e67e6`.
- Greptile gave 5/5 confidence with no comments or unresolved threads.

## Risks

- Low risk. The follow-up rule affects only a recovery hand-back wake
that arrives while the same issue already has an active run.
- The backstop adds one indexed recovery-action lookup for an assigned
`todo` issue whose latest run succeeded.
- The timestamp check uses the latest run start time. This includes
hand-backs made by that run and later hand-backs, but excludes older
resolved actions.

## Model Used

- OpenAI Codex with GPT-5 (`gpt-5`), agentic reasoning, tool use, and
code execution. The serving context-window size is not exposed to the
agent.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] 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

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-07-31 11:03:30 -07:00
1 parent bd53b99686
commit cd7f84965c
5 files changed
+221 -2

No files matched your search

+1
View File
@@ -544,6 +544,7 @@ function mergeAdapterRecoveryMetadata(input: {
const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set([
"approval_approved",
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
"issue_recovery_action_restored",
]);
const ISSUE_RESPONSIBLE_USER_WAKE_REASONS = new Set([
"issue_assigned",
+42 -2
View File
@@ -140,7 +140,15 @@ type ResolvedDependencyWakeBackstopOptions = {
type LatestIssueRun = Pick<
typeof heartbeatRuns.$inferSelect,
"id" | "agentId" | "status" | "error" | "errorCode" | "contextSnapshot" | "livenessState"
| "id"
| "agentId"
| "status"
| "error"
| "errorCode"
| "contextSnapshot"
| "livenessState"
| "startedAt"
| "createdAt"
> & {
resultJson?: unknown;
} | null;
@@ -784,6 +792,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
contextSnapshot: heartbeatRuns.contextSnapshot,
livenessState: heartbeatRuns.livenessState,
resultJson: heartbeatRuns.resultJson,
startedAt: heartbeatRuns.startedAt,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.where(
@@ -812,6 +822,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
contextSnapshot: heartbeatRuns.contextSnapshot,
livenessState: heartbeatRuns.livenessState,
resultJson: heartbeatRuns.resultJson,
startedAt: heartbeatRuns.startedAt,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.where(
@@ -948,6 +960,29 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.then((rows) => Boolean(rows[0]));
}
async function wasTodoHandedBackDuringOrAfterLatestRun(
issue: typeof issues.$inferSelect,
latestRun: LatestIssueRun,
) {
if (issue.status !== "todo" || latestRun?.status !== "succeeded") return false;
const runBeganAt = latestRun.startedAt ?? latestRun.createdAt;
return db
.select({ id: issueRecoveryActions.id })
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, issue.companyId),
eq(issueRecoveryActions.sourceIssueId, issue.id),
eq(issueRecoveryActions.status, "resolved"),
eq(issueRecoveryActions.outcome, "handed_back"),
gte(issueRecoveryActions.resolvedAt, runBeganAt),
),
)
.limit(1)
.then((rows) => Boolean(rows[0]));
}
async function hasQueuedIssueWake(companyId: string, issueId: string, agentId?: string | null) {
return db
.select({ id: agentWakeupRequests.id })
@@ -1026,6 +1061,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
contextSnapshot: heartbeatRuns.contextSnapshot,
livenessState: heartbeatRuns.livenessState,
resultJson: heartbeatRuns.resultJson,
startedAt: heartbeatRuns.startedAt,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.where(
@@ -4001,7 +4038,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
continue;
}
if (latestRun.status === "succeeded") {
if (
latestRun.status === "succeeded" &&
!(await wasTodoHandedBackDuringOrAfterLatestRun(issue, latestRun))
) {
result.skipped += 1;
continue;
}