Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions server/src/__tests__/issue-liveness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
});
24 changes: 24 additions & 0 deletions server/src/services/recovery/issue-graph-liveness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading