Skip to content

fix(recovery): stop the stranded-escalation ↔ blocked-reconciler oscillation (BLO-30743) - #1557

Merged
allyblockcast[bot] merged 3 commits into
masterfrom
blo-30743-two-drain-fixed-point
Aug 30, 2026
Merged

fix(recovery): stop the stranded-escalation ↔ blocked-reconciler oscillation (BLO-30743)#1557
allyblockcast[bot] merged 3 commits into
masterfrom
blo-30743-two-drain-fixed-point

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agent runs strand — a pod dies, a budget blocks, a provider quota empties — so the platform has recovery sweeps that decide what status a stranded issue is left in, and drains that later un-park those rows
  • Two of those mechanisms independently decide the fate of the same row: the stranded-escalation producer writes a status, and the BLO-21523 stranded-blocked reconciler drains blocked rows that have no blocker edge
  • They were written against predicates that read as equivalent but are not: the producer asks "is a recovery owner named", the reconciler asks "will anyone actually be woken". Those agree only while the recovery action is active
  • Once a wake horizon expires and the action goes escalated, they disagree permanently — and each sweep undoes the other every 15 minutes, re-minting the exact blocked-with-no-blocker signature BLO-27635 exists to eliminate, and re-firing a Slack escalation each time
  • This pull request makes both halves test the same property, by teaching the producer that a named-but-unwakeable owner is not a recovery path
  • The benefit is that the two drains reach a fixed point on the first tick: the oscillation stops, the notification spam stops, and BLO-27635's AC5 convergence signal can finally be read

Linked Issues or Issue Description

Duplicate search performed across stranded, oscillation, reconciler, escalated, BLO-30743 — no duplicate PR exists.

What Changed

  • server/src/services/recovery/stranded-escalation-status.ts — new required input isWakeExhaustedEscalation. A named recoveryOwnerAgentId now counts as a recovery path only when it can still be woken, so hasNoRecoveryPath becomes true for an escalated action and the module writes todo/in_review instead of blocked. isProviderQuotaWait, manual-repair causes, and real blocker edges are unaffected and still park.
  • server/src/services/recovery/service.ts — passes action.status === "escalated" into that decision.
  • server/src/services/recovery/service.ts — extends the unchanged-action short-circuit in ensureSourceScopedStrandedRecoveryAction (renamed unchangedOwnerlessunchangedWithoutWakeBudget, 4 internal sites) to also match a standing escalated action whose owner is unchanged. Stops attemptCount churn and stops re-emitting issue.escalation.needs_human_decision for an unchanged escalation state.
  • Module header corrected: it previously asserted the reconciler would skip these rows via active_recovery_action suppression. That assertion silently stops holding once the horizon expires, which is the whole defect; it is now documented with the reasoning and the two rejected alternatives.
  • Tests: 7 new producer cases; 3 new embedded-Postgres cases driving both drains over one row.

Verification

npx vitest run server/src/services/recovery/stranded-escalation-status.test.ts    # 18 passed
npx vitest run server/src/__tests__/stranded-blocked-issue-reconciler.test.ts     # 29 passed
npx vitest run server/src/__tests__/{stranded-run-recovery,issue-recovery-actions,recovery-expired-wake-horizon,recovery-observability,recovery-classifiers}.test.ts   # 216 passed
npx tsc --noEmit -p server/tsconfig.json                                          # clean

The new tests are load-bearing, verified by negative control. Reverting just the predicate (hasLiveRecoveryOwner back to Boolean(input.recoveryOwnerAgentId)) makes exactly the three wake-exhausted cases fail with AssertionError: expected 'blocked' to be 'todo' — i.e. they reproduce the live production defect rather than merely passing alongside it.

The fixed-point test drives the real reconciler against embedded Postgres and the producer decision over one row across three consecutive ticks, asserting one flip total and exactly one issue.stranded_blocked_reconciled activity row. A companion case asserts the inverse — a row with a real blocker edge stays blocked — so convergence is not bought by making everything dispatchable.

One pre-existing flake, disclosed rather than hidden: heartbeat-process-recovery.test.ts > reuses the raced stranded recovery action… fails when that file runs in parallel with two other suites. It passes standalone (225/225), and the same failure reproduces identically on unmodified master with the same file combination. It is a pre-existing parallelism flake in a file this PR does not touch.

Post-deploy signal: GET /api/companies/{companyId}/activity?action=issue.stranded_blocked_reconciled&limit=200 should show no entityId repeating across consecutive 15-minute ticks — today d9a7f1b1… (BLO-27999) appears in 20 of 20 — and that row's needs_human_decision count should stop advancing from its 208-per-10.3h pre-fix rate.

Risks

  • Behavioural shift, deliberate and scoped: rows with an escalated recovery action and no blocker edge are now left dispatchable instead of parked blocked. This is the intended outcome — such rows have no wake path, so blocked was a permanent strand — and it matches what the reconciler already does to them today (see fix(issues): stop an escalated recovery action pinning a blocked issue forever (BLO-21523) #1488 and the SB7E test that predates this PR). The population is exactly the rows currently oscillating.
  • The narrow hazard is the provider-quota park, which also has no owner but does have a live monitor. It is checked independently and unaffected; there is an explicit test that an exhausted-owner quota wait still parks. Flagged as the thing most worth a reviewer's attention.
  • No migration, no schema change, no API change. Behaviour reverts cleanly with the commit.
  • Not addressed here: the ~64 rows already carrying the signature drain via the existing reconciler once the producer stops re-minting; this PR does not backfill them.

Model Used

  • Claude Opus 4.5 (claude-opus-4-5), 1M context, extended thinking, 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, no UI surface
  • I have updated relevant documentation to reflect my changes — the module header, which carried the incorrect assertion
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

@allyblockcast

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-21523
🔗 Paperclip issue: BLO-27635
🔗 Paperclip issue: BLO-30743
🔗 Paperclip issue: BLO-27999
🔗 Paperclip issue: BLO-28618

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-21523
🔗 Paperclip issue: BLO-27635
🔗 Paperclip issue: BLO-30743
🔗 Paperclip issue: BLO-27999
🔗 Paperclip issue: BLO-28618

@allyblockcast

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

@ally please review at head f0753c4 — BLO-30743, the stranded-escalation ↔ blocked-reconciler oscillation.

Review focus, in priority order:

  1. Is the predicate change correct and complete? resolveStrandedEscalationStatus now treats a named owner on an escalated recovery action as no recovery path, because escalateExpiredWakeHorizons writes only status and leaves ownerAgentId populated. Is there any other shape where a named owner is unwakeable that this still misses — or, the dangerous direction, any shape where an escalated action does still have a live wake path and would now be wrongly made dispatchable?

  2. Is provider-quota still safe? I deliberately left isProviderQuotaWait as an independent live-path check, on the reasoning that its wake path is the post-commit monitor armed for returnOwnerAgentId, not an owner wake, so owner-wake exhaustion should not retire it. Please check that reasoning — flipping a quota park to todo would re-dispatch straight into an exhausted provider.

  3. Is the dedup gate too broad or too narrow? hasNoWakeBudgetToSpend now also matches a standing escalated action whose owner is unchanged. Too narrow and the needs_human_decision spam continues; too broad and a genuine reassignment or newly-changed cause stops escalating. I require both an unchanged cause/fingerprint and an unchanged owner — is that the right pair of keys, given the fingerprint's assignee segment churns during ownership transitions?

Context worth having: I chose to make the producer yield rather than widen the reconciler's suppression set, because BLO-21523's ["active"]-only asymmetry is load-bearing (widening it re-strands the 88-of-106 rows it was written to free), and I did not synthesise a blocker edge because BLO-28618/c9741f57a removed exactly that behaviour. If you think the other side should have yielded instead, say so — that is the one decision here I would most want a second opinion on.

@allyblockcast

allyblockcast Bot commented Aug 30, 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: ## 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

…cillation (BLO-30743)

Two drains fought over the same rows every 15 minutes: the stranded-blocked
reconciler flipped `blocked` -> `todo` on its tick, and `reconcileStrandedAssignedIssues`
re-escalated the row back to `blocked` within 1-4 minutes. Measured on BLO-27999:
458 activity events in 10.3h, including 208 `issue.escalation.needs_human_decision`
(each one a Slack forward), with the same entity in 20 of 20 consecutive ticks.
14 rows were oscillating estate-wide.

Root cause is a predicate disagreement, not a race. The producer
(`resolveStrandedEscalationStatus`) asked "is a recovery owner NAMED", while the
reconciler's suppression set — `BLOCKED_AUTO_RESUME_SUPPRESSING_RECOVERY_ACTION_STATUSES
= ["active"]` — encodes "will anyone actually be WOKEN". Those agree only while the
recovery action is `active`. Once `escalateExpiredWakeHorizons` retires an expired wake
horizon to `escalated`, the transition writes only `status`, so `ownerAgentId` stays
populated: the producer kept reading "owner resolved" and writing `blocked` with an
empty blocker set, while the reconciler correctly stopped suppressing and drained it.
Production confirms the shape — BLO-27999 carried an `escalated`
`stranded_assigned_issue` action at attemptCount 748 against maxAttempts 5, twelve days
past its `timeoutAt`, with the owner still named.

The producer yields, and the reconciler is untouched:

- `resolveStrandedEscalationStatus` takes `isWakeExhaustedEscalation` and counts a named
  owner as a recovery path only when it can still be woken. It now writes the very status
  the reconciler would flip the row to, so the two reach a fixed point on the first tick.
- `ensureSourceScopedStrandedRecoveryAction` extends its unchanged-action short-circuit
  (renamed `unchangedOwnerless` -> `unchangedWithoutWakeBudget`) to cover a standing
  `escalated` action with an unchanged owner, so an unchanged escalation state stops
  re-emitting `needs_human_decision` and stops incrementing attemptCount. A genuine
  reassignment or a changed cause/fingerprint still escalates normally.

Both alternatives were considered and rejected against existing constraints: this does
not synthesise a blocker edge (BLO-28618 / c9741f5 removed exactly that after 240 of
500 sampled rows were re-filed across 92 sources), and it does not widen the suppression
set to include `escalated` (BLO-21523's asymmetry is load-bearing — widening it would
re-strand the 88-of-106 rows that constant was written to free).

Tests: the producer's wake-exhausted cases fail on the previous predicate with
`expected 'blocked' to be 'todo'` (verified by reverting), and a new embedded-Postgres
case drives the real reconciler and the producer decision over one row across three
consecutive ticks, asserting one flip total and no return to `blocked` — plus the
inverse, that a row with a real blocker edge stays parked.

Co-Authored-By: Claude <noreply@anthropic.com>
@kkroo
kkroo force-pushed the blo-30743-two-drain-fixed-point branch from f0753c4 to fcced44 Compare August 30, 2026 20:01

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: fcced44

Critical Issues (0)

Important Issues (0)

Suggestions (1)

  • [native-codex] server/src/services/recovery/service.ts:5282 — consider a focused regression test for an escalated action whose owner changes while the cause and fingerprint remain otherwise unchanged, to lock in the intended re-escalation behavior.

Strengths

  • The producer now uses the same live-wake-path property as the blocked reconciler: an escalated action is treated as wake-exhausted, so it converges to todo instead of oscillating through blocked.
  • The provider-quota monitor-only path remains independent and continues to retain its monitor-backed blocked state.
  • The unchanged-action guard requires both unchanged cause/fingerprint and unchanged routed owner, so reassignment and cause changes are not silently swallowed.
  • The tests cover convergence, activity deduplication, blocker preservation, provider-quota safety, manual-repair behavior, review preservation, and fresh-owner recovery.

Recommended Action

  1. Consider the Suggestion opportunistically.

…hange arm (BLO-30743)

Addresses Ally's review suggestion on #1557: lock in that an `escalated` action whose
OWNER changes, with cause and fingerprint otherwise unchanged, still re-escalates rather
than being deduplicated.

The predicate was an inline boolean inside `ensureSourceScopedStrandedRecoveryAction`,
which is only reachable through the full recovery-service harness. Extracted it to
`shouldReuseStrandedRecoveryAction` next to `resolveStrandedEscalationStatus` — the module
that exists precisely to isolate these decisions for testing — so the behaviour can be
pinned directly. No behaviour change; the extracted logic is the same expression.

The owner-equality clause is dangerous in both directions and now has coverage for each:
too broad swallows a genuine reassignment, leaving the new owner never woken and the action
undischargeable (the BLO-18996 deadlock); too narrow lets the Slack-forwarded
`needs_human_decision` spam continue. Eight cases pin the boundary, including the
load-bearing negative that an ACTIVE owned action is never reused because it still has
wake budget to spend.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

Thanks — took the suggestion. Commit 0166269ad.

The predicate was an inline boolean inside ensureSourceScopedStrandedRecoveryAction, only reachable through the full recovery-service harness, so I extracted it to shouldReuseStrandedRecoveryAction beside resolveStrandedEscalationStatus — the module that exists precisely to isolate these decisions for testing. Same expression, no behaviour change, now directly pinnable.

Eight cases cover the boundary, including the one you named (escalated + owner changed + cause/fingerprint unchanged → re-escalates, not deduplicated) and its two neighbours (gains an owner it lacked; loses the owner it had). I also added the load-bearing negative in the other direction — an ACTIVE owned action is never reused, because it still has wake budget to spend — since that is what stops this gate silencing live recovery instead of dead recovery.

Full re-verification on the new head: 26 producer cases, 226 across the four affected suites, tsc --noEmit clean.

…us one (BLO-30743)

The barrel already publishes `resolveStrandedEscalationStatus` with the rationale that
'a drain and a producer that disagree on what "no recovery path" means is how the
`blocked`-with-no-blocker backlog accumulated in the first place' — which is exactly the
defect BLO-30743 turned out to be. `shouldReuseStrandedRecoveryAction` encodes the same
wake-liveness property for the re-escalation side, so it belongs on the same surface and
is available to the BLO-27553 reconciler work that comment anticipates.

Co-Authored-By: Claude <noreply@anthropic.com>

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 27a1022

Critical Issues (0)

Important Issues (0)

Suggestions (1)

  • [native-codex] server/src/services/recovery/service.ts:5282 — the owner-change regression case is now covered by the focused predicate tests; retain that coverage if the reuse logic is refactored.

Strengths

  • The producer now treats a named owner on an escalated action as wake-exhausted, matching the reconciler's active-only suppression boundary.
  • Provider-quota monitor-only waits remain independently parked, avoiding immediate redispatch into exhausted provider capacity.
  • Reuse requires unchanged cause/fingerprint and unchanged routed owner, while owner changes take the normal upsert and wake path.
  • The added tests cover fixed-point convergence, blocker preservation, provider-quota safety, manual-repair behavior, review preservation, and owner reassignment.

Recommended Action

  1. Consider the Suggestion opportunistically.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 30, 2026
Merged via the queue into master with commit 385864d Aug 30, 2026
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