diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 49e87b2fd5a..74b6d48425d 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -4416,7 +4416,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain("Next action:"); }); - it("assigns open unassigned blockers back to their creator agent", async () => { + it("does NOT reassign board-owned unassigned blockers (responsibleUserId set) to the creator agent", async () => { const companyId = randomUUID(); const creatorAgentId = randomUUID(); const blockedAssigneeAgentId = randomUUID(); @@ -4458,7 +4458,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { { id: blockerIssueId, companyId, - title: "Fix blocker", + title: "Review & merge PR", status: "todo", priority: "high", createdByAgentId: creatorAgentId, @@ -4473,7 +4473,98 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { status: "blocked", priority: "high", assigneeAgentId: blockedAssigneeAgentId, - responsibleUserId: "responsible-user", + issueNumber: 2, + identifier: `${issuePrefix}-2`, + }, + ]); + await db.insert(issueRelations).values({ + companyId, + issueId: blockerIssueId, + relatedIssueId: blockedIssueId, + type: "blocks", + createdByAgentId: creatorAgentId, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + + // Board-owned blocker must NOT be reassigned to an agent. + expect(result.orphanBlockersAssigned).toBe(0); + expect(result.issueIds).not.toContain(blockerIssueId); + + const blocker = await db + .select() + .from(issues) + .where(eq(issues.id, blockerIssueId)) + .then((rows) => rows[0] ?? null); + expect(blocker?.assigneeAgentId).toBeNull(); + + // A board-attention comment must have been posted instead. + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockerIssueId)); + expect(comments[0]?.body).toContain("Blocked on Board Review"); + expect(comments[0]?.body).toContain("Board action required"); + + // No wakeup should have been enqueued for the creator agent. + const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, creatorAgentId)); + expect(wakeups).toHaveLength(0); + }); + + it("assigns open unassigned blockers back to their creator agent when no responsibleUserId", async () => { + const companyId = randomUUID(); + const creatorAgentId = randomUUID(); + const blockedAssigneeAgentId = randomUUID(); + const blockerIssueId = randomUUID(); + const blockedIssueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + defaultResponsibleUserId: null, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: creatorAgentId, + companyId, + name: "SecurityEngineer", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: blockedAssigneeAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + await db.insert(issues).values([ + { + id: blockerIssueId, + companyId, + title: "Fix blocker", + status: "todo", + priority: "high", + createdByAgentId: creatorAgentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }, + { + id: blockedIssueId, + companyId, + title: "Blocked work", + status: "blocked", + priority: "high", + assigneeAgentId: blockedAssigneeAgentId, issueNumber: 2, identifier: `${issuePrefix}-2`, }, diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 6ad4b557de8..d6f27e1868b 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -936,6 +936,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) identifier: issues.identifier, status: issues.status, createdByAgentId: issues.createdByAgentId, + responsibleUserId: issues.responsibleUserId, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.issueId, issues.id)) @@ -965,6 +966,29 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (seen.has(candidate.id)) continue; seen.add(candidate.id); + // Board-owned blockers (responsibleUserId set) must never be auto-assigned + // to an agent — the human board member is the intended owner. Emit a + // board-attention comment and skip so the parent surfaces as awaiting + // board action rather than as a stalled orphan. + if (candidate.responsibleUserId) { + const relations = await issuesSvc.getRelationSummaries(candidate.id); + const blockingLinks = formatIssueLinksForComment(relations.blocks); + await issuesSvc.addComment( + candidate.id, + [ + "## Blocked on Board Review", + "", + `This issue is blocking ${blockingLinks} but has no agent assignee.`, + "", + "- It is owned by a board member (`responsibleUserId` is set) and will not be auto-assigned to an agent.", + "- **Board action required**: resolve or reassign this blocker to unblock the dependent work.", + ].join("\n"), + {}, + ); + skipped += 1; + continue; + } + const creatorAgentId = candidate.createdByAgentId; if (!creatorAgentId) { skipped += 1;