diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 200168c70b0..0d4fd35b825 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2999,6 +2999,110 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", }); }); + it("keeps the workspace-finalize barrier for legacy operations without heartbeatRunId", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = 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 dependentId = randomUUID(); + await db.insert(issues).values([ + { + id: blockerId, + companyId, + projectId, + title: "Legacy predecessor", + status: "done", + priority: "medium", + executionWorkspaceId, + }, + { + id: dependentId, + companyId, + projectId, + title: "Dependent", + status: "blocked", + priority: "medium", + assigneeAgentId, + }, + ]); + await svc.update(dependentId, { blockedByIssueIds: [blockerId] }); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + phase: "worktree_prepare", + status: "succeeded", + startedAt: new Date("2026-05-23T22:00:00.000Z"), + }); + + await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({ + isDependencyReady: false, + pendingFinalizeBlockerIssueIds: [blockerId], + unresolvedBlockerIssueIds: [blockerId], + }); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + phase: "workspace_finalize", + status: "succeeded", + startedAt: new Date("2026-05-23T22:10:00.000Z"), + }); + + await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({ + isDependencyReady: true, + pendingFinalizeBlockerIssueIds: [], + unresolvedBlockerIssueIds: [], + }); + }); + it("keeps a reused-workspace blocker ready when a later sibling operation is unfinalized", async () => { const companyId = randomUUID(); const assigneeAgentId = randomUUID(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 55c76ee42a7..0c7320c8d5b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10612,7 +10612,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) { + if ("dependencyGate" in outcome && outcome.dependencyGate && reason === BLOCKER_RELEASE_WAKE_REASON && issueId) { await requeueBlockerReleaseWakeIfDependencyGateCleared({ companyId: agent.companyId, agentId, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index a0034a60a94..78ec84becce 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -696,6 +696,8 @@ async function listPendingFinalizeBlockerIssueIds( ); const latestByBlockerIssueId = new Map(); + const latestLegacyByWorkspaceId = new Map(); + const workspacesWithRunAttributedOps = new Set(); for (const row of rows) { if (!row.executionWorkspaceId) continue; const blockerIds = blockerIdsByWorkspaceId.get(row.executionWorkspaceId); @@ -703,6 +705,18 @@ async function listPendingFinalizeBlockerIssueIds( const runIssueIds = [row.contextIssueId, row.contextTaskId].filter( (value): value is string => typeof value === "string" && value.length > 0, ); + if (runIssueIds.length === 0) { + const current = latestLegacyByWorkspaceId.get(row.executionWorkspaceId); + if (!current || row.startedAt > current.startedAt) { + latestLegacyByWorkspaceId.set(row.executionWorkspaceId, { + phase: row.phase, + status: row.status, + startedAt: row.startedAt, + }); + } + continue; + } + workspacesWithRunAttributedOps.add(row.executionWorkspaceId); for (const blockerIssueId of runIssueIds) { if (!blockerIds.has(blockerIssueId)) continue; const current = latestByBlockerIssueId.get(blockerIssueId); @@ -717,7 +731,11 @@ async function listPendingFinalizeBlockerIssueIds( } for (const target of targets) { - const latest = latestByBlockerIssueId.get(target.blockerIssueId); + const workspaceId = target.executionWorkspaceId; + const latest = latestByBlockerIssueId.get(target.blockerIssueId) ?? + (workspaceId && !workspacesWithRunAttributedOps.has(workspaceId) + ? latestLegacyByWorkspaceId.get(workspaceId) + : null); 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); diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index ec7bc8fad61..ddada0e343f 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -274,7 +274,7 @@ export function IssueBlockedNotice({ {" "} so a fresh run is queued, or add a real blocker if work genuinely can't proceed.

- {blockerAttention.coveredBlockerCount > 0 ? ( + {(blockerAttention?.coveredBlockerCount ?? 0) > 0 ? (

Some ancestor blockers are still covered by active work — see the "Ultimately waiting on" row below.