Fix blocker readiness in reused workspaces

This commit is contained in:
Dotta committed 2026-06-09 22:41:45 +00:00
1 parent 568f1d3517
commit 6de173d35c
3 files changed
+438 -14

No files matched your search

+337 -3
View File
@@ -2592,6 +2592,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await db.delete(activityLog);
await db.delete(issues);
await db.delete(workspaceOperations);
await db.delete(heartbeatRuns);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
@@ -2643,6 +2644,105 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
});
it("ignores malformed cross-company blocker relations in summaries, readiness, and dependent wakes", async () => {
const companyA = randomUUID();
const companyB = randomUUID();
const localBlockerId = randomUUID();
const localBlockedId = randomUUID();
const foreignBlockerId = randomUUID();
const foreignDependentId = randomUUID();
const foreignAssigneeAgentId = randomUUID();
await db.insert(companies).values([
{
id: companyA,
name: "Paperclip A",
issuePrefix: `A${companyA.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: companyB,
name: "Paperclip B",
issuePrefix: `B${companyB.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(agents).values({
id: foreignAssigneeAgentId,
companyId: companyB,
name: "Foreign assignee",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: localBlockerId,
companyId: companyA,
title: "Local blocker",
status: "done",
priority: "medium",
},
{
id: localBlockedId,
companyId: companyA,
title: "Local blocked issue",
status: "blocked",
priority: "medium",
},
{
id: foreignBlockerId,
companyId: companyB,
title: "Foreign blocker",
status: "todo",
priority: "medium",
},
{
id: foreignDependentId,
companyId: companyB,
title: "Foreign dependent",
status: "blocked",
priority: "medium",
assigneeAgentId: foreignAssigneeAgentId,
},
]);
await db.insert(issueRelations).values([
{
companyId: companyA,
issueId: foreignBlockerId,
relatedIssueId: localBlockedId,
type: "blocks",
},
{
companyId: companyA,
issueId: localBlockerId,
relatedIssueId: foreignDependentId,
type: "blocks",
},
]);
await expect(svc.getDependencyReadiness(localBlockedId)).resolves.toMatchObject({
blockerIssueIds: [],
unresolvedBlockerIssueIds: [],
isDependencyReady: true,
});
await expect(svc.getRelationSummaries(localBlockedId)).resolves.toMatchObject({
blockedBy: [],
blocks: [],
});
await expect(svc.getRelationSummaries(localBlockerId)).resolves.toMatchObject({
blockedBy: [],
blocks: [],
});
await expect(svc.listWakeableBlockedDependents(localBlockerId)).resolves.toEqual([]);
});
it("adds terminal blockers to immediate blocked-by summaries", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
@@ -2767,6 +2867,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const blockerRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
@@ -2834,6 +2935,15 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
assigneeAgentId,
},
]);
await db.insert(heartbeatRuns).values({
id: blockerRunId,
companyId,
agentId: assigneeAgentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "succeeded",
contextSnapshot: { issueId: blockerId, taskId: blockerId },
});
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
// A run touched the workspace (prepare phase) but has not yet recorded
@@ -2841,6 +2951,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: blockerRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
@@ -2857,6 +2968,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: blockerRunId,
phase: "workspace_finalize",
status: "failed",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
@@ -2868,6 +2980,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: blockerRunId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
@@ -2886,6 +2999,154 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
});
});
it("keeps a reused-workspace blocker ready when a later sibling operation is unfinalized", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const blockerRunId = randomUUID();
const siblingRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA",
role: "qa",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Shared workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Shared workspace",
sourceType: "local_path",
visibility: "default",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Shared exec workspace",
status: "active",
providerType: "git_worktree",
});
const blockerId = randomUUID();
const siblingId = randomUUID();
const dependentId = randomUUID();
await db.insert(issues).values([
{
id: blockerId,
companyId,
projectId,
title: "Completed blocker",
status: "done",
priority: "medium",
executionWorkspaceId,
},
{
id: siblingId,
companyId,
projectId,
title: "Later sibling",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
assigneeAgentId,
},
{
id: dependentId,
companyId,
projectId,
title: "Dependent",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
]);
await db.insert(heartbeatRuns).values([
{
id: blockerRunId,
companyId,
agentId: assigneeAgentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "succeeded",
contextSnapshot: { issueId: blockerId, taskId: blockerId },
},
{
id: siblingRunId,
companyId,
agentId: assigneeAgentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "running",
contextSnapshot: { issueId: siblingId, taskId: siblingId },
},
]);
await db.insert(workspaceOperations).values([
{
companyId,
executionWorkspaceId,
heartbeatRunId: blockerRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
},
{
companyId,
executionWorkspaceId,
heartbeatRunId: blockerRunId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
},
{
companyId,
executionWorkspaceId,
heartbeatRunId: siblingRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:15:00.000Z"),
},
]);
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
assigneeAgentId,
blockerIssueIds: [blockerId],
}),
]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: true,
pendingFinalizeBlockerIssueIds: [],
unresolvedBlockerIssueIds: [],
});
});
it("treats blockers with no executionWorkspaceId as not subject to the workspace-finalize barrier", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
@@ -3121,11 +3382,11 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toMatchObject({
id: parentId,
assigneeAgentId,
childIssueIds: [childA, childB],
childIssueSummaries: [
childIssueIds: expect.arrayContaining([childA, childB]),
childIssueSummaries: expect.arrayContaining([
expect.objectContaining({ id: childA, title: "Child A", status: "done" }),
expect.objectContaining({ id: childB, title: "Child B", status: "cancelled" }),
],
]),
childIssueSummaryTruncated: false,
});
});
@@ -3475,6 +3736,79 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
});
});
it("rejects cross-company workspace inheritance sources", async () => {
const companyA = randomUUID();
const companyB = randomUUID();
const projectId = randomUUID();
const sourceIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values([
{
id: companyA,
name: "Paperclip A",
issuePrefix: `A${companyA.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: companyB,
name: "Paperclip B",
issuePrefix: `B${companyB.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId: companyB,
name: "Foreign workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId: companyB,
projectId,
name: "Foreign workspace",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId: companyB,
projectId,
projectWorkspaceId,
mode: "operator_branch",
strategyType: "git_worktree",
name: "Foreign operator branch",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values({
id: sourceIssueId,
companyId: companyB,
projectId,
projectWorkspaceId,
title: "Foreign source issue",
status: "todo",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "operator_branch",
},
});
await expect(svc.create(companyA, {
title: "Cross-company inheritance should fail",
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
})).rejects.toMatchObject({
status: 404,
});
});
it("derives project identity when an update adds workspace linkage to a projectless issue", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
+12 -2
View File
@@ -10407,12 +10407,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { kind: "skipped" as const, dependencyGate: true as const };
}
if (!activeExecutionRun && reason === BLOCKER_RELEASE_WAKE_REASON && effectiveIdempotencyKey) {
if (reason === BLOCKER_RELEASE_WAKE_REASON && effectiveIdempotencyKey) {
const existingReleaseWake = await findExistingBlockerReleaseWake(tx, {
companyId: issue.companyId,
idempotencyKey: effectiveIdempotencyKey,
});
if (existingReleaseWake) {
const existingReleaseRun = existingReleaseWake.runId
? await tx
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, existingReleaseWake.runId))
.then((rows) => rows[0] ?? null)
: null;
await tx.insert(agentWakeupRequests).values({
companyId: agent.companyId,
agentId,
@@ -10435,7 +10442,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
updatedAt: new Date(),
})
.where(eq(agentWakeupRequests.id, existingReleaseWake.id));
return { kind: "duplicate" as const };
return existingReleaseRun
? { kind: "existing" as const, run: existingReleaseRun }
: { kind: "duplicate" as const };
}
}
@@ -10601,6 +10610,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
});
if (outcome.kind === "deferred" || outcome.kind === "duplicate") return null;
if (outcome.kind === "existing") return outcome.run;
if (outcome.kind === "skipped") {
if ("dependencyGate" in outcome && outcome.dependencyGate && reason === BLOCKER_RELEASE_WAKE_REASON) {
await requeueBlockerReleaseWakeIfDependencyGateCleared({
+89 -9
View File
@@ -655,6 +655,77 @@ export async function listUnfinalizedExecutionWorkspaceIds(
return unfinalized;
}
type DoneBlockerFinalizeTarget = {
blockerIssueId: string;
executionWorkspaceId: string | null;
};
async function listPendingFinalizeBlockerIssueIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
blockers: DoneBlockerFinalizeTarget[],
): Promise<Set<string>> {
const targets = blockers.filter((blocker) => blocker.executionWorkspaceId);
const pending = new Set<string>();
if (targets.length === 0) return pending;
const blockerIdsByWorkspaceId = new Map<string, Set<string>>();
for (const target of targets) {
if (!target.executionWorkspaceId) continue;
const blockerIds = blockerIdsByWorkspaceId.get(target.executionWorkspaceId) ?? new Set<string>();
blockerIds.add(target.blockerIssueId);
blockerIdsByWorkspaceId.set(target.executionWorkspaceId, blockerIds);
}
const rows = await dbOrTx
.select({
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
phase: workspaceOperations.phase,
status: workspaceOperations.status,
startedAt: workspaceOperations.startedAt,
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
contextTaskId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`,
})
.from(workspaceOperations)
.leftJoin(heartbeatRuns, eq(workspaceOperations.heartbeatRunId, heartbeatRuns.id))
.where(
and(
eq(workspaceOperations.companyId, companyId),
inArray(workspaceOperations.executionWorkspaceId, [...blockerIdsByWorkspaceId.keys()]),
),
);
const latestByBlockerIssueId = new Map<string, { phase: string; status: string; startedAt: Date }>();
for (const row of rows) {
if (!row.executionWorkspaceId) continue;
const blockerIds = blockerIdsByWorkspaceId.get(row.executionWorkspaceId);
if (!blockerIds) continue;
const runIssueIds = [row.contextIssueId, row.contextTaskId].filter(
(value): value is string => typeof value === "string" && value.length > 0,
);
for (const blockerIssueId of runIssueIds) {
if (!blockerIds.has(blockerIssueId)) continue;
const current = latestByBlockerIssueId.get(blockerIssueId);
if (!current || row.startedAt > current.startedAt) {
latestByBlockerIssueId.set(blockerIssueId, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
}
}
}
for (const target of targets) {
const latest = latestByBlockerIssueId.get(target.blockerIssueId);
if (!latest) continue; // no blocker-specific workspace ops recorded -> no finalize barrier
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
pending.add(target.blockerIssueId);
}
return pending;
}
async function listIssueDependencyReadinessMap(
dbOrTx: Pick<Db, "select">,
companyId: string,
@@ -680,23 +751,28 @@ async function listIssueDependencyReadinessMap(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
eq(issues.companyId, companyId),
inArray(issueRelations.relatedIssueId, uniqueIssueIds),
),
);
// Collect executionWorkspaceIds of "done" blockers — these are the only ones
// subject to the workspace-finalize barrier. Blockers that aren't done already
// mark the dependent as not-ready and don't need a finalize check.
const doneBlockerWorkspaceIds = new Set<string>();
// Collect "done" blockers with execution workspaces — these are the only ones
// subject to the workspace-finalize barrier. The barrier is blocker-specific:
// a later sibling can reuse the same workspace without making this already-done
// blocker unresolved again.
const doneBlockers: DoneBlockerFinalizeTarget[] = [];
for (const row of blockerRows) {
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId);
doneBlockers.push({
blockerIssueId: row.blockerIssueId,
executionWorkspaceId: row.blockerExecutionWorkspaceId,
});
}
}
const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds(
const pendingFinalizeBlockerIssueIds = await listPendingFinalizeBlockerIssueIds(
dbOrTx,
companyId,
[...doneBlockerWorkspaceIds],
doneBlockers,
);
for (const row of blockerRows) {
@@ -711,9 +787,9 @@ async function listIssueDependencyReadinessMap(
current.isDependencyReady = false;
} else if (
row.blockerExecutionWorkspaceId &&
unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId)
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId)
) {
// Workspace-finalize barrier: the blocker's most recent run on its
// Workspace-finalize barrier: the blocker's own most recent run on its
// execution workspace hasn't recorded a successful workspace_finalize.
// Treat the dependent as not-ready until sync-back lands (or the run
// finalizes); a subsequent finalize wake will re-evaluate readiness.
@@ -2145,6 +2221,7 @@ async function blockedByMapForIssues(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
eq(issues.companyId, companyId),
inArray(issueRelations.relatedIssueId, issueIdChunk),
),
);
@@ -3525,6 +3602,7 @@ export function issueService(db: Db) {
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
eq(issues.companyId, companyId),
inArray(issueRelations.relatedIssueId, uniqueIssueIds),
),
),
@@ -3545,6 +3623,7 @@ export function issueService(db: Db) {
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
eq(issues.companyId, companyId),
inArray(issueRelations.issueId, uniqueIssueIds),
),
),
@@ -4256,6 +4335,7 @@ export function issueService(db: Db) {
eq(issueRelations.companyId, blockerIssue.companyId),
eq(issueRelations.type, "blocks"),
eq(issueRelations.issueId, blockerIssueId),
eq(issues.companyId, blockerIssue.companyId),
),
);
if (candidates.length === 0) return [];