fix(recovery): do not auto-assign board-owned blockers to the creator agent

Board-owned blocker issues (those with `responsibleUserId` set) were
being silently reassigned to the creating agent by the orphan-blocker
recovery path, causing board-assigned work items to land on the
creating agent instead of remaining with the board.

Guard: if `responsibleUserId` is non-null, skip agent reassignment and
post a "Blocked on Board Review / Board action required" comment
instead so the parent surfaces as awaiting a human action.

Plain orphaned blockers (no `responsibleUserId`) continue to recover
as before.

Tests:
- Renamed existing test (which already had responsibleUserId) to
  document the board-owned skip behaviour and assert no reassignment.
- Added new test for the plain-orphan path to cover the existing
  recovery behaviour.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-07-13 20:36:11 +00:00
1 parent ce7dedf33d
commit 0c7d90397a
2 files changed
+118 -3

No files matched your search

@@ -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`,
},
+24
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,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;