Skip to content

fix(recovery): stop stranding recovery actions on backlog issues (BLO-25907) - #1345

Merged
allyblockcast[bot] merged 1 commit into
masterfrom
cto/blo-25907-backlog-recovery-coverage
Aug 14, 2026
Merged

fix(recovery): stop stranding recovery actions on backlog issues (BLO-25907)#1345
allyblockcast[bot] merged 1 commit into
masterfrom
cto/blo-25907-backlog-recovery-coverage

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Its recovery subsystem creates issue_recovery_actions rows to re-drive issues whose agent runs have stalled, and reconciler sweeps are what actually service those rows
  • Each sweep selects candidates by issue status, and the union of those filters silently failed to cover backlog: reconcileStrandedAssignedIssues took todo/in_progress/in_review, reconcileStrandedRecoveryWakeBackstop took blocked
  • So an assigned backlog issue holding an active recovery action fell between the two filters — selected by nothing, serviced by nothing, and still reporting as active to any "what needs attention" query
  • This is not hypothetical: BLO-16074 itself sat in exactly that state for 27 days with updatedAt == createdAt until a human moved it by hand
  • This pull request makes backlog an explicitly-handled status — folded, not woken — and pins the union of the sweep filters as a tested invariant so a future status addition cannot reopen the same hole
  • The benefit is that the active-recovery set becomes honest: every non-terminal status is covered by exactly one sweep, and nothing can hold an active action that no candidate query can select

Linked Issues or Issue Description

Closes BLO-25907 — the last open gap (gap 3) of BLO-16074.

Related PRs found in the duplicate search (all touch server/src/services/recovery/service.ts or issue-recovery-actions.test.ts, all disjoint in intent — none of them widen a candidate filter or add a backlog branch):

What Changed

  • server/src/services/recovery/service.ts — backstop candidate filter widened blocked["blocked", "backlog"], with a fold branch at the top of the candidate loop that resolves backlog rows cancelled/cancelled with a resolution note naming backlog, counted as backlogParkedResolved. Placed ahead of the owner/cause/cooldown gates deliberately: those gates decide whether a wake is worth attempting, and none of them apply to an action that will never be serviced at all.
  • server/src/routes/issues.tsbacklog branch added to classifySourceRecoveryRevalidation, placed after the human-owner check so a human park keeps its more specific note. This retires the action at the moment of the park.
  • Both sweeps' status filters are now exported constants, and RECOVERY_SWEEP_COVERED_ISSUE_STATUSES is their union — so the invariant test asserts the real filters rather than a copy of them.
  • Latent bug fixed on the way: the wake path's activity log hardcoded status: "blocked"; it now logs the candidate's real status.

Why fold-plus-sweep, not fold alone

The issue offered three options. This takes option 3 (fold), but folded from a sweep as well as from the write path — not option 3 alone.

Option 3 as written only fires when something writes to the issue and trips classifySourceRecoveryRevalidation. The failure mode is precisely that nothing writes to it, so a write-triggered fold cannot heal the already-stuck population. AC #2 is also explicitly an invariant over sweep candidate queries, so a sweep has to be able to select backlog.

It just must not wake on it — that is what option 1 got wrong. backlog means "deliberately not dispatchable"; re-delivering an owner wake would contradict the status.

Verification

Red-first, per the acceptance criteria — each row was shown to fail against master's behavior before passing with the change:

