From 19a16db1d29e06ab7624a167dfa8c2250b90a7da Mon Sep 17 00:00:00 2001
From: Dotta
Date: Tue, 9 Jun 2026 22:56:30 +0000
Subject: [PATCH] Address blocker release review gaps
---
server/src/__tests__/issues-service.test.ts | 104 ++++++++++++++++++++
server/src/services/heartbeat.ts | 2 +-
server/src/services/issues.ts | 20 +++-
ui/src/components/IssueBlockedNotice.tsx | 2 +-
4 files changed, 125 insertions(+), 3 deletions(-)
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.