diff --git a/server/src/__tests__/issue-liveness.test.ts b/server/src/__tests__/issue-liveness.test.ts index 96f4cded118c..aa431647ea37 100644 --- a/server/src/__tests__/issue-liveness.test.ts +++ b/server/src/__tests__/issue-liveness.test.ts @@ -1056,4 +1056,85 @@ describe("issue graph liveness classifier", () => { expect(findings.map((entry) => entry.state)).toEqual(["blocked_by_cancelled_issue"]); }); }); + + describe("uninvokable assignee on a blocker that is itself blocked (BLO-15200)", () => { + const midId = "mid-1"; + const pausedOwner = agent({ id: "blocker-agent", name: "Paused owner", status: "paused" }); + + // source -> blocker (blocked, paused owner) -> mid + const chain = [ + { companyId, blockerIssueId: blockerId, blockedIssueId: blockedId }, + { companyId, blockerIssueId: midId, blockedIssueId: blockerId }, + ]; + + function blockedBlocker() { + return issue({ + id: blockerId, + identifier: "BLO-14424", + title: "Operator-gated unblock work", + status: "blocked", + assigneeAgentId: "blocker-agent", + }); + } + + it("does not escalate while the blocker is still waiting on its own unresolved edge", () => { + // The storm shape: 53 of the 58 blockers in the 2026-08-18 census carried their + // own unresolved blockedBy edges, so "assign it to an active owner" could not have + // helped any of them -- a fresh owner wakes straight back into the same wait. + const findings = classifyIssueGraphLiveness({ + issues: [ + issue(), + blockedBlocker(), + issue({ id: midId, identifier: "BLO-14430", title: "Real upstream work", status: "todo" }), + ], + relations: chain, + agents: [agent(), manager, pausedOwner], + }); + + expect(findings).toEqual([]); + }); + + it("escalates once that edge resolves and the assignee is the thing holding it", () => { + // Deferred, not discarded. With the upstream edge done the blocker is genuinely + // dispatchable, so the paused assignee is now the real defect and the rule must + // fire -- this is what separates the fix from simply muting the invariant. + const findings = classifyIssueGraphLiveness({ + issues: [ + issue(), + blockedBlocker(), + issue({ id: midId, identifier: "BLO-14430", title: "Real upstream work", status: "done" }), + ], + relations: chain, + agents: [agent(), manager, pausedOwner], + }); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + issueId: blockedId, + state: "blocked_by_uninvokable_assignee", + recoveryIssueId: blockerId, + incidentKey: `harness_liveness:${companyId}:${blockedId}:blocked_by_uninvokable_assignee:${blockerId}`, + }); + }); + + it("still surfaces a real defect further down the chain", () => { + // Suppression must not swallow the chain walk: the cancelled edge under the + // blocker is a broken dependency that stays broken no matter who owns anything. + const findings = classifyIssueGraphLiveness({ + issues: [ + issue(), + blockedBlocker(), + issue({ id: midId, identifier: "BLO-14430", title: "Abandoned upstream", status: "cancelled" }), + ], + relations: chain, + agents: [agent(), manager, pausedOwner], + }); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + state: "blocked_by_cancelled_issue", + recoveryIssueId: midId, + }); + }); + }); }); diff --git a/server/src/services/recovery/issue-graph-liveness.ts b/server/src/services/recovery/issue-graph-liveness.ts index 42d937b06da1..0d64b059d59d 100644 --- a/server/src/services/recovery/issue-graph-liveness.ts +++ b/server/src/services/recovery/issue-graph-liveness.ts @@ -692,6 +692,30 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu if (!blocker.assigneeAgentId) return null; + /** + * BLO-15200: an assignee's invokability only gates a blocker that is otherwise ready + * to be worked. + * + * A blocker that is itself `blocked` behind its own unresolved edges is waiting on + * those edges, not on its owner. The chain walk above has already descended through + * them and come back empty, which means every issue further down has a live owner or + * an explicit waiting path — so the dependency is progressing and the assignee is + * simply not the thing holding it. Escalating here states the wrong fact and, worse, + * recommends a remedy that provably cannot work: a freshly-assigned active owner + * would be blocked on exactly the same edges the moment it woke. + * + * This defers the finding rather than discarding it. When the blocker's own edges + * clear, `hasUnresolvedBlockerEdge` goes false, the blocker becomes genuinely + * dispatchable, and an uninvokable assignee is then the real defect — so the next + * sweep escalates it, at the point where "assign it to an active owner" is finally + * actionable. + * + * Deliberately scoped to this rule. `blocked_by_cancelled_issue` names a broken edge + * and `blocked_by_assigned_backlog_issue` names a status that never dispatches; both + * stay true regardless of what the blocker is waiting on, so both must keep firing. + */ + if (blocker.status === "blocked" && hasUnresolvedBlockerEdge(blocker)) return null; + const blockerAgent = agentsById.get(blocker.assigneeAgentId); const blockerEligibility = blockerAgent ? getAgentWorkEligibility({ agent: blockerAgent, agents: input.agents })