diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index e4639f87f74..98c923fa7d4 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -399,6 +399,7 @@ export interface IssueBlockerAttention { coveredBlockerCount: number; stalledBlockerCount: number; attentionBlockerCount: number; + pendingFinalizeBlockerIssueIds?: string[]; sampleBlockerIdentifier: string | null; sampleStalledBlockerIdentifier: string | null; } diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index f238c96493b..255c3d6faa1 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -3417,6 +3417,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", id: blockerId, companyId, projectId, + identifier: "PAP-15043", title: "Predecessor", status: "done", priority: "medium", @@ -3426,6 +3427,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", id: dependentId, companyId, projectId, + identifier: "PAP-15046", title: "Dependent", status: "blocked", priority: "medium", @@ -3435,6 +3437,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", id: foreignIssueId, companyId, projectId, + identifier: "PAP-15125", title: "Foreign in-flight issue", status: "in_progress", priority: "medium", @@ -3803,6 +3806,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", executionWorkspaceId, blockerId, dependentId, + foreignIssueId, assigneeAgentId, } = await seedSharedWorkspaceDependency(); @@ -3834,13 +3838,36 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", startedAt: new Date("2026-05-23T22:05:00.000Z"), }); expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]); + const pendingDependent = (await svc.list(companyId, { status: "blocked" })) + .find((issue) => issue.id === dependentId); + expect(pendingDependent?.blockerAttention).toMatchObject({ + state: "needs_attention", + unresolvedBlockerCount: 1, + attentionBlockerCount: 1, + pendingFinalizeBlockerIssueIds: [blockerId], + sampleBlockerIdentifier: "PAP-15043", + }); + await expect( + svc.checkout(dependentId, assigneeAgentId, ["blocked"], null), + ).rejects.toMatchObject({ + status: 422, + details: { + unresolvedBlockerIssueIds: [blockerId], + unresolvedBlockers: [{ + issueId: blockerId, + identifier: "PAP-15043", + title: "Predecessor", + reason: "pending_finalize", + }], + }, + }); - // Once a workspace_finalize succeeded row lands AFTER the failed one, - // the gate opens and the dependent is wakeable. + // A later successful finalize on the same workspace, even when attributed + // to another issue, proves the shared branch is coherent past the failure. await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, - issueId: blockerId, + issueId: foreignIssueId, phase: "workspace_finalize", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), @@ -4023,7 +4050,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", const blockerId = randomUUID(); const blockedId = randomUUID(); await db.insert(issues).values([ - { id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" }, + { id: blockerId, companyId, identifier: "PAP-1", title: "Blocker", status: "todo", priority: "medium" }, { id: blockedId, companyId, @@ -4041,7 +4068,18 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await expect( svc.checkout(blockedId, assigneeAgentId, ["todo", "blocked"], null), - ).rejects.toMatchObject({ status: 422 }); + ).rejects.toMatchObject({ + status: 422, + details: { + unresolvedBlockerIssueIds: [blockerId], + unresolvedBlockers: [{ + issueId: blockerId, + identifier: "PAP-1", + title: "Blocker", + reason: "not_done", + }], + }, + }); }); it("wakes parents only when all direct children are terminal", async () => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index aa9cf5a89fc..a4b044454d2 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1016,14 +1016,20 @@ async function listPendingFinalizeBlockerIssueIds( and( eq(workspaceOperations.companyId, companyId), inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds), - or(inArray(workspaceOperations.issueId, blockerIssueIds), isNull(workspaceOperations.issueId)), ), ); const latestAttributedByBlockerWorkspace = new Map(); const latestUnattributedByWorkspace = new Map(); + const latestSuccessfulFinalizeByWorkspace = new Map(); for (const row of rows) { if (!row.executionWorkspaceId) continue; + if (row.phase === "workspace_finalize" && row.status === "succeeded") { + const current = latestSuccessfulFinalizeByWorkspace.get(row.executionWorkspaceId); + if (!current || row.startedAt > current) { + latestSuccessfulFinalizeByWorkspace.set(row.executionWorkspaceId, row.startedAt); + } + } if (row.issueId) { const key = `${row.issueId}:${row.executionWorkspaceId}`; if (!blockerWorkspaceKeys.has(key)) continue; @@ -1053,6 +1059,8 @@ async function listPendingFinalizeBlockerIssueIds( ?? latestUnattributedByWorkspace.get(pair.executionWorkspaceId); if (!latest) continue; // no ops recorded -> nothing to finalize for this blocker if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue; + const laterSuccessfulFinalize = latestSuccessfulFinalizeByWorkspace.get(pair.executionWorkspaceId); + if (laterSuccessfulFinalize && laterSuccessfulFinalize > latest.startedAt) continue; pending.add(pair.blockerIssueId); } @@ -1217,6 +1225,34 @@ async function listIssueDependencyReadinessMap( return readinessMap; } +async function listUnresolvedBlockerDetails( + dbOrTx: Pick, + companyId: string, + unresolvedBlockerIssueIds: string[], + pendingFinalizeBlockerIssueIds: string[] = [], +) { + if (unresolvedBlockerIssueIds.length === 0) return []; + const pendingFinalizeIds = new Set(pendingFinalizeBlockerIssueIds); + const rows = await dbOrTx + .select({ + issueId: issues.id, + identifier: issues.identifier, + title: issues.title, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.id, unresolvedBlockerIssueIds))); + const rowsById = new Map(rows.map((row) => [row.issueId, row])); + return unresolvedBlockerIssueIds.map((issueId) => { + const row = rowsById.get(issueId); + return { + issueId, + identifier: row?.identifier ?? null, + title: row?.title ?? null, + reason: pendingFinalizeIds.has(issueId) ? "pending_finalize" as const : "not_done" as const, + }; + }); +} + async function listUnresolvedBlockerIssueIds( dbOrTx: Pick, companyId: string, @@ -1924,6 +1960,7 @@ function createIssueBlockerAttention(input: Partial = {}) coveredBlockerCount: input.coveredBlockerCount ?? 0, stalledBlockerCount: input.stalledBlockerCount ?? 0, attentionBlockerCount: input.attentionBlockerCount ?? 0, + pendingFinalizeBlockerIssueIds: input.pendingFinalizeBlockerIssueIds ?? [], sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null, sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null, }; @@ -2179,10 +2216,17 @@ async function listIssueBlockerAttentionMap( let frontier = roots.map((root) => root.id); let truncated = false; + const pendingFinalizeBlockerIssueIds = new Set(); for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) { const nextFrontier = new Set(); for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const readinessByIssueId = await listIssueDependencyReadinessMap(dbOrTx, companyId, chunk); + for (const readiness of readinessByIssueId.values()) { + for (const blockerIssueId of readiness.pendingFinalizeBlockerIssueIds) { + pendingFinalizeBlockerIssueIds.add(blockerIssueId); + } + } const explicitBlockerRowsPromise: Promise = dbOrTx .select({ issueId: issueRelations.relatedIssueId, @@ -2205,7 +2249,6 @@ async function listIssueBlockerAttentionMap( eq(issueRelations.type, "blocks"), inArray(issueRelations.relatedIssueId, chunk), eq(issues.companyId, companyId), - ne(issues.status, "done"), ), ); const childRowsPromise: Promise = dbOrTx @@ -2235,8 +2278,11 @@ async function listIssueBlockerAttentionMap( childRowsPromise, ]); + const unresolvedExplicitBlockerRows = explicitBlockerRows.filter( + (row) => row.status !== "done" || pendingFinalizeBlockerIssueIds.has(row.blockerIssueId), + ); appendBlockerAttentionEdges(edgesByIssueId, [ - ...explicitBlockerRows + ...unresolvedExplicitBlockerRows .filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null) .map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })), ...childRows @@ -2244,7 +2290,7 @@ async function listIssueBlockerAttentionMap( .map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })), ]); - for (const row of [...explicitBlockerRows, ...childRows]) { + for (const row of [...unresolvedExplicitBlockerRows, ...childRows]) { if (!row.issueId || nodesById.has(row.blockerIssueId)) continue; nodesById.set(row.blockerIssueId, { id: row.blockerIssueId, @@ -2416,7 +2462,7 @@ async function listIssueBlockerAttentionMap( return { covered: false, stalled: false, sampleBlockerIdentifier: nodeId, sampleStalledBlockerIdentifier: null }; } const nodeSample = blockerSampleIdentifier(node); - if (node.status === "done") { + if (node.status === "done" && !pendingFinalizeBlockerIssueIds.has(node.id)) { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } if (explicitWaitingIssueIds.has(node.id)) { @@ -2442,7 +2488,10 @@ async function listIssueBlockerAttentionMap( return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } - const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done"); + const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => { + const blocker = nodesById.get(edge.blockerIssueId); + return blocker?.status !== "done" || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId); + }); if (downstream.length > 0) { const nextSeen = new Set(seen); nextSeen.add(nodeId); @@ -2486,7 +2535,10 @@ async function listIssueBlockerAttentionMap( }; for (const root of roots) { - const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done"); + const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => { + const blocker = nodesById.get(edge.blockerIssueId); + return blocker?.status !== "done" || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId); + }); if (topLevelEdges.length === 0) { attentionMap.set(root.id, createIssueBlockerAttention({ state: "needs_attention", @@ -2532,6 +2584,9 @@ async function listIssueBlockerAttentionMap( coveredBlockerCount, stalledBlockerCount, attentionBlockerCount, + pendingFinalizeBlockerIssueIds: topLevelEdges + .map((edge) => edge.blockerIssueId) + .filter((blockerIssueId) => pendingFinalizeBlockerIssueIds.has(blockerIssueId)), sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode), sampleStalledBlockerIdentifier: stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null, @@ -6624,13 +6679,23 @@ export function issueService(db: Db) { throw unprocessable("in_progress issues require an assignee"); } if (patch.status === "in_progress") { + const dependencyReadiness = blockedByIssueIds === undefined + ? (await listIssueDependencyReadinessMap(dbOrTx, existing.companyId, [id])).get(id) + : null; const unresolvedBlockerIssueIds = blockedByIssueIds !== undefined ? await listUnresolvedBlockerIssueIds(dbOrTx, existing.companyId, blockedByIssueIds) - : ( - await listIssueDependencyReadinessMap(dbOrTx, existing.companyId, [id]) - ).get(id)?.unresolvedBlockerIssueIds ?? []; + : dependencyReadiness?.unresolvedBlockerIssueIds ?? []; if (unresolvedBlockerIssueIds.length > 0) { - throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds }); + const unresolvedBlockers = await listUnresolvedBlockerDetails( + dbOrTx, + existing.companyId, + unresolvedBlockerIssueIds, + dependencyReadiness?.pendingFinalizeBlockerIssueIds, + ); + throw unprocessable("Issue is blocked by unresolved blockers", { + unresolvedBlockerIssueIds, + unresolvedBlockers, + }); } } const shouldValidateNextAssignee = @@ -6926,9 +6991,19 @@ export function issueService(db: Db) { await clearCheckoutRunIfTerminal(id); const dependencyReadiness = await listIssueDependencyReadinessMap(db, issueCompany.companyId, [id]); - const unresolvedBlockerIssueIds = dependencyReadiness.get(id)?.unresolvedBlockerIssueIds ?? []; + const readiness = dependencyReadiness.get(id); + const unresolvedBlockerIssueIds = readiness?.unresolvedBlockerIssueIds ?? []; if (unresolvedBlockerIssueIds.length > 0) { - throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds }); + const unresolvedBlockers = await listUnresolvedBlockerDetails( + db, + issueCompany.companyId, + unresolvedBlockerIssueIds, + readiness?.pendingFinalizeBlockerIssueIds, + ); + throw unprocessable("Issue is blocked by unresolved blockers", { + unresolvedBlockerIssueIds, + unresolvedBlockers, + }); } const sameRunAssigneeCondition = checkoutRunId