Add blocker relations and dependency wakeups

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-04 13:56:04 -05:00
parent 2f73346a64
commit dde4cc070e
18 changed files with 13924 additions and 69 deletions

View file

@ -120,9 +120,14 @@ describe("issue comment reopen routes", () => {
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@ -144,10 +149,15 @@ describe("issue comment reopen routes", () => {
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
status: "todo",
});
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
status: "todo",
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({

View file

@ -0,0 +1,195 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(async () => null),
update: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
findMentionedAgents: vi.fn(async () => []),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => ({
getIssueDocumentPayload: vi.fn(async () => ({})),
}),
executionWorkspaceService: () => ({
getById: vi.fn(),
}),
goalService: () => ({
getById: vi.fn(),
getDefaultCompanyGoal: vi.fn(),
}),
heartbeatService: () => ({
wakeup: mockWakeup,
reportRunActivity: vi.fn(async () => undefined),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({
getById: vi.fn(),
listByIds: vi.fn(async () => []),
}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({
listForIssue: vi.fn(async () => []),
}),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.status ?? 500).json({ error: err?.message ?? "Internal server error" });
});
return app;
}
describe("issue dependency wakeups in issue routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
});
it("wakes dependents when the final blocker transitions to done", async () => {
mockIssueService.getById.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PAP-100",
title: "Finish blocker",
description: null,
status: "blocked",
priority: "medium",
parentId: null,
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionWorkspaceId: null,
labels: [],
labelIds: [],
});
mockIssueService.update.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PAP-100",
title: "Finish blocker",
description: null,
status: "done",
priority: "medium",
parentId: null,
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionWorkspaceId: null,
labels: [],
labelIds: [],
});
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
{
id: "issue-2",
assigneeAgentId: "agent-2",
blockerIssueIds: ["issue-1", "issue-3"],
},
]);
const res = await request(createApp()).patch("/api/issues/issue-1").send({ status: "done" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(res.status).toBe(200);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-2",
expect.objectContaining({
reason: "issue_blockers_resolved",
payload: expect.objectContaining({
issueId: "issue-2",
resolvedBlockerIssueId: "issue-1",
}),
}),
);
});
it("wakes the parent when all direct children become terminal", async () => {
mockIssueService.getById.mockResolvedValue({
id: "child-1",
companyId: "company-1",
identifier: "PAP-101",
title: "Last child",
description: null,
status: "in_progress",
priority: "medium",
parentId: "parent-1",
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionWorkspaceId: null,
labels: [],
labelIds: [],
});
mockIssueService.update.mockResolvedValue({
id: "child-1",
companyId: "company-1",
identifier: "PAP-101",
title: "Last child",
description: null,
status: "done",
priority: "medium",
parentId: "parent-1",
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionWorkspaceId: null,
labels: [],
labelIds: [],
});
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue({
id: "parent-1",
assigneeAgentId: "agent-9",
childIssueIds: ["child-0", "child-1"],
});
const res = await request(createApp()).patch("/api/issues/child-1").send({ status: "done" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(res.status).toBe(200);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-9",
expect.objectContaining({
reason: "issue_children_completed",
payload: expect.objectContaining({
issueId: "parent-1",
completedChildIssueId: "child-1",
}),
}),
);
});
});

View file

@ -7,6 +7,7 @@ import { errorHandler } from "../middleware/index.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getAncestors: vi.fn(),
getRelationSummaries: vi.fn(),
findMentionedProjectIds: vi.fn(),
getCommentCursor: vi.fn(),
getComment: vi.fn(),
@ -123,6 +124,7 @@ describe("issue goal context routes", () => {
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
mockIssueService.getCommentCursor.mockResolvedValue({
totalComments: 0,
@ -201,4 +203,33 @@ describe("issue goal context routes", () => {
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
expect(res.body.attachments).toEqual([]);
});
it("surfaces blocker summaries on GET /issues/:id/heartbeat-context", async () => {
mockIssueService.getRelationSummaries.mockResolvedValue({
blockedBy: [
{
id: "55555555-5555-4555-8555-555555555555",
identifier: "PAP-580",
title: "Finish wakeup plumbing",
status: "done",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
});
const res = await request(createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
expect(res.status).toBe(200);
expect(res.body.issue.blockedBy).toEqual([
expect.objectContaining({
id: "55555555-5555-4555-8555-555555555555",
identifier: "PAP-580",
}),
]);
});
});

View file

@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import {
activityLog,
agents,
@ -10,6 +11,7 @@ import {
instanceSettings,
issueComments,
issueInboxArchives,
issueRelations,
issues,
projectWorkspaces,
projects,
@ -24,6 +26,22 @@ import { issueService } from "../services/issues.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function ensureIssueRelationsTable(db: ReturnType<typeof createDb>) {
await db.execute(sql.raw(`
CREATE TABLE IF NOT EXISTS "issue_relations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"related_issue_id" uuid NOT NULL,
"type" text NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
`));
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
@ -39,10 +57,12 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
@ -594,10 +614,12 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
@ -859,3 +881,210 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
});
});
});
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const blockerId = randomUUID();
const blockedId = randomUUID();
await db.insert(issues).values([
{
id: blockerId,
companyId,
title: "Blocker",
status: "todo",
priority: "high",
},
{
id: blockedId,
companyId,
title: "Blocked issue",
status: "blocked",
priority: "medium",
},
]);
await svc.update(blockedId, {
blockedByIssueIds: [blockerId],
});
const blockerRelations = await svc.getRelationSummaries(blockerId);
const blockedRelations = await svc.getRelationSummaries(blockedId);
expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]);
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
});
it("rejects blocking cycles", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const issueA = randomUUID();
const issueB = randomUUID();
await db.insert(issues).values([
{ id: issueA, companyId, title: "Issue A", status: "todo", priority: "medium" },
{ id: issueB, companyId, title: "Issue B", status: "todo", priority: "medium" },
]);
await svc.update(issueA, { blockedByIssueIds: [issueB] });
await expect(
svc.update(issueB, { blockedByIssueIds: [issueA] }),
).rejects.toMatchObject({ status: 422 });
});
it("only returns dependents once every blocker is done", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const blockerA = randomUUID();
const blockerB = randomUUID();
const blockedIssueId = randomUUID();
await db.insert(issues).values([
{ id: blockerA, companyId, title: "Blocker A", status: "done", priority: "medium" },
{ id: blockerB, companyId, title: "Blocker B", status: "todo", priority: "medium" },
{
id: blockedIssueId,
companyId,
title: "Blocked issue",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
]);
await svc.update(blockedIssueId, { blockedByIssueIds: [blockerA, blockerB] });
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([]);
await svc.update(blockerB, { status: "done" });
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([
{
id: blockedIssueId,
assigneeAgentId,
blockerIssueIds: [blockerA, blockerB],
},
]);
});
it("wakes parents only when all direct children are terminal", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const parentId = randomUUID();
const childA = randomUUID();
const childB = randomUUID();
await db.insert(issues).values([
{
id: parentId,
companyId,
title: "Parent issue",
status: "todo",
priority: "medium",
assigneeAgentId,
},
{
id: childA,
companyId,
parentId,
title: "Child A",
status: "done",
priority: "medium",
},
{
id: childB,
companyId,
parentId,
title: "Child B",
status: "blocked",
priority: "medium",
},
]);
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull();
await svc.update(childB, { status: "cancelled" });
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toEqual({
id: parentId,
assigneeAgentId,
childIssueIds: [childA, childB],
});
});
});