run result
behavioral tests against master behavior (fold branch removed, filter back to blocked) 2 failedchecked: 0, backlogParkedResolved: 0, action stays active
coverage-union test with the constant reverted to ["blocked"] failedexpected [ 'backlog' ] to deeply equal []
issue-recovery-actions.test.ts (rebased on fc5354a6dc1483376) 80/80 passed
services/recovery/* + stranded-blocked-issue-reconciler 89/89 passed
heartbeat-process-recovery.test.ts 202/202 passed
tsc --noEmit clean

The second row matters: it proves the invariant test isn't vacuous — it names backlog as the uncovered status when the filter is master's.

The 80/80 is the load-bearing number after the rebase: 78 from this branch plus 2 from master's new describe("scheduler-side failure heartbeat for routine executions") block (BLO-21395), which landed in the same trailing region of the test file. Both sides of that conflict execute, which is what makes the resolution verified rather than merely non-conflicting.

An earlier run saw heartbeat-process-recovery.test.ts fail one test (a waitForValue(..., 8_000) poll that took 13,775 ms). It does not reproduce on the rebased tree — 202/202 above. It was load, not this diff.

Risks

  • Low risk, but a deliberate behavioral change: a recovery action on a backlog issue now resolves as cancelled instead of remaining active. That is the intended semantics — it makes the active set honest — but anything counting active recovery actions will see the number drop as the backlog population folds.
  • The fold is not a wake: no agent is dispatched for a backlog issue, so this cannot cause surprise runs or spend.
  • Branch-order dependent in two places, both covered by tests: the sweep fold must precede the owner/cause/cooldown gates, and the classifySourceRecoveryRevalidation backlog branch must follow the human-owner check.
  • No migration and no schema change — the fold is a write to existing rows through the normal resolution path.
  • File overlap with open PRs test(recovery): pin terminal dispatch race against reopen/reassign #1292 and fix(recovery): bound how long a lapsed monitor counts as a live wake path (BLO-24782) #1330 on recovery/service.ts means whichever lands second will need a rebase; the changes are in different functions, so no semantic conflict is expected.

Model Used

Claude Opus 5 (claude-opus-5), 1M context window, extended thinking enabled, with tool use and code execution via Claude Code.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — N/A, server-only change
  • I have updated relevant documentation to reflect my changes — the chosen backlog behavior is documented in a comment on the changed function, per the issue's third acceptance criterion
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending; this description rewrite is what unblocks the review gate
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — no Greptile review posted on this PR yet
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-25907
🔗 Paperclip issue: BLO-16074

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-25907
🔗 Paperclip issue: BLO-16074

@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

…-25907)

An assigned issue in `backlog` carrying an active recovery action was
selected by no sweep: `reconcileStrandedAssignedIssues` filters to
todo/in_progress/in_review and `reconcileStrandedRecoveryWakeBackstop`
filtered to `blocked`. The action stayed active and the issue stayed
silent — BLO-16074 itself sat that way for 27 days with
`updatedAt == createdAt`.

Widen the backstop to select `backlog`, but fold rather than wake it:
`backlog` means "deliberately not dispatchable", so re-delivering an
owner wake would contradict the park. Resolving the action keeps the
active set honest without claiming a backlog issue is schedulable.

Selecting it in the sweep rather than relying only on a write-time fold
in `classifySourceRecoveryRevalidation` is what heals rows already
parked — that classifier runs only when something writes to the issue,
and the failure mode is that nothing does. The classifier gets a
`backlog` branch too, so new parks retire at the moment of the park.

Both sweeps' status filters are now exported constants whose union is
asserted to cover every non-terminal `IssueStatus`, so adding a status
without routing it to a sweep fails the suite instead of silently
reopening the hole.

Co-Authored-By: Claude <noreply@anthropic.com>
@kkroo
kkroo force-pushed the cto/blo-25907-backlog-recovery-coverage branch from 251ff9e to dc14833 Compare August 14, 2026 14:30
@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please review at head dc1483376 (BLO-25907 / BLO-16074 gap 3).

This PR was opened 2026-08-12 and received no review on either surface, then went CONFLICTING on base drift. It has now been rebased onto fc5354a6 (31 commits) and the conflict resolved.

Review focus:

  1. Conflict resolution in server/src/__tests__/issue-recovery-actions.test.ts. Master added describe("scheduler-side failure heartbeat for routine executions") (BLO-21395) in the same trailing region where this PR adds describe("backlog recovery actions are folded, not stranded"). Both were kept. Suite is 80/80 (78 from this branch + 2 from master's block), so both sides execute — but please confirm the block nesting is what master intended.

  2. The fold branch placement in reconcileStrandedRecoveryWakeBackstop (services/recovery/service.ts): it sits ahead of the owner/cause/cooldown gates deliberately, since those gates decide whether a wake is worth attempting and none apply to an action that will never be serviced. Is that the right precedence?

  3. The coverage-union invariantRECOVERY_SWEEP_COVERED_ISSUE_STATUSES asserts every non-terminal IssueStatus is claimed by exactly one sweep. Red-first evidence is recorded in the issue: reverting the constant to ["blocked"] fails with expected [ 'backlog' ] to deeply equal [], so the test is not vacuous.

  4. classifySourceRecoveryRevalidation (routes/issues.ts): the backlog branch is placed after the human-owner check so a human park keeps its more specific note.

Verification on the rebased tree: issue-recovery-actions 80/80, services/recovery/* + stranded-blocked-issue-reconciler 89/89, tsc --noEmit clean.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 14, 2026
Merged via the queue into master with commit f45a3de Aug 14, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants