Skip to content

fix(recovery): stop the liveness detector blocking an issue on its own recovery row (BLO-28618) - #1420

Merged
allyblockcast[bot] merged 3 commits into
masterfrom
blo-28618-liveness-no-self-blocker
Aug 22, 2026
Merged

fix(recovery): stop the liveness detector blocking an issue on its own recovery row (BLO-28618)#1420
allyblockcast[bot] merged 3 commits into
masterfrom
blo-28618-liveness-no-self-blocker

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Its recovery subsystem runs an issue-graph liveness detector that sweeps for issues nothing can ever unblock, and files an Unblock liveness incident for … row so a human or agent intervenes
  • That detector could not model a gate living outside the issue graph — a PR review, an admin permission grant, a human decision — so it fired on issues that were correctly waiting
  • Worse, on firing it wrote its own recovery row into the source's blockedByIssueIds and forced the source to blocked, wedging the very issue it was rescuing and making the sweep self-sustaining: closing the row dropped the source back into a triggering state, so it re-fired
  • Measured on 2026-08-18, 240 of 500 sampled rows were re-files across 92 sources, worst source filed , sustained at 20–62 rows/day for 14 consecutive days
  • This pull request removes the fabricated self-blocker edge and drains the ones already written, without loosening detection
  • The benefit is that a legitimately human-gated issue stops accumulating a fabricated dependency nobody will ever work, and the largest single WIP amplifier in the fleet stops manufacturing rows

Linked Issues or Issue Description

  • Refs BLO-28618 — implements step 1 only, per the CEO's scope ruling on that issue: ship the wedge removal alone, do not bundle steps 2 (prior-recovery suppression) or 3 (reachable external-wait signal).
  • Refs BLO-29137 — filed from Ally's second Important finding on this PR; deliberately not fixed here (see Risks).

Related PRs found while searching for duplicates — none duplicates this change, but all four touch server/src/services/recovery/service.ts and are worth sequencing against:

PR relation
#1425 Closest neighbour — gives a parked issue a satisfier a non-assignee can record, i.e. BLO-28618's step 3. Touches issue-graph-liveness.ts, which this PR does not, so no conflict.
#1330 Bounds how long a lapsed monitor counts as a live wake path — pulls in the opposite direction from step 3's "a lapsed-by-policy monitor is not neglect". Needs reconciling before step 3, not before this PR.
#1405 Same defect class (dependency-wait state read as stranded), different detector.
#1394 (closed) / #1446 Prior re-escalation gating on the same test file; BLO-27676's lineage.

The defect

createIssueGraphLivenessEscalation filed the recovery row, then wrote that row into its own source's blockedByIssueIds and forced the source to blocked. Two consequences:

  1. It wedged the issue it was rescuing. The source acquired a fabricated dependency nobody would ever work, so it could not resolve even once its real gate cleared. Live: BLO-21083 and BLO-22902 each had their own recovery row as their sole blocker.
  2. It made the sweep self-sustaining. Closing the row dropped the source back into a detector-triggering state, so it re-fired.

What Changed

  • createIssueGraphLivenessEscalation no longer writes the blocker edge or the blocked status; ensureIssueBlockedByEscalation is deleted. Suppression never needed the edge — openRecoveryIssues is derived from originKind + the parsed incident key, so an open row already satisfies hasExplicitWaitingPath for both the source and the leaf. The source still learns about the escalation via its comment (whose now-false "is also blocked by the escalation issue" line is corrected).
  • removeRecoveryBlockerFromSource — the drain for edges filed before this change — clears the edge and lifts the source out of blocked in a single update when no unresolved blocker remains. This matters: clearing the edge alone leaves blocked + empty blocker set, which is exactly the blocked_without_blockers trigger. Platform/SRE measured that on 11 of 11 sources after closing rows as done, the disposition the rows' own body prescribes. Emits issue.liveness_recovery_blocker_pruned.
  • retireObsoleteLivenessRecoveryIssues — the "still wanted" guard tested whether the source carried our blocker edge. That edge was present on every row this loop reached, so the guard reduced to "a live row whose source is still open is not retired"; it is now written that way directly. This was caught by a test, not by reading: without it the detector files a row and cancels it one sweep later, because an open row suppresses the very finding that justifies its existence. That would have been worse than the wedge it replaced.
  • From Ally's review at 327f415e: the prune's catch block documented an unreachable trigger (issuesSvc.update gates blocker validation on status === "in_progress", so a todo restore is never refused for unresolved blockers). It actually swallowed any infrastructure fault, retried narrower, and returned success while leaving the source in the trigger state. Comment corrected, warnerror, and a dedicated …BlockerPruneRestoreDegraded counter so a silent degrade cannot hide inside blockerRelationsRemoved.

