Skip to content

fix(liveness): don't escalate an uninvokable assignee on a still-waiting blocker (BLO-15200) - #1413

Merged
kkroo merged 1 commit into
masterfrom
blo-15200-uninvokable-assignee-dependency-gate
Aug 19, 2026
Merged

fix(liveness): don't escalate an uninvokable assignee on a still-waiting blocker (BLO-15200)#1413
kkroo merged 1 commit into
masterfrom
blo-15200-uninvokable-assignee-dependency-gate

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
  • The recovery subsystem's issue-graph liveness classifier walks blocked dependency chains and raises a harness_liveness_escalation naming the leaf that has no owning next action
  • One of its seven invariants, blocked_by_uninvokable_assignee, keys purely on whether the blocker's assignee can be invoked — it never asks whether the assignee is the thing actually holding the blocker
  • So a blocker that is itself blocked behind its own unresolved edges escalates on its owner's status, and the escalation's own recommended remedy ("assign it to an active owner") cannot work there: a fresh owner wakes straight back into the same wait
  • It needs addressing because each escalation is a CEO-assigned issue AND a child of its recovery target, so a single upstream pause converts into unbounded run consumption while inflating the unresolvedBlockerCount of the very graph the detector protects
  • This pull request suppresses the invariant while the blocker still has an unresolved blocker edge, deferring the finding to the sweep where it becomes actionable
  • The benefit is that the detector reports a fact that is true and a remedy that can be executed, without going quiet on genuinely abandoned work

Linked Issues or Issue Description

Refs BLO-15200 (Paperclip-internal tracker; no GitHub issue).

Searched open PRs for liveness, uninvokable, escalation, blockedno duplicate, and the two neighbours are complementary rather than overlapping:

PR Scope Relationship
#1394 (BLO-27676) Gates re-escalation on target change instead of elapsed time, in recovery/service.ts + heartbeat.ts Complementary. It stops repeat fires on one incident key; it cannot suppress a first fire across 58 distinct targets, which is what the 2026-08-18 storm was.
#1405 (BLO-27463) Stops dependency-wait terminal runs escalating as stranded, in recovery/service.ts Different path — run classification, not graph classification.

No open PR touches issue-graph-liveness.ts; verified the blob is byte-identical on master and every open head.

The problem, as a bug report. blocked_by_uninvokable_assignee fires 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.

observation count
blocker carried its own unresolved blockedBy edges 53/58
already blocked before the pause 46/58 confirmed, 0 counter-examples
terminated at a human gate (procurement, credentials, cluster-admin) ~17/58 (29%)
median blocker age at pause time 15 days (max 83)

Nothing 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 — in blockedFindingForLeaf, return null from the blocked_by_uninvokable_assignee branch when the blocker is blocked and hasUnresolvedBlockerEdge(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 a BLO-15200 describe: the storm shape is suppressed; the finding still fires once the upstream edge goes done; 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 blocked before the assignee became uninvokable. That needs pausedAt threaded onto IssueLivenessAgentInput and its query, and pausedAt is independently known to be unreliable — BLO-15200's own thread records an agent reading pausedAt: 2026-08-14T06:16:13Z while pauseReason was null and it was running. The structural predicate needs no new data and states the real reason the escalation is wrong.

Verification

cd server && vitest run src/__tests__/issue-liveness.test.ts \
  src/__tests__/heartbeat-issue-liveness-escalation.test.ts src/services/recovery/
#  Test Files  8 passed (8)
#       Tests  134 passed (134)

cd server && tsc --noEmit          # exit 0
node scripts/check-commit-author-attribution.mjs --base 8e6708f80 --head HEAD   # passes

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:

× does not escalate while the blocker is still waiting on its own unresolved edge
  Tests  1 failed | 37 passed (38)

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:

  1. Deferred, not discarded. The suppression is a function of live graph state. When the blocker's edges clear, hasUnresolvedBlockerEdge goes false and the next sweep escalates normally. Covered by the second test.
  2. The chain walk is untouched. firstBlockedChainFinding descends before reaching this branch, so a real defect further down still surfaces — and returning null continues the relation loop rather than aborting the search. Covered by the third test.
  3. Scoped to one rule. blocked_by_cancelled_issue (broken edge) and blocked_by_assigned_backlog_issue (status that never dispatches) stay true regardless of what the blocker waits on, so both keep firing. blocked_without_blockers is 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_blockers still 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

  • 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 rule's rationale is documented inline at the guard
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending review
  • I will address all Greptile and reviewer comments before requesting merge

…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>
@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27676
🔗 Paperclip issue: BLO-27463
🔗 Paperclip issue: BLO-15200

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27676
🔗 Paperclip issue: BLO-27463
🔗 Paperclip issue: BLO-15200

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

@ally please review at head 2915ff2 — BLO-15200, one guard clause in the liveness classifier.

Focus where I am least certain:

  1. Over-suppression. The guard returns null from the blocked_by_uninvokable_assignee branch when blocker.status === "blocked" && hasUnresolvedBlockerEdge(blocker). Is there a chain shape where this goes permanently quiet on genuinely dead work? My claim is no, because the suppression is a pure function of live graph state and re-fires once the edges clear — but that is the failure mode worth attacking.

  2. Predicate choice. hasUnresolvedBlockerEdge treats a cancelled blocker as unresolved (deliberate, per its doc comment). I believe the nested chain walk always emits blocked_by_cancelled_issue before control reaches my guard, so the cancelled case cannot be swallowed — third test asserts this. Please check that reasoning rather than the test.

  3. Placement. The guard sits after the !blocker.assigneeAgentId early return, so it also covers the deleted-assignee and cross-company sub-cases of the same if. Intentional (same deferral argument), but worth a second opinion.

Not in scope, still open on BLO-15200: the self-feeding escalation loop, and auto-resolving already-open escalations whose premise went false.

@kkroo
kkroo added this pull request to the merge queue Aug 19, 2026
Merged via the queue into master with commit a910162 Aug 19, 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.

1 participant