fix(server): clear stale workspace finalize blockers

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-27 15:32:13 +00:00
1 parent ae7ca9b456
commit 0f5a674ea0
3 files changed
+132 -18

No files matched your search

+1
View File
@@ -399,6 +399,7 @@ export interface IssueBlockerAttention {
coveredBlockerCount: number;
stalledBlockerCount: number;
attentionBlockerCount: number;
pendingFinalizeBlockerIssueIds?: string[];
sampleBlockerIdentifier: string | null;
sampleStalledBlockerIdentifier: string | null;
}
+43 -5
View File
@@ -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 () => {
+88 -13
View File
@@ -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<string, { phase: string; status: string; startedAt: Date }>();
const latestUnattributedByWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
const latestSuccessfulFinalizeByWorkspace = new Map<string, Date>();
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<Db, "select">,
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<Db, "select">,
companyId: string,
@@ -1924,6 +1960,7 @@ function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {})
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<string>();
for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
const nextFrontier = new Set<string>();
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<IssueBlockerAttentionQueryRow[]> = 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<IssueBlockerAttentionQueryRow[]> = 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