diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index a74b438c0f6..200168c70b0 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index b97c3dbca6b..55c76ee42a7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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({ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e4c01818ee7..a0034a60a94 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -655,6 +655,77 @@ export async function listUnfinalizedExecutionWorkspaceIds( return unfinalized; } +type DoneBlockerFinalizeTarget = { + blockerIssueId: string; + executionWorkspaceId: string | null; +}; + +async function listPendingFinalizeBlockerIssueIds( + dbOrTx: Pick, + companyId: string, + blockers: DoneBlockerFinalizeTarget[], +): Promise> { + const targets = blockers.filter((blocker) => blocker.executionWorkspaceId); + const pending = new Set(); + if (targets.length === 0) return pending; + + const blockerIdsByWorkspaceId = new Map>(); + for (const target of targets) { + if (!target.executionWorkspaceId) continue; + const blockerIds = blockerIdsByWorkspaceId.get(target.executionWorkspaceId) ?? new Set(); + 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`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + contextTaskId: sql`${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(); + 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, 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(); + // 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 [];