fix(liveness): don't escalate an uninvokable assignee on a still-waiting blocker (BLO-15200) - #1413
Conversation
…ing blocker (BLO-15200) `blocked_by_uninvokable_assignee` fires on assignee invokability alone, so it also fires when the blocker is itself `blocked` behind its own unresolved edges. There the assignee is not what holds the work, and the escalation's remedy -- "assign it to an active owner" -- provably cannot help: a fresh owner wakes straight back into the same wait. Measured on 2026-08-18, a CTO budget pause minted 72 escalations in 4h37m across 58 distinct blockers; 53/58 carried their own unresolved blockedBy edges and ~29% terminated at a human gate no reassignment could clear. Each row is a CEO-assigned issue and a child of its recovery target, so it also inflates the unresolvedBlockerCount of the graph it exists to protect. Suppress the rule when the blocker is `blocked` with an unresolved edge. This defers rather than discards: once those edges clear the blocker is genuinely dispatchable, and the next sweep escalates at the point where the recommended action is finally actionable. Scoped to this rule only -- `blocked_by_cancelled_issue` and `blocked_by_assigned_backlog_issue` stay true regardless of what the blocker waits on, and the chain walk still surfaces real defects deeper down. Complements #1394 (re-escalation cadence), which gates repeat fires on one incident key but cannot suppress a first fire across 58 distinct targets. Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
@ally please review at head 2915ff2 — BLO-15200, one guard clause in the liveness classifier. Focus where I am least certain:
Not in scope, still open on BLO-15200: the self-feeding escalation loop, and auto-resolving already-open escalations whose premise went false. |
Thinking Path
Linked Issues or Issue Description
Refs BLO-15200 (Paperclip-internal tracker; no GitHub issue).
Searched open PRs for
liveness,uninvokable,escalation,blocked— no duplicate, and the two neighbours are complementary rather than overlapping:recovery/service.ts+heartbeat.tsrecovery/service.tsNo open PR touches
issue-graph-liveness.ts; verified the blob is byte-identical onmasterand every open head.The problem, as a bug report.
blocked_by_uninvokable_assigneefires on assignee invokability and reports it as a dependency-liveness incident. Those are different facts. Measured by the CEO on 2026-08-18: a CTO budget pause at 18:11:42Z produced 72 escalations in 4h37m across 58 distinct blockers — 58 of them inside the first 12 minutes.blockedByedgesblockedbefore the pauseNothing about the pause changed the state of chains stalled for a median of 15 days, and for the 29% ending at a human gate no reassignment could ever have helped.
What Changed
server/src/services/recovery/issue-graph-liveness.ts— inblockedFindingForLeaf, returnnullfrom theblocked_by_uninvokable_assigneebranch when the blocker isblockedandhasUnresolvedBlockerEdge(blocker)is true. One clause, reusing a predicate already defined in the same closure; no new input field, query, or schema change.server/src/__tests__/issue-liveness.test.ts— three cases under aBLO-15200describe: the storm shape is suppressed; the finding still fires once the upstream edge goesdone; a cancelled edge deeper in the chain still surfaces.Why this predicate rather than the one proposed on the issue. The issue suggested suppressing when the blocker was
blockedbefore the assignee became uninvokable. That needspausedAtthreaded ontoIssueLivenessAgentInputand its query, andpausedAtis independently known to be unreliable — BLO-15200's own thread records an agent readingpausedAt: 2026-08-14T06:16:13ZwhilepauseReasonwasnulland it was running. The structural predicate needs no new data and states the real reason the escalation is wrong.Verification
The new test was confirmed to fail without the fix, which is the part that matters — stashing only the source change and re-running gives:
The other two new cases pass in both states by design: they are the over-suppression guards, and their job is to prove the change did not simply mute the invariant.
Risks
The real risk is over-suppression — going quiet on work that is genuinely dead. Bounded three ways:
hasUnresolvedBlockerEdgegoes false and the next sweep escalates normally. Covered by the second test.firstBlockedChainFindingdescends before reaching this branch, so a real defect further down still surfaces — and returningnullcontinues the relation loop rather than aborting the search. Covered by the third test.blocked_by_cancelled_issue(broken edge) andblocked_by_assigned_backlog_issue(status that never dispatches) stay true regardless of what the blocker waits on, so both keep firing.blocked_without_blockersis evaluated earlier and is unaffected.Residual: a chain where every level is blocked behind a live-but-slow dependency now reports nothing from this invariant. That is the intended trade — those chains have an owner at the bottom, and
blocked_without_blockersstill catches the case where they bottom out in nothing.Not addressed here, and still open on BLO-15200: the self-feeding loop (an escalation issue becoming a blocker that satisfies the predicate) and stale-premise auto-resolution of already-open escalations. Both want their own guard; folding them in would make this diff unreviewable.
No migration, no API change, no UI change. Behavioural shift is strictly a reduction in emitted findings for one invariant.
Model Used
Claude Opus 5 (
claude-opus-5[1m]), 1M context, extended thinking, with tool use and code execution.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template