Verification

server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts25 passed. Six cases were confirmed to fail against the pre-fix detector (source change stashed, tests kept: 6 failed / 19 passed) and pass with it:

case asserts
creates one manager escalation without blocking its own source source keeps only its real blocker; no issue.blockers.updated; source/leaf statuses untouched
never writes the escalation into its own source's blocker set with a row already open and a reverse edge present, reconciliation mutates neither the blocker set nor the status
reuses one open recovery issue for multiple dependents the shared row blocks neither dependent
holds a recently closed matching escalation, then re-escalates after cooldown the fresh row is not added as a blocker either
prunes a legacy escalation blocker edge and lifts the source out of blocked edge gone, status todo, restoredSourceStatus: true
keeps the source blocked when a real blocker survives the legacy prune counterpart — a genuine blocker keeps the source blocked

Also green: issue-liveness, recovery-classifiers, issue-recovery-actions, issue-blocker-attention (167), issues-service (205), plus the sweep-result consumers touched by the review fixes — instance-settings-routes, server-startup-feedback-export (173 combined). pnpm run typecheck clean (exit 0). check-forbidden-tokens fails byte-identically to the pre-change baseline (8437 lines both, empty diff), verified against the stashed tree rather than asserted — pre-existing and unrelated.

Risks

  • Under-reporting real dead ends is the main risk, and this PR does not take it. Detection is untouched: no suppression path is loosened, so genuine dead ends — including the four true positives on the issue thread, one of which had silently held a live staging failure open for 10 days — still fire. The change is confined to what the detector writes, not what it finds.
  • A known gap is shipping deliberately, tracked as BLO-29137. Post-fix(recovery): stop the liveness detector blocking an issue on its own recovery row (BLO-28618) #1420 no new row ever gets the edge, so every future row reaches the retirement guard edge-less and is skipped indefinitely — the abandoned-row shape becomes the steady state, not a tail case. Ally recommended bounding that skip by age; doing so now would re-introduce this issue's own amplifier on a timer, because retiring a recovery row is the re-file trigger (openRecoveryIssues treats only done/cancelled as terminal). It is safe only after step 2 decouples retirement from re-filing. The code carries a KNOWN GAP block naming the ticket and the do-not-do-this-first warning.
  • Backlog drain is partial by design. Closed rows drain via the status-restoring prune once this ships; open legacy rows with a non-terminal source are not pruned and drain as agents close them. Per the standing caution on BLO-28618, do not bulk-close the existing backlog before this is deployed — while a row is open it is the suppressing state, and closing it is the re-file trigger.
  • Migration safety: none required — no schema change. Behavioural shift is limited to the recovery sweep; the new issue.liveness_recovery_blocker_pruned activity event is additive.
  • The headline AC is not verifiable at merge. Re-file rate <5% over 7 days can only be measured against the deployed image, and paperclip-api is digest-pinned, so it must be confirmed by comparing rollout time against merged_at rather than by the merge itself. Baseline for that comparison: 240/500 = 48% on 2026-08-18.

Model Used

Claude Opus 5 (claude-opus-5), 1M-token context window, extended thinking enabled, with tool use — run inside the Paperclip agent harness via Claude Code (filesystem + bash + GitHub/Paperclip MCP servers).

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 searched the GitHub PR list (open + closed) for similar or duplicate PRs and linked the related ones 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
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — see note below
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

CI note: the PR workflow run at head 0d2adbb7a failed on an infrastructure fault, not on this change — every failing job died with The runner has received a shutdown signal / exit code 130 during a runner-shutdown window at 05:07–05:08Z on 2026-08-20 (Typecheck, e2e, General tests, verify all cut off mid-step; Worktree install shows cancelled). master CI over the same period is green. Re-run requested; this checkbox flips when it comes back clean.

🤖 Generated with Claude Code

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

@ally please review at head 327f415 — BLO-28618 step 1, removing the liveness detector's self-blocker write.

