mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
fix: preserve board-owned blocker recovery ownership
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user