fix: preserve board-owned blocker recovery ownership

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-07-13 20:40:22 +00:00
1 parent ce7dedf33d
commit f47c19b02f
2 files changed
+98 -5

No files matched your search

@@ -4416,13 +4416,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).toContain("Next action:");
});
it("assigns open unassigned blockers back to their creator agent", async () => {
async function seedUnassignedBlockingIssueFixture(input: {
blockerResponsibleUserId: string | null;
blockerStatus?: "todo" | "blocked";
}) {
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",
@@ -4459,10 +4463,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
id: blockerIssueId,
companyId,
title: "Fix blocker",
status: "todo",
status: input.blockerStatus ?? "todo",
priority: "high",
createdByAgentId: creatorAgentId,
responsibleUserId: "responsible-user",
responsibleUserId: input.blockerResponsibleUserId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
},
@@ -4485,6 +4489,75 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
type: "blocks",
createdByAgentId: creatorAgentId,
});
return {
companyId,
creatorAgentId,
blockerIssueId,
blockedIssueId,
issuePrefix,
};
}
it("leaves board-owned open unassigned blockers for board review", async () => {
const { companyId, blockerIssueId, issuePrefix } = await seedUnassignedBlockingIssueFixture({
blockerResponsibleUserId: "responsible-user",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
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();
expect(blocker?.assigneeUserId).toBeNull();
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockerIssueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("Board-Owned Blocker");
expect(comments[0]?.body).toContain("Awaiting Board Review");
expect(comments[0]?.body).toContain(`[${issuePrefix}-2](/${issuePrefix}/issues/${issuePrefix}-2)`);
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId));
expect(wakeups).toEqual([]);
});
it("does not enqueue agent wakeups for board-owned unassigned blockers", async () => {
const { companyId, blockerIssueId } = await seedUnassignedBlockingIssueFixture({
blockerResponsibleUserId: "responsible-user",
blockerStatus: "blocked",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.orphanBlockersAssigned).toBe(0);
const blocker = await db
.select()
.from(issues)
.where(eq(issues.id, blockerIssueId))
.then((rows) => rows[0] ?? null);
expect(blocker?.assigneeAgentId).toBeNull();
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockerIssueId));
expect(comments[0]?.body).toContain("Board-Owned Blocker");
expect(comments[0]?.body).toContain("responsibleUserId");
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId));
expect(wakeups).toEqual([]);
});
it("assigns plain open unassigned blockers back to their creator agent", async () => {
const { creatorAgentId, blockerIssueId, issuePrefix } = await seedUnassignedBlockingIssueFixture({
blockerResponsibleUserId: null,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
@@ -4498,6 +4571,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.where(eq(issues.id, blockerIssueId))
.then((rows) => rows[0] ?? null);
expect(blocker?.assigneeAgentId).toBe(creatorAgentId);
expect(blocker?.responsibleUserId).toBeNull();
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockerIssueId));
expect(comments[0]?.body).toContain("Assigned Orphan Blocker");
+21 -2
View File
@@ -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,26 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (seen.has(candidate.id)) continue;
seen.add(candidate.id);
const relations = await issuesSvc.getRelationSummaries(candidate.id);
const blockingLinks = formatIssueLinksForComment(relations.blocks);
if (candidate.responsibleUserId != null) {
await issuesSvc.addComment(
candidate.id,
[
"## Board-Owned Blocker - Awaiting Board Review",
"",
`This issue is blocking ${blockingLinks} but has no agent assignee.`,
"",
"- It has a `responsibleUserId` set, so it is board/human-owned.",
"- It will not be auto-assigned to an agent.",
"- Action required: the responsible board owner should resolve or reassign this blocker.",
].join("\n"),
{},
);
skipped += 1;
continue;
}
const creatorAgentId = candidate.createdByAgentId;
if (!creatorAgentId) {
skipped += 1;
@@ -976,8 +997,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
continue;
}
const relations = await issuesSvc.getRelationSummaries(candidate.id);
const blockingLinks = formatIssueLinksForComment(relations.blocks);
const updated = await issuesSvc.update(candidate.id, {
assigneeAgentId: creatorAgent.id,
assigneeUserId: null,