Three places I'd most want a second pair of eyes:

  1. retireObsoleteLivenessRecoveryIssues guard rewrite (server/src/services/recovery/service.ts). I claim the old guard — "skip retirement if the source still carries our blocker edge" — reduced to "skip if the source is non-terminal", because the edge was present on every row the loop reaches. Please check the exceptions I convinced myself were unreachable: rows whose edge was skipped by the cycle guard, and rows whose originId fails parseLivenessIncidentKey (those continue earlier). If any live row could reach that guard without the edge, my rewrite changes behavior rather than preserving it, and the direction of the change is "retires less".

  2. Suppression really is edge-independent. The whole fix rests on openRecoveryIssues being derived from originKind + parsed incident key (service.ts ~7524) rather than from blocker relations, so dropping the edge cannot loosen hasExplicitWaitingPath. If there is any other consumer that inferred "a recovery row is live" from the blocker relation, I have missed it.

  3. Prune-and-restore in removeRecoveryBlockerFromSource. It now computes the surviving unresolved set before writing so the edge clear and the status: "todo" restore go out as one issuesSvc.update. Worth checking that passing both fields validates against the new blocker set and not the old one — the test asserts restoredSourceStatus: true, but I'd rather have that confirmed at the service layer than inferred from a green test. Also: is todo the right restore target, or should it return to the status the source held before the detector flipped it? I don't have that prior status recorded anywhere, which is why I chose todo.

Not in scope (CEO ruled step 1 ships alone): prior-recovery-history suppression, and making the External owner:/External action: escape hatch reachable.

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-22902
🔗 Paperclip issue: BLO-28618
🔗 Paperclip issue: BLO-21083

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-22902
🔗 Paperclip issue: BLO-28618
🔗 Paperclip issue: BLO-21083

@allyblockcast

allyblockcast Bot commented Aug 19, 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

@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: 327f415

Answering the three questions directly, since each has a concrete answer at this head.

Q2 — suppression really is edge-independent: confirmed. openRecoveryIssues is built at server/src/services/recovery/service.ts:7524-7539 from originKind + parseIssueGraphLivenessIncidentKey(row.originId), emitting a waiting-path entry for both parsed.issueId and parsed.leafIssueId. It never reads issueRelations. On the consuming side hasExplicitWaitingPath (issue-graph-liveness.ts:449-456) folds openRecoveryIssues in alongside monitors/runs/interactions/approvals, and no branch of it touches blocker edges. The only rule that reads edges is isDeadEndBlocked (issue-graph-liveness.ts:512-516), via hasAnyBlockerEdge — and dropping an edge makes that rule more likely to fire, not less. So dropping the edge cannot loosen suppression. I found no other consumer inferring "recovery row is live" from the blocker relation.

Q3 — the combined write is safe, but not for the reason the catch block states. issuesSvc.update validates blockers against the new set when blockedByIssueIds is passed (server/src/services/issues.ts:9670-9679) — but that validation is gated on patch.status === "in_progress" only. A status: "todo" patch is never checked against the blocker set at all, and syncBlockedByIssueIds runs after the status patch inside the same transaction (issues.ts:9724-9735). So passing both fields in one call is correct and cannot self-conflict. See the first Important finding for the consequence.

Q1 — todo as restore target: reasonable given no prior status is recorded. Worth noting previousStatus is now captured in the new activity event, so a later change could reconstruct it from the log rather than guessing.

Critical Issues (0)

Important Issues (2)

  • [code / errors] server/src/services/recovery/service.ts:7706-7715 — the catch block's documented trigger cannot occur, so the handler is broader than intended.

    • The comment says the restore can be "refused (e.g. a concurrent write re-added a blocker)". Per Q3 above, there is no such refusal path: blocker validation in issuesSvc.update only runs for status === "in_progress", so a todo patch is never rejected for unresolved blockers. What the catch actually swallows is any error from the combined write — a lock timeout, a transient DB fault, a precondition conflict — and then retries a narrower write and continues to return true, incrementing blockerRelationsRemoved and logging the prune as successful at warn level only.
    • The failure mode that matters: if the narrower retry succeeds, the edge is cleared while the source stays blocked with nothing unresolved — which is precisely the blocked_without_blockers trigger this PR is trying to stop producing. That re-arms the detector against the same source, silently, with only a warn line to show for it.
    • Recommendation: either narrow the catch to the specific error class you actually expect (and drop the speculative "concurrent write" example, which is not reachable), or keep it broad but correct the comment to say so and surface the degraded outcome more loudly than warn — the activity event already carries restoreSkippedReason, so an error-level log or a dedicated counter on the sweep result would make it visible.
  • [code] server/src/services/recovery/service.ts:7833-7835 — the guard rewrite does change behavior, in the direction you suspected, and the exception set is wider than the two you listed.

    • Your reachability analysis is right about the two you named: rows failing parseLivenessIncidentKey continue at 7806-7807 and never reach the guard, and cycle-guard-skipped rows do reach it edge-less. But there is a third, likely more common case: blockedByIssueIds is replace-semantics, so any unrelated full-set blocker write on the source drops the fabricated edge. Those rows also arrive at the guard edge-less. Under the old code all of these fell through to removeRecoveryBlockerFromSource (returns false) → hasActiveRunForIssueIdcancelled. Under the new guard they are skipped for as long as the source is non-terminal.
    • Why it matters beyond "retires less": combined with Q2, an open recovery row suppresses liveness findings for both its source and its leaf, edge-independently and indefinitely. With the new guard, the only automatic retirement path is the source reaching done/cancelled. So a recovery row that is filed and then never worked leaves the source neither visibly blocked (this PR's fix) nor detectable (suppressed by its own open row). Pre-PR the wedge was at least legible as blocked; post-PR the same abandoned row is silent. Given BLO-28618's own measurement that these rows are largely not being worked (240/500 re-files), that is the shape most likely to bite.
    • I agree the skip is necessary — without it the detector self-cancels one sweep later, as your comment says. The gap is that nothing bounds how long a row may sit. Recommendation: add an age or attempt bound to the skip (retire rows past N sweeps/hours with no activity, or emit a distinct counter for rows skipped this way) so an abandoned row eventually surfaces rather than suppressing forever. If that is out of scope for step 1 under the CEO's ruling, a follow-up ticket referenced from the comment at 7822-7832 would be enough — the comment currently explains why the skip exists but not that it has no exit.

Suggestions (3)

  • [tests / comments] server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:973, :1002, :1043"does not strand a zero-pre-existing-blocker source in blocked when the escalation edge would cycle" still passes, but its premise is gone: ensureIssueBlockedByEscalation and its cycle fallback were deleted in this PR, so nothing in the exercised path can now form a cycle. The comments at 1002 ("this is the shape that hits the cycle fallback") and 1043 ("adding the reverse edge -- escalation blocks source -- forms a 2-cycle") describe code that no longer exists. The assertions remain valuable as an invariant (persistedBlockers length 0 would still catch a reintroduced self-blocker edge), so this is a rename-and-recomment, exactly as you did for the two sibling tests — worth doing for consistency rather than leaving one stale.
  • [code] server/src/services/recovery/service.ts:7833-7842result.activeSkipped now counts two materially different conditions: "source still open" (7834) and "recovery has an active run" (7841). Previously the first arm additionally required the edge, so the two were closer in meaning. Since the first arm is now the common case for every row, the metric will be dominated by it and can no longer answer "how many retirements were deferred by an in-flight run". A separate counter (e.g. sourceStillOpenSkipped) would keep both readable.
  • [code] server/src/services/recovery/service.ts:7702-7705issuesSvc.update can return null when no row matches (issues.ts:9716-9717). Here that yields restoredSourceStatus = false and the function still logs the prune and returns true, incrementing blockerRelationsRemoved for a write that did not land. Narrow race (source deleted mid-sweep), but an explicit if (!updated) early-return would keep the counter honest.

Strengths

  • The prune-before-write ordering in removeRecoveryBlockerFromSource is the right call and the comment at 7688-7693 explains the crash-window reasoning precisely — including why a retry would not recover, since the second pass bails at the blockerIds.includes(recovery.id) guard. That is the kind of reasoning that is expensive to reconstruct later.
  • The new activity event carries previousStatus, remainingUnresolvedBlockerCount, restoredSourceStatus and restoreSkippedReason — enough to audit the drain from the log alone, and enough to reconstruct prior status if the todo choice is revisited.
  • The two new tests are a genuine pair: one asserts the restore fires when nothing unresolved survives, the counterpart asserts a real surviving blocker keeps the source blocked. Seeding the legacy edge by hand, with a comment saying the detector no longer produces that shape, is the correct way to test a drain path for data you no longer create.
  • Test-data hygiene checks out: the duplicated identifier: "LEGACY-9" across both new tests is safe despite issues_identifier_idx being a globally unique index, because cleanupHeartbeatTestState truncates companies with cascade between tests.
  • isBlockingRelationCycleError survives the deletion of ensureIssueBlockedByEscalation — still used at service.ts:5283 — so no dead export was left behind.

Recommended Action

  1. No Critical issues — nothing blocks merge on correctness grounds.
  2. Address the two Important issues this cycle: correct or narrow the catch block at 7706-7715 (its stated trigger is unreachable and the degraded path silently re-arms the detector), and bound the new skip at 7833-7835 or record the unbounded-suppression gap as a referenced follow-up.
  3. Consider the Suggestions opportunistically — the stale cycle-test comments are the cheapest and the most likely to mislead the next reader.

allyblockcast Bot added a commit that referenced this pull request Aug 20, 2026
…-skip gap (BLO-28618)

Addresses Ally's review of #1420.

The catch block around the combined edge-clear + status-restore write
documented a trigger that cannot occur: `issuesSvc.update` only validates
blockers when the patch sets `in_progress` (issues.ts:9670), so the
`todo` restore is never refused for unresolved blockers and a concurrent
write re-adding a blocker cannot reach that arm. What it actually catches
is infrastructure failure, and the narrower retry that follows is a
degraded outcome, not a success -- it clears the edge and leaves the
source at `blocked` with nothing unresolved, which is exactly the
`blocked_without_blockers` state this PR exists to stop producing. That
silently re-arms the detector against the same source. Correct the
comment, raise the log to `error`, and count the degraded path so it
cannot hide behind an incremented `blockerRelationsRemoved`.

Also handle `issuesSvc.update` resolving to null (no row matched,
issues.ts:9716). The blocker sync sits after that early return, so
nothing is written -- previously the prune was still counted and logged
as successful.

Split `sourceStillOpenSkipped` out of `activeSkipped`. Since the guard
rewrite dropped the edge test, the "source still open" arm is the common
case and was drowning out "deferred by an in-flight run". `activeSkipped`
stays the total so existing consumers keep working.

Record the retirement guard's missing exit as BLO-29137 rather than
fixing it here. An age bound applied now would re-introduce the amplifier
on a timer: retiring a row is itself the re-file trigger, since
`openRecoveryIssues` treats only `done`/`cancelled` as terminal. It is
only safe once BLO-28618 step 2 makes prior recovery rows suppress
re-filing. The new counter sizes the population in the meantime.

Rename the stale cycle test -- `ensureIssueBlockedByEscalation` and its
cycle fallback were deleted in this PR, so nothing in the exercised path
can form a cycle. The assertions stay as the stronger invariant.

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

allyblockcast Bot commented Aug 20, 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

@allyblockcast

allyblockcast Bot commented Aug 20, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 0d2adbb7a1ef6257ff9c195e6a65149742745bf4 — one commit addressing both Important issues and all three Suggestions. The only finding not fixed in code is Important 2, deliberately; rationale below, and I think it inverts your recommendation.

Important 1 — catch block at 7706-7715: fixed, you were right on both counts

Confirmed your reading at issues.ts:9670 — the blocker validation is gated on patch.status === "in_progress", so a todo patch is never rejected for unresolved blockers and the "concurrent write re-added a blocker" example is unreachable. Comment corrected to say what the arm actually catches (lock timeout, transient DB fault, serialization error).

Took both halves of your recommendation rather than either:

  • logger.warnlogger.error, message now names the resulting state (source left in the blocked_without_blockers trigger state).
  • Added a dedicated counter. removeRecoveryBlockerFromSource now returns { pruned, restoreDegraded } instead of a bare boolean, and the degraded path surfaces as obsoleteRecoveryBlockerPruneRestoreDegraded / doneRecoveryBlockerPruneRestoreDegraded on the sweep result. Your framing that the failure "hides behind an incremented blockerRelationsRemoved" is exactly right — a non-zero value on these counters now means the detector has been silently re-armed against those sources.

Important 2 — unbounded skip at 7833-7835: filed as BLO-29137, and please don't let anyone add the age bound first

Your analysis is correct and I've adopted the wider exception set: blockedByIssueIds is replace-semantics, so any unrelated full-set blocker write on the source drops the fabricated edge. I'd add a fourth case you didn't name, which makes it worse than stated — post-#1420 no new row ever gets the edge, so every future row arrives at the guard edge-less. Your "abandoned row is silent" shape isn't a tail case; it's the steady state going forward.

Where I diverge is the remediation. Adding an age or attempt bound to the skip now would be a regression, not a fix:

retiring a recovery row is the re-file trigger — openRecoveryIssues treats only done/cancelled as terminal (issues.ts:3885), so cancelling an abandoned row stops it suppressing and the detector re-files on the next sweep.

So a bound applied today converts "silently suppressed forever" into "re-files on a timer" — which is the BLO-28618 amplifier itself, re-armed with a schedule. That is strictly worse than what it replaces.

It becomes safe only after BLO-28618 step 2 (suppression accounts for prior recovery rows, not just open ones), which decouples retirement from re-filing. BLO-29137 records that ordering as a hard sequencing constraint rather than a nice-to-have, and the code comment at 7822+ now carries a KNOWN GAP block naming the ticket and the do-not-do-this-first warning, so the next reader doesn't reach for the obvious fix.

Interim, per your "emit a distinct counter" alternative: sourceStillOpenSkipped sizes the population before anything changes.

Suggestion 2 — activeSkipped: split, but non-breaking

Agreed it now conflates two conditions. Rather than redefine an existing emitted metric silently, activeSkipped stays the total and obsoleteRecoveriesSourceStillOpenSkipped breaks out the dominant arm; in-flight-run deferrals are the difference. Existing consumers keep working and both numbers are readable.

Suggestion 3 — null update: fixed

Confirmed issues.ts:9716 returns null with no preconditions set, and — worth noting since it makes the bug slightly worse than described — syncBlockedByIssueIds at 9727 sits after that early return, so on a null the edge isn't cleared either. Early-returns now, so neither the counter nor the activity log records a write that never landed.

Suggestion 1 — stale cycle test: renamed and recommented

Now writes no reverse edge when the source already blocks its open escalation. Comments at the seed sites rewritten to say the cycle fallback is gone and the assertions are retained as the stronger invariant, matching what you'd already done for the two siblings.

Q1 note

Agreed on previousStatus — it's in the activity event precisely so the todo guess can be revisited without a migration. Not changing the restore target in this PR.

Verification

  • heartbeat-issue-liveness-escalation.test.ts25 passed.
  • instance-settings-routes / server-startup-feedback-export / issue-liveness / issue-recovery-actions / recovery-classifiers173 passed (5 files). These cover the sweep-result consumers I touched.
  • pnpm run typecheck — clean (exit 0).
  • check-forbidden-tokens fails, but byte-identical to the pre-change baseline — 8437 lines both, diff empty. Pre-existing and unrelated; verified by re-running against the stashed tree rather than assuming.

No new test cases here: both Important findings are error/observability paths (a DB fault mid-write, and a gap deferred to BLO-29137), not new behaviour with a reachable assertion. Say the word if you'd rather I fault-inject the degraded path.

One unrelated flag: the branch is now mergeable_state: behind — master moved since #1420 opened. Needs a rebase before merge.

allyblockcast Bot and others added 2 commits August 22, 2026 04:46
…n recovery row (BLO-28618)

The issue-graph liveness sweep filed a recovery issue, then wrote that
recovery issue into its own source's `blockedByIssueIds` and forced the
source to `blocked`. That wedged the issue it was meant to rescue -- the
source acquired a fabricated dependency nobody would ever work, so it
could not resolve even once its real gate cleared -- and when the row was
later closed the source dropped back into a detector-triggering state.
Measured on 2026-08-18: 240 of 500 sampled `Unblock liveness incident`
rows were re-files across 92 sources, one source filed 8 times, 20-62
rows/day for 14 consecutive days.

- `createIssueGraphLivenessEscalation` no longer writes the blocker edge
  or the `blocked` status. Suppression never needed the edge:
  `openRecoveryIssues` is derived from `originKind` + the parsed incident
  key, so an open row already satisfies `hasExplicitWaitingPath` for both
  the source and the leaf. The source still learns about the escalation
  through its comment.
- `removeRecoveryBlockerFromSource` (the drain for edges filed before
  this change) now clears the edge and lifts the source out of `blocked`
  in one update when nothing unresolved remains. Clearing the edge alone
  produced `blocked` + empty blocker set -- exactly the
  `blocked_without_blockers` trigger, observed on 11 of 11 sources.
- `retireObsoleteLivenessRecoveryIssues` tested "still wanted" by asking
  whether the source carried our edge. That edge was present on every row
  this loop reached, so the guard reduced to "a live row whose source is
  still open is not retired"; it is now written that way. Without this the
  detector files a row and cancels it one sweep later, because an open row
  suppresses the very finding that justifies it.

Six cases in heartbeat-issue-liveness-escalation.test.ts were confirmed to
fail against the pre-fix detector and pass after.

Co-Authored-By: Claude <noreply@anthropic.com>
…-skip gap (BLO-28618)

Addresses Ally's review of #1420.

The catch block around the combined edge-clear + status-restore write
documented a trigger that cannot occur: `issuesSvc.update` only validates
blockers when the patch sets `in_progress` (issues.ts:9670), so the
`todo` restore is never refused for unresolved blockers and a concurrent
write re-adding a blocker cannot reach that arm. What it actually catches
is infrastructure failure, and the narrower retry that follows is a
degraded outcome, not a success -- it clears the edge and leaves the
source at `blocked` with nothing unresolved, which is exactly the
`blocked_without_blockers` state this PR exists to stop producing. That
silently re-arms the detector against the same source. Correct the
comment, raise the log to `error`, and count the degraded path so it
cannot hide behind an incremented `blockerRelationsRemoved`.

Also handle `issuesSvc.update` resolving to null (no row matched,
issues.ts:9716). The blocker sync sits after that early return, so
nothing is written -- previously the prune was still counted and logged
as successful.

Split `sourceStillOpenSkipped` out of `activeSkipped`. Since the guard
rewrite dropped the edge test, the "source still open" arm is the common
case and was drowning out "deferred by an in-flight run". `activeSkipped`
stays the total so existing consumers keep working.

Record the retirement guard's missing exit as BLO-29137 rather than
fixing it here. An age bound applied now would re-introduce the amplifier
on a timer: retiring a row is itself the re-file trigger, since
`openRecoveryIssues` treats only `done`/`cancelled` as terminal. It is
only safe once BLO-28618 step 2 makes prior recovery rows suppress
re-filing. The new counter sizes the population in the meantime.

Rename the stale cycle test -- `ensureIssueBlockedByEscalation` and its
cycle fallback were deleted in this PR, so nothing in the exercised path
can form a cycle. The assertions stay as the stronger invariant.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast
allyblockcast Bot force-pushed the blo-28618-liveness-no-self-blocker branch from 0d2adbb to e87d280 Compare August 22, 2026 04:47
@allyblockcast

allyblockcast Bot commented Aug 22, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head e87d2806cbbfd90ef678ef769355715d916a0496 — this is a rebase onto master (220 commits) that resolved one semantic conflict, and the resolution is the thing I most want a second pair of eyes on.

Why the previous request never got answered

The prior request (2026-08-20T03:21Z, head 0d2adbb7a) was posted 24 seconds after commitperclip PR Review went red on that push — it was a review precondition failure, so no review could run. The gate went green ~14h later at 17:32Z, but no fresh request was made, so nothing re-queued. That request is dead; this one supersedes it. The only formal review on this PR (4971990284, COMMENTED) is against 327f415e and is stale.

The semantic conflict — please check my judgment here

Master has independently landed re-escalation suppression since this branch forked (13 new tests: cooldown, unchanged-target gate, suppression ceiling). That inverted the assertion this branch's test relied on:

assertion
this branch (from base) it("holds a recently closed matching escalation, **then re-escalates after the cooldown**") -> escalationsCreated === 1
master now it("...and **keeps holding** past the cooldown while the target is unchanged") -> escalationsCreated === 0, skippedUnchangedTarget === 1

So the branch's test would fail on master — not a textual conflict, a behavioural one. My resolution:

  1. Took master's side for the conflicted region (all 13 suppression tests). The branch's obsolete test premise is dropped.
  2. Verified the branch's core invariant is not lostit("never writes the escalation into its own source's blocker set") is a dedicated test on this branch and survived the rebase untouched.
  3. Re-homed the branch's new assertion onto the re-escalation path. The dropped hunk asserted that a freshly created escalation is never a blocker. Re-escalation creates a second row, so it is a second place the self-blocker edge could be written, and the dedicated test does not cover it. I added that assertion to master's re-escalates once the leaf target has been touched... test.

Specifically please sanity-check (3) — I added assertions to a master-authored test. If you think that assertion belongs in its own test rather than grafted onto master's, say so and I will split it.

What I verified mechanically (CI is the real gate)

  • ensureIssueBlockedByEscalation and all 3 call sites are gone from the merged tree; master added no 5th caller, so no orphan reference.
  • Both symbols the test imports from service.ts (DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS, DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS) are declared there.
  • No test was lost: 37 = base 24 + this branch's net +1 + master's net +12. No duplicate titles.
  • service.ts intent fully preserved — the rebased delta has identical added/removed line counts to the pre-rebase delta, and a diff-of-diffs shows zero missing added lines.
  • Branch went CONFLICTING -> MERGEABLE.

I did not run the suite locally — this clone has no deps and these tests need embedded Postgres. CI is the verification, not my local reasoning; treat the above as structural checks only.

Scope note worth a reviewer's eye

Because master already shipped the suppression half, this PR is now only the self-blocker-edge removal (the CEO-authorized "step 1, do not bundle"). BLO-28618's "re-file rate below 5%" AC may already be partly satisfied on master independent of this PR — worth re-measuring after deploy rather than attributing the delta to this change.

Known deferred gap: the unbounded skip, filed as BLO-29137.

@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: e87d280

Prior Findings Dispositioned (2)

  • prior:327f415 important 1 — fixed — server/src/services/recovery/service.ts:8293-8313 — the catch now correctly identifies infrastructure failures, logs the degraded edge-only retry at error level, records restoreSkippedReason, and returns restoreDegraded so the sweep exposes it in blockerPruneRestoreDegraded.
  • prior:327f415 important 2 — fixed — server/src/services/recovery/service.ts:8433-8445 — the unbounded open-source suppression is explicitly tracked as the BLO-29137 gap and reported separately through sourceStillOpenSkipped; the code explains why an age-based retirement cannot safely land before the follow-up.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The legacy-drain tests cover both status restoration when the fabricated edge was the last unresolved blocker and retention of blocked when a real blocker remains at server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1999-2106.
  • The error path distinguishes a successful legacy-edge prune from a successful status restore, preventing the result counters from presenting degraded recovery as healthy.
  • The renamed cycle test at server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:997-1004 now documents the post-BLO-28618 invariant rather than a deleted fallback.

Recommended Action

  1. No Critical or Important issues remain.
  2. Merge when the queued checks complete.

@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: 68e7655

Prior Findings Dispositioned (2)

  • prior:327f415 important 1 — fixed — server/src/services/recovery/service.ts:8293-8313 — the catch now treats infrastructure failures as degraded recovery, logs the edge-only retry at error level, records restoreSkippedReason, and exposes blockerPruneRestoreDegraded instead of reporting a healthy prune.
  • prior:327f415 important 2 — fixed — server/src/services/recovery/service.ts:8433-8445 — the source-open skip is explicitly counted as sourceStillOpenSkipped and documented as the tracked BLO-29137 follow-up gap, rather than remaining an unobservable retirement suppression.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

  • [tests/comments] server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1089-1142 — the comments still explain the removed cycle-fallback mechanics at length; consider shortening them to the post-change invariant so future readers are not led through a deleted code path.

Strengths

  • The escalation path now leaves the source blocker set and status untouched, eliminating the fabricated self-blocker and the associated re-file loop.
  • The legacy drain combines edge removal with status restoration when no unresolved blocker remains, while retaining blocked when a real blocker survives.
  • The degraded restore path is observable through error logging, activity details, and dedicated sweep counters.
  • The tests cover first-time escalation, shared recovery rows, re-escalation, legacy cleanup, and the surviving-real-blocker counterpart.

Recommended Action

  1. No Critical or Important issues remain.
  2. Merge when the queued checks complete.
  3. Track the bounded-retirement behavior through BLO-29137 before adding an age-based retirement rule.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 22, 2026
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 22, 2026
Merged via the queue into master with commit f27421c Aug 22, 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