mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
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:
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`,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user