mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
fix(server): clear stale workspace finalize blockers
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ae7ca9b456
commit
0f5a674ea0
3 files changed
+132
-18
No files matched your search
@@ -399,6 +399,7 @@ export interface IssueBlockerAttention {
|
||||
coveredBlockerCount: number;
|
||||
stalledBlockerCount: number;
|
||||
attentionBlockerCount: number;
|
||||
pendingFinalizeBlockerIssueIds?: string[];
|
||||
sampleBlockerIdentifier: string | null;
|
||||
sampleStalledBlockerIdentifier: string | null;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user