diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 74b6d48425d..38b5b912610 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -8,6 +8,7 @@ import { agentRuntimeState, agentWakeupRequests, budgetPolicies, + companyMemberships, companySecretBindings, companySecrets, companySkills, @@ -422,6 +423,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await db.delete(documents); await db.delete(companySecretBindings); await db.delete(companySecrets); + await db.delete(companyMemberships); try { await db.delete(companies); break; @@ -4430,6 +4432,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { defaultResponsibleUserId: "responsible-user", requireBoardApprovalForNewAgents: false, }); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: "responsible-user", + status: "active", + membershipRole: "owner", + }); await db.insert(agents).values([ { id: creatorAgentId, @@ -4498,15 +4507,21 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .where(eq(issues.id, blockerIssueId)) .then((rows) => rows[0] ?? null); expect(blocker?.assigneeAgentId).toBeNull(); + expect(blocker?.assigneeUserId).toBe("responsible-user"); // A board-attention comment must have been posted instead. const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockerIssueId)); + expect(comments).toHaveLength(1); 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); + + await heartbeat.reconcileStrandedAssignedIssues(); + const allComments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockerIssueId)); + expect(allComments).toHaveLength(1); }); it("assigns open unassigned blockers back to their creator agent when no responsibleUserId", async () => { @@ -4520,9 +4535,16 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { id: companyId, name: "Paperclip", issuePrefix, - defaultResponsibleUserId: null, + defaultResponsibleUserId: "responsible-user", requireBoardApprovalForNewAgents: false, }); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: "responsible-user", + status: "active", + membershipRole: "owner", + }); await db.insert(agents).values([ { id: creatorAgentId, diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index d6f27e1868b..a14cfdb8152 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -985,6 +985,30 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ].join("\n"), {}, ); + const updated = await issuesSvc.update(candidate.id, { + assigneeUserId: candidate.responsibleUserId, + }); + if (!updated) { + skipped += 1; + continue; + } + + await logActivity(db, { + companyId: candidate.companyId, + actorType: "system", + actorId: "system", + agentId: null, + runId: null, + action: "issue.updated", + entityType: "issue", + entityId: candidate.id, + details: { + identifier: candidate.identifier, + assigneeUserId: candidate.responsibleUserId, + source: "recovery.reconcile_unassigned_blocking_issue", + }, + }); + skipped += 1; continue; }