From 9ac24317e6d70107dfebda40b6902025bc2c392b Mon Sep 17 00:00:00 2001 From: Svetlana Zolotenkova <69031404+MrBlackTongue@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:29:44 +0300 Subject: [PATCH] fix(issues): ignore cancelled child blockers in attention (#7577) Fixes #7578 ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Blocked issue health is surfaced through blockerAttention so operators can see whether blocked work has a live path. > - The blockerAttention graph uses both explicit blockedBy edges and direct child issue edges. > - Done children were already ignored, but cancelled direct children still appeared as unresolved blockers. > - Explicit cancelled dependencies should remain visible as dependency problems, but terminal direct children should not inflate a parent blocker count. > - This pull request narrows child-edge traversal to non-terminal children and adds a regression test for the observed case. > - The benefit is that cancelled child issues no longer make blocked parents look like they have extra unresolved blocker attention. ## What Changed - Added terminal child status filtering for blockerAttention parent-child traversal so cancelled direct children are ignored with done children. - Added a server regression test where a blocked parent has active explicit blockers plus a cancelled direct child; the cancelled child no longer increases unresolved counts or becomes the sample blocker. ## Verification - `perl -e 'alarm shift; exec @ARGV' 300 pnpm exec vitest run server/src/__tests__/issue-blocker-attention.test.ts` -> 19 tests passed. ## Risks - Low risk: the change only affects implicit direct-child blockerAttention edges. - Explicit blockedBy edges to cancelled issues are intentionally unchanged and remain represented as attention-required dependency problems. ## Model Used - OpenAI GPT-5, Codex coding agent in tool-enabled CLI environment; reasoning and code execution used for repository inspection, patching, git operations, and targeted test verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (not applicable; no UI change) - [x] I have updated relevant documentation to reflect my changes (not applicable; bugfix covered by regression test) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../__tests__/issue-blocker-attention.test.ts | 43 +++++++++++++++++++ server/src/services/issues.ts | 3 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 87608ad3..13480b66 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -255,6 +255,49 @@ describeEmbeddedPostgres("issue blocker attention", () => { }); }); + it("ignores cancelled direct children when counting unresolved blocker attention", async () => { + const { companyId, agentId } = await createCompany("PBD"); + const parentId = await insertIssue({ companyId, identifier: "PBD-1", title: "Parent", status: "blocked" }); + const activeBlockerOneId = await insertIssue({ + companyId, + identifier: "PBD-2", + title: "Running dependency one", + status: "todo", + assigneeAgentId: agentId, + }); + const activeBlockerTwoId = await insertIssue({ + companyId, + identifier: "PBD-3", + title: "Running dependency two", + status: "todo", + assigneeAgentId: agentId, + }); + await insertIssue({ + companyId, + identifier: "PBD-4", + title: "Cancelled child", + status: "cancelled", + parentId, + assigneeAgentId: agentId, + }); + await block({ companyId, blockerIssueId: activeBlockerOneId, blockedIssueId: parentId }); + await block({ companyId, blockerIssueId: activeBlockerTwoId, blockedIssueId: parentId }); + await activeRun({ companyId, agentId, issueId: activeBlockerOneId }); + await activeRun({ companyId, agentId, issueId: activeBlockerTwoId }); + + const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId); + + expect(parent?.blockerAttention).toMatchObject({ + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 2, + coveredBlockerCount: 2, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + }); + expect(parent?.blockerAttention?.sampleBlockerIdentifier).not.toBe("PBD-4"); + }); + it("covers recursive blocker chains when the downstream leaf has active work", async () => { const { companyId, agentId } = await createCompany("PBR"); const parentId = await insertIssue({ companyId, identifier: "PBR-1", title: "Parent", status: "blocked" }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 5768d09c..7060cec9 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1204,6 +1204,7 @@ const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execut const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"]; const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"]; const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation"; +const BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES = ["done", "cancelled"]; const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review"; const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"]; const PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS = [ @@ -1636,7 +1637,7 @@ async function listIssueBlockerAttentionMap( and( eq(issues.companyId, companyId), inArray(issues.parentId, chunk), - ne(issues.status, "done"), + notInArray(issues.status, BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES), ), ); const [explicitBlockerRows, childRows] = await Promise.all([