diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 611921911ad..66df6959376 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -76,6 +76,9 @@ describeEmbeddedPostgres("issue blocker attention", () => { status: string; parentId?: string | null; assigneeAgentId?: string | null; + originKind?: string | null; + originId?: string | null; + originFingerprint?: string | null; }) { const id = input.id ?? randomUUID(); await db.insert(issues).values({ @@ -87,6 +90,9 @@ describeEmbeddedPostgres("issue blocker attention", () => { priority: "medium", parentId: input.parentId ?? null, assigneeAgentId: input.assigneeAgentId ?? null, + originKind: input.originKind ?? "manual", + originId: input.originId ?? null, + originFingerprint: input.originFingerprint ?? "default", }); return id; } @@ -356,6 +362,52 @@ describeEmbeddedPostgres("issue blocker attention", () => { }); }); + it("treats open liveness escalation blockers as covered waiting paths", async () => { + const { companyId, agentId } = await createCompany("PBL"); + const parentId = await insertIssue({ companyId, identifier: "PBL-1", title: "Parent", status: "blocked" }); + const cancelledLeafId = await insertIssue({ + companyId, + identifier: "PBL-2", + title: "Cancelled blocker", + status: "cancelled", + assigneeAgentId: agentId, + }); + const incidentKey = [ + "harness_liveness", + companyId, + parentId, + "blocked_by_cancelled_issue", + cancelledLeafId, + ].join(":"); + const escalationId = await insertIssue({ + companyId, + identifier: "PBL-3", + title: "Liveness escalation", + status: "todo", + assigneeAgentId: agentId, + originKind: "harness_liveness_escalation", + originId: incidentKey, + originFingerprint: [ + "harness_liveness_leaf", + companyId, + "blocked_by_cancelled_issue", + cancelledLeafId, + ].join(":"), + }); + await block({ companyId, blockerIssueId: cancelledLeafId, blockedIssueId: parentId }); + await block({ companyId, blockerIssueId: escalationId, blockedIssueId: parentId }); + + const parent = (await svc.list(companyId, { status: "blocked,todo" })).find((issue) => issue.id === parentId); + + expect(parent?.blockerAttention).toMatchObject({ + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 2, + coveredBlockerCount: 2, + attentionBlockerCount: 0, + }); + }); + it("does not treat a scheduled retry as actively covered work", async () => { const { companyId, agentId } = await createCompany("PBY"); const parentId = await insertIssue({ companyId, identifier: "PBY-1", title: "Parent", status: "blocked" }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 741d8ce302c..876b76b70f3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -51,6 +51,7 @@ import { issueTreeControlService, type ActiveIssueTreePauseHoldGate, } from "./issue-tree-control.js"; +import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; @@ -1160,12 +1161,12 @@ async function listIssueBlockerAttentionMap( } } - const reviewNodeIds = [...nodesById.values()] - .filter((node) => node.status === "in_review") + const explicitWaitCandidateIds = [...nodesById.values()] + .filter((node) => node.status !== "done") .map((node) => node.id); const explicitWaitingIssueIds = new Set(); - if (reviewNodeIds.length > 0) { - for (const chunk of chunkList(reviewNodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + if (explicitWaitCandidateIds.length > 0) { + for (const chunk of chunkList(explicitWaitCandidateIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const interactionRows: Array<{ issueId: string }> = await dbOrTx .select({ issueId: issueThreadInteractions.issueId }) .from(issueThreadInteractions) @@ -1190,22 +1191,25 @@ async function listIssueBlockerAttentionMap( ), ); for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId); + } - const recoveryRows: Array<{ originId: string | null }> = await dbOrTx - .select({ originId: issues.originId }) - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND), - isNull(issues.hiddenAt), - inArray(issues.originId, chunk), - notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES), - ), - ); - for (const row of recoveryRows) { - if (row.originId) explicitWaitingIssueIds.add(row.originId); - } + const recoveryRows: Array<{ id: string; originId: string | null }> = await dbOrTx + .select({ id: issues.id, originId: issues.originId }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND), + isNull(issues.hiddenAt), + notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES), + ), + ); + for (const row of recoveryRows) { + const parsed = parseIssueGraphLivenessIncidentKey(row.originId); + if (!parsed || parsed.companyId !== companyId) continue; + explicitWaitingIssueIds.add(row.id); + explicitWaitingIssueIds.add(parsed.issueId); + explicitWaitingIssueIds.add(parsed.leafIssueId); } } @@ -1243,6 +1247,9 @@ async function listIssueBlockerAttentionMap( if (node.status === "done") { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } + if (explicitWaitingIssueIds.has(node.id)) { + return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; + } if (node.status === "in_review") { const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId) || explicitWaitingIssueIds.has(node.id); if (hasWaitingPath) {