diff --git a/packages/db/src/migrations/0184_routable_blocked.sql b/packages/db/src/migrations/0184_routable_blocked.sql new file mode 100644 index 0000000000..b1979ae6e9 --- /dev/null +++ b/packages/db/src/migrations/0184_routable_blocked.sql @@ -0,0 +1,3 @@ +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "unblock_descriptor" jsonb;--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "blocked_transition_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "blocked_owner_notified_at" timestamp with time zone; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index d701d6b235..3bed050665 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1275,6 +1275,13 @@ "when": 1784653200000, "tag": "0183_connection_user_authorization_state", "breakpoints": true + }, + { + "idx": 184, + "version": "7", + "when": 1784822400000, + "tag": "0184_routable_blocked", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index a7bc5a14fc..5be902a325 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -18,6 +18,7 @@ import { heartbeatRuns } from "./heartbeat_runs.js"; import { projectWorkspaces } from "./project_workspaces.js"; import { executionWorkspaces } from "./execution_workspaces.js"; import type { SourceTrustMetadata } from "@paperclipai/shared"; +import type { IssueUnblockDescriptor } from "@paperclipai/shared"; export const issues = pgTable( "issues", @@ -65,6 +66,9 @@ export const issues = pgTable( executionWorkspacePreference: text("execution_workspace_preference"), executionWorkspaceSettings: jsonb("execution_workspace_settings").$type>(), sourceTrust: jsonb("source_trust").$type(), + unblockDescriptor: jsonb("unblock_descriptor").$type(), + blockedTransitionAt: timestamp("blocked_transition_at", { withTimezone: true }), + blockedOwnerNotifiedAt: timestamp("blocked_owner_notified_at", { withTimezone: true }), startedAt: timestamp("started_at", { withTimezone: true }), completedAt: timestamp("completed_at", { withTimezone: true }), cancelledAt: timestamp("cancelled_at", { withTimezone: true }), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a9aad0542a..e2957778a8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -833,6 +833,8 @@ export type { IssueInboxAttentionKind, IssueBlockedInboxAction, IssueBlockedInboxAttention, + IssueUnblockDescriptor, + IssueUnblockOwner, IssueBlockedInboxIssueRef, IssueBlockedInboxOwner, IssueBlockedInboxOwnerType, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ee8c9eae23..f35cc14894 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -561,6 +561,8 @@ export type { IssueInboxAttentionKind, IssueBlockedInboxAction, IssueBlockedInboxAttention, + IssueUnblockDescriptor, + IssueUnblockOwner, IssueBlockedInboxIssueRef, IssueBlockedInboxOwner, IssueBlockedInboxOwnerType, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 7fe47abe89..e4639f87f7 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -472,6 +472,13 @@ export interface IssueBlockedInboxAttention { }; } +export type IssueUnblockOwner = { agentId: string } | { userId: string } | "board"; + +export interface IssueUnblockDescriptor { + owner: IssueUnblockOwner; + action: string; +} + export type IssueProductivityReviewTrigger = | "no_comment_streak" | "long_active_duration" @@ -754,6 +761,9 @@ export interface Issue { blocks?: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention; blockedInboxAttention?: IssueBlockedInboxAttention | null; + unblockDescriptor?: IssueUnblockDescriptor | null; + blockedTransitionAt?: Date | null; + blockedOwnerNotifiedAt?: Date | null; productivityReview?: IssueProductivityReview | null; activeRecoveryAction?: IssueRecoveryAction | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index f766dd7890..264152c62f 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -48,6 +48,29 @@ describe("issue validators", () => { expect(parsed.comment).toBe("Done\n\n- Verified the route"); }); + it("validates structured unblock descriptors", () => { + expect(updateIssueSchema.parse({ + status: "blocked", + unblockDescriptor: { owner: { agentId: "00000000-0000-4000-8000-000000000001" }, action: "Review the finding" }, + }).unblockDescriptor).toEqual({ + owner: { agentId: "00000000-0000-4000-8000-000000000001" }, + action: "Review the finding", + }); + expect(updateIssueSchema.safeParse({ + status: "blocked", + unblockDescriptor: { owner: { agentId: "not-a-uuid" }, action: "Review" }, + }).success).toBe(false); + expect(updateIssueSchema.safeParse({ + status: "blocked", + unblockDescriptor: { owner: "board", action: " " }, + }).success).toBe(false); + expect(createIssueSchema.safeParse({ + title: "Invalid descriptor status", + status: "todo", + unblockDescriptor: { owner: "board", action: "Review" }, + }).success).toBe(false); + }); + it("keeps issue attribution fields create-only", () => { const created = createIssueSchema.parse({ title: "Preserve attribution input for route checks", diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index a229f29918..e25c34bc54 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -381,6 +381,14 @@ const createIssueBaseSchema = z.object({ goalId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), blockedByIssueIds: z.array(z.string().uuid()).optional(), + unblockDescriptor: z.object({ + owner: z.union([ + z.object({ agentId: z.string().uuid() }).strict(), + z.object({ userId: z.string().trim().min(1) }).strict(), + z.literal("board"), + ]), + action: multilineTextSchema.pipe(z.string().trim().min(1).max(2_000)), + }).strict().optional().nullable(), inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(), title: z.string().min(1), description: multilineTextSchema.optional().nullable(), @@ -410,6 +418,19 @@ const createIssueBaseSchema = z.object({ }).strict().optional().nullable(), }); +function requireBlockedStatusForUnblockDescriptor( + value: { status?: string; unblockDescriptor?: unknown }, + ctx: z.RefinementCtx, +) { + if (value.unblockDescriptor != null && value.status !== undefined && value.status !== "blocked") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "unblockDescriptor requires blocked status", + path: ["unblockDescriptor"], + }); + } +} + const createIssueDuplicateGuardSchema = { idempotencyKey: z.string().trim().min(1).max(255).optional().nullable(), allowDuplicate: z.boolean() @@ -423,7 +444,9 @@ export const createIssueInputSchema = createIssueBaseSchema.extend({ ...createIssueDuplicateGuardSchema, }); -export const createIssueSchema = withCreateIssueStatusDefault(createIssueBaseSchema.extend(createIssueDuplicateGuardSchema)); +export const createIssueSchema = withCreateIssueStatusDefault( + createIssueBaseSchema.extend(createIssueDuplicateGuardSchema), +).superRefine(requireBlockedStatusForUnblockDescriptor); export type CreateIssue = z.infer; @@ -443,7 +466,7 @@ export const createChildIssueSchema = withCreateIssueStatusDefault(createIssueBa .extend({ acceptanceCriteria: z.array(z.string().trim().min(1).max(500)).max(20).optional(), blockParentUntilDone: z.boolean().optional().default(false), - })); + })).superRefine(requireBlockedStatusForUnblockDescriptor); export type CreateChildIssue = z.infer; diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index 633149f5d5..ed4ed8eff5 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -35,6 +35,7 @@ import { import { errorHandler } from "../middleware/index.js"; import { attentionRoutes } from "../routes/attention.js"; import { attentionService } from "../services/attention.js"; +import { ROUTABLE_BLOCKED_ROLLOUT_AT } from "../services/routable-blocked.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -153,6 +154,8 @@ describeEmbeddedPostgres("attention service", () => { executionState?: Record | null; updatedAt?: Date; createdAt?: Date; + unblockDescriptor?: { owner: { userId: string } | "board"; action: string } | null; + blockedTransitionAt?: Date | null; }) { const id = input.id ?? randomUUID(); await db.insert(issues).values({ @@ -171,6 +174,8 @@ describeEmbeddedPostgres("attention service", () => { originId: input.originId ?? null, originFingerprint: input.originFingerprint ?? "default", executionState: input.executionState ?? null, + unblockDescriptor: input.unblockDescriptor ?? null, + blockedTransitionAt: input.blockedTransitionAt ?? null, createdAt: input.createdAt, updatedAt: input.updatedAt, }); @@ -248,6 +253,7 @@ describeEmbeddedPostgres("attention service", () => { identifier: "ATN-4", title: "Blocked parent", status: "blocked", + blockedTransitionAt: new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() + 1), updatedAt: new Date("2026-07-09T12:04:00.000Z"), }); const blockerLeafId = await insertIssue({ @@ -887,6 +893,57 @@ describeEmbeddedPostgres("attention service", () => { expect(feed.items.some((item) => item.dedupKey === `approval:${approvalId}`)).toBe(true); }); + it("delivers a structured human unblock descriptor once per blocked transition", async () => { + const { companyId } = await seedCompany("ATU"); + const transitionAt = new Date("2026-07-23T18:30:00.000Z"); + const issueId = await insertIssue({ + companyId, + identifier: "ATU-1", + title: "Needs board action", + status: "blocked", + unblockDescriptor: { owner: "board", action: "Approve the exception" }, + blockedTransitionAt: transitionAt, + }); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const items = feed.items.filter((item) => item.dedupKey === `blocked-owner:${issueId}:${transitionAt.toISOString()}`); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ sourceKind: "blocker_attention", whyNow: "Approve the exception" }); + }); + + it("keeps legacy blocker attention visible for pre-rollout blocked issues", async () => { + const { companyId } = await seedCompany("ATP"); + const issueId = await insertIssue({ + companyId, + identifier: "ATP-1", + title: "Blocked before rollout", + status: "blocked", + blockedTransitionAt: new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() - 1), + }); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + + expect(feed.items.some((item) => item.dedupKey === `blocker:${issueId}:ATP-1`)).toBe(true); + }); + + it("does not route pre-rollout human unblock descriptors", async () => { + const { companyId } = await seedCompany("ATQ"); + const transitionAt = new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() - 1); + const issueId = await insertIssue({ + companyId, + identifier: "ATQ-1", + title: "Human-owned before rollout", + status: "blocked", + unblockDescriptor: { owner: "board", action: "Review the issue" }, + blockedTransitionAt: transitionAt, + }); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + + expect(feed.items.some((item) => item.dedupKey === `blocked-owner:${issueId}:${transitionAt.toISOString()}`)).toBe(false); + }); + it("returns one pending approval row when the approval is linked to multiple tasks", async () => { const { companyId } = await seedCompany("ATM"); const approvalId = randomUUID(); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 1a5dd41613..15381dc270 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -20,6 +20,7 @@ const mockIssueService = vi.hoisted(() => ({ getByIdentifier: vi.fn(), getById: vi.fn(), getComment: vi.fn(), + getDependencyReadiness: vi.fn(), getRelationSummaries: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), list: vi.fn(), @@ -286,9 +287,13 @@ function createRunContextDb( return [{ id: runAgentId, companyId: runAgentCompanyId, permissions: {}, role: "engineer", reportsTo: null }]; }; const buildQuery = (selection: Record) => { + const rows = rowsForSelection(selection); const whereResult = { orderBy: vi.fn(async () => []), - then: async (resolve: (rows: unknown[]) => unknown) => resolve(rowsForSelection(selection)), + limit: vi.fn(() => ({ + then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(rows), + })), + then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(rows), }; const query = { innerJoin: vi.fn(() => query), @@ -415,6 +420,12 @@ describe("agent issue mutation checkout ownership", () => { mockIssueService.getByIdentifier.mockReset(); mockIssueService.getById.mockReset(); mockIssueService.getComment.mockReset(); + mockIssueService.getDependencyReadiness.mockReset(); + mockIssueService.getDependencyReadiness.mockResolvedValue({ + blockerIssueIds: [], + isDependencyReady: false, + unresolvedBlockerCount: 0, + }); mockIssueService.getRelationSummaries.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); mockIssueService.list.mockReset(); @@ -1511,6 +1522,59 @@ describe("agent issue mutation checkout ownership", () => { }); }); + it.each([ + ["board", "board"], + ["a company user", { userId: "board-user" }], + ])("rejects an agent naming %s as unblock owner", async (_label, unblockOwner) => { + mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress" })); + + const res = await request(await createApp(ownerActor())).patch(`/api/issues/${issueId}`).send({ + status: "blocked", + unblockDescriptor: { owner: unblockOwner, action: "Review the blocker" }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agents may only name themselves as an unblock owner"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it.each([ + ["board", "board"], + ["a company user", { userId: "board-user" }], + ])("rejects an agent changing an already-blocked issue owner to %s", async (_label, unblockOwner) => { + mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked" })); + + const res = await request(await createApp(ownerActor())).patch(`/api/issues/${issueId}`).send({ + unblockDescriptor: { owner: unblockOwner, action: "Review the blocker" }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agents may only name themselves as an unblock owner"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("allows a board actor to name the board as unblock owner", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress" })); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue({ status: "in_progress" }), + ...patch, + })); + + const res = await request(await createApp(boardActor())).patch(`/api/issues/${issueId}`).send({ + status: "blocked", + unblockDescriptor: { owner: "board", action: "Review the blocker" }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + issueId, + expect.objectContaining({ + status: "blocked", + unblockDescriptor: { owner: "board", action: "Review the blocker" }, + }), + ); + }); + it("rejects peer-agent status updates that would clear a recovery action they do not own", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), @@ -1706,9 +1770,13 @@ describe("agent issue mutation checkout ownership", () => { return [{ id: peerAgentId, companyId, permissions: {}, role: "engineer", reportsTo: null }]; }; const buildQuery = (selection: Record) => { + const rows = rowsForSelection(selection); const whereResult = { orderBy: vi.fn(async () => []), - then: async (resolve: (rows: unknown[]) => unknown) => resolve(rowsForSelection(selection)), + limit: vi.fn(() => ({ + then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(rows), + })), + then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(rows), }; const query = { innerJoin: vi.fn(() => query), diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index da27484dbf..f26c07b73a 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -101,6 +101,19 @@ vi.mock("../services/issue-dependency-wakeups.js", async () => { }); async function createApp() { + const emptyRows: unknown[] = []; + const whereResult = { + limit: vi.fn(async () => emptyRows), + then: async (resolve: (rows: unknown[]) => unknown) => resolve(emptyRows), + }; + const query: Record = {}; + query.innerJoin = vi.fn(() => query); + query.where = vi.fn(() => whereResult); + const routeDb = { + select: vi.fn(() => ({ + from: vi.fn(() => query), + })), + }; const [{ issueRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/issues.js"), vi.importActual("../middleware/index.js"), @@ -117,7 +130,7 @@ async function createApp() { }; next(); }); - app.use("/api", issueRoutes({} as any, {} as any)); + app.use("/api", issueRoutes(routeDb as any, {} as any)); app.use(errorHandler); return app; } @@ -259,7 +272,11 @@ describe("issue dependency wakeups in issue routes", () => { const res = await request(await createApp()) .patch(`/api/issues/${parentIssueId}`) - .send({ status: "blocked", blockedByIssueIds: [childIssueId] }); + .send({ + status: "blocked", + blockedByIssueIds: [childIssueId], + unblockDescriptor: { owner: "board", action: "Review the restored dependency" }, + }); expect(res.status).toBe(200); await vi.waitFor(() => { diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 90a84f5187..fdf5574386 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -846,14 +846,23 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => it("relays blocked and cancelled stops once without laundering child prose", async () => { const fixture = await seedLowTrustFixture(db); const app = createApp(db, boardActor(fixture)); + const unblockDescriptor = { owner: "board", action: "Review the low-trust stop" } as const; + + await db + .delete(issueApprovals) + .where(eq(issueApprovals.issueId, fixture.issues.assignedReview.id)); const blocked = await request(app) .patch(`/api/issues/${fixture.issues.assignedReview.id}`) - .send({ status: "blocked", comment: fixture.canaries.raw }); + .send({ status: "blocked", comment: fixture.canaries.raw, unblockDescriptor }); expect(blocked.status, JSON.stringify(blocked.body)).toBe(200); + expect(blocked.body.unblockDescriptor).toEqual(unblockDescriptor); await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "todo" }).expect(200); - await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "blocked" }).expect(200); + await request(app) + .patch(`/api/issues/${fixture.issues.assignedReview.id}`) + .send({ status: "blocked", unblockDescriptor }) + .expect(200); await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "todo" }).expect(200); await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "cancelled" }).expect(200); await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "todo" }).expect(200); @@ -863,10 +872,13 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => .where(eq(issues.id, fixture.issues.assignedReview.id)); await request(app) .patch(`/api/issues/${fixture.issues.assignedReview.id}`) - .send({ parentId: fixture.issues.reviewGrandparent.id, status: "blocked" }) + .send({ parentId: fixture.issues.reviewGrandparent.id, status: "blocked", unblockDescriptor }) .expect(200); - await request(app).patch(`/api/issues/${fixture.issues.standardChild.id}`).send({ status: "blocked" }).expect(200); + await request(app) + .patch(`/api/issues/${fixture.issues.standardChild.id}`) + .send({ status: "blocked", unblockDescriptor }) + .expect(200); await request(app).patch(`/api/issues/${fixture.issues.standardChild.id}`).send({ status: "todo" }).expect(200); await request(app).patch(`/api/issues/${fixture.issues.standardChild.id}`).send({ status: "in_review" }).expect(200); await request(app).patch(`/api/issues/${fixture.issues.standardChild.id}`).send({ status: "done" }).expect(200); diff --git a/server/src/__tests__/routable-blocked.test.ts b/server/src/__tests__/routable-blocked.test.ts new file mode 100644 index 0000000000..1c0cd011a3 --- /dev/null +++ b/server/src/__tests__/routable-blocked.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { + deliverAgentUnblockNotification, + ROUTABLE_BLOCKED_ROLLOUT_AT, +} from "../services/routable-blocked.js"; + +const agentId = "00000000-0000-4000-8000-000000000001"; + +function blockedIssue(input: { + transitionAt?: Date | null; + notifiedAt?: Date | null; +} = {}) { + return { + id: "00000000-0000-4000-8000-000000000002", + status: "blocked", + unblockDescriptor: { owner: { agentId }, action: "Review the finding" } as const, + blockedTransitionAt: input.transitionAt === undefined + ? new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() + 1) + : input.transitionAt, + blockedOwnerNotifiedAt: input.notifiedAt ?? null, + }; +} + +describe("routable blocked notifications", () => { + it("wakes the named agent and records delivery on a prospective transition", async () => { + const wakeup = vi.fn(async () => undefined); + const markNotified = vi.fn(async () => undefined); + const now = new Date("2026-07-23T18:30:00.000Z"); + const issue = blockedIssue(); + + await expect(deliverAgentUnblockNotification({ issue, wakeup, markNotified, now: () => now })) + .resolves.toBe(true); + expect(wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({ + reason: "issue_unblock_requested", + idempotencyKey: `issue-unblock:${issue.id}:${issue.blockedTransitionAt!.toISOString()}`, + payload: { issueId: issue.id, action: "Review the finding" }, + })); + expect(markNotified).toHaveBeenCalledWith(now); + }); + + it("leaves pre-existing blocked issues untouched", async () => { + const wakeup = vi.fn(async () => undefined); + const markNotified = vi.fn(async () => undefined); + + await expect(deliverAgentUnblockNotification({ + issue: blockedIssue({ transitionAt: new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() - 1) }), + wakeup, + markNotified, + })).resolves.toBe(false); + expect(wakeup).not.toHaveBeenCalled(); + expect(markNotified).not.toHaveBeenCalled(); + }); + + it("deduplicates one transition and notifies again after a blocked flap", async () => { + const wakeup = vi.fn(async () => undefined); + const markNotified = vi.fn(async () => undefined); + const firstTransition = new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() + 1); + const secondTransition = new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() + 2); + + await deliverAgentUnblockNotification({ + issue: blockedIssue({ transitionAt: firstTransition, notifiedAt: new Date() }), + wakeup, + markNotified, + }); + await deliverAgentUnblockNotification({ + issue: blockedIssue({ transitionAt: secondTransition }), + wakeup, + markNotified, + }); + + expect(wakeup).toHaveBeenCalledTimes(1); + expect(wakeup.mock.calls[0]?.[1]).toMatchObject({ + idempotencyKey: expect.stringContaining(secondTransition.toISOString()), + }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0e00405a9d..36d74da9c3 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -7,13 +7,17 @@ import type { Db } from "@paperclipai/db"; import { activityLog, agents, + approvals, + companyMemberships, documents, executionWorkspaces, heartbeatRuns, + issueApprovals, issueComments, issueDocuments, issueExecutionDecisions, issueRelations, + issueThreadInteractions, issues as issueRows, issueWorkProducts, pipelineCaseIssueLinks, @@ -192,6 +196,7 @@ import { type TrustPresetResolution, } from "../services/trust-preset-resolver.js"; import { externalObjectService } from "../services/external-objects.js"; +import { deliverAgentUnblockNotification } from "../services/routable-blocked.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; const updateIssueRouteSchema = updateIssueSchema.extend({ @@ -7901,6 +7906,65 @@ export function issueRoutes( }; } Object.assign(updateFields, transition.patch); + + const nextStatus = updateFields.status ?? existing.status; + if (updateFields.unblockDescriptor && nextStatus !== "blocked") { + throw unprocessable("unblockDescriptor requires blocked status"); + } + const descriptor = updateFields.unblockDescriptor ?? null; + if (descriptor && typeof descriptor === "object") { + const owner = descriptor.owner; + if (req.actor.type === "agent" && (owner === "board" || "userId" in owner)) { + throw forbidden("Agents may only name themselves as an unblock owner"); + } + if (owner !== "board" && "agentId" in owner) { + const target = await db.select({ id: agents.id }).from(agents).where(and( + eq(agents.id, owner.agentId), + eq(agents.companyId, existing.companyId), + )).limit(1).then((rows) => rows[0] ?? null); + if (!target) throw unprocessable("Unblock owner agent must belong to the issue company"); + if (req.actor.type === "agent" && req.actor.agentId !== owner.agentId) { + throw forbidden("Agents may only name themselves as an unblock owner"); + } + } else if (owner !== "board" && "userId" in owner) { + const member = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, existing.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, owner.userId), + eq(companyMemberships.status, "active"), + )).limit(1).then((rows) => rows[0] ?? null); + if (!member) throw unprocessable("Unblock owner user must be an active company member"); + } + } + const enteringBlocked = existing.status !== "blocked" && updateFields.status === "blocked"; + if (enteringBlocked) { + const requestedBlockerIds = Array.isArray(req.body.blockedByIssueIds) + ? [...new Set(req.body.blockedByIssueIds as string[])] + : null; + const hasUnresolvedBlocker = requestedBlockerIds + ? requestedBlockerIds.length > 0 && await db.select({ id: issueRows.id }).from(issueRows).where(and( + eq(issueRows.companyId, existing.companyId), + inArray(issueRows.id, requestedBlockerIds), + notInArray(issueRows.status, ["done", "cancelled"]), + )).limit(1).then((rows) => rows.length > 0) + : (await svc.getDependencyReadiness(existing.id)).unresolvedBlockerCount > 0; + const [pendingInteraction, pendingApproval] = await Promise.all([ + db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.companyId, existing.companyId), + eq(issueThreadInteractions.issueId, existing.id), + eq(issueThreadInteractions.status, "pending"), + )).limit(1).then((rows) => rows[0] ?? null), + db.select({ id: approvals.id }).from(issueApprovals).innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)).where(and( + eq(issueApprovals.companyId, existing.companyId), + eq(issueApprovals.issueId, existing.id), + eq(approvals.status, "pending"), + )).limit(1).then((rows) => rows[0] ?? null), + ]); + if (!hasUnresolvedBlocker && !pendingInteraction && !pendingApproval && !descriptor) { + res.status(422).json({ error: "Entering blocked requires unresolved blockers, a pending interaction/approval, or unblockDescriptor" }); + return; + } + } if (reviewRequest !== undefined && transition.patch.executionState === undefined) { const existingExecutionState = parseIssueExecutionState(existing.executionState); if (!existingExecutionState || existingExecutionState.status !== "pending") { @@ -7981,7 +8045,7 @@ export function issueRoutes( const stopRelayResult: { value: Awaited>; } = { value: null }; - let issue; + let issue: Awaited>; try { if (transition.decision && decisionId) { const decision = transition.decision; @@ -8062,6 +8126,25 @@ export function issueRoutes( return; } + if (enteringBlocked) { + const blockedIssue = issue; + let ownerNotifiedAt: Date | null = null; + await deliverAgentUnblockNotification({ + issue: blockedIssue, + wakeup: heartbeat.wakeup, + markNotified: async (blockedOwnerNotifiedAt) => { + ownerNotifiedAt = blockedOwnerNotifiedAt; + }, + }); + if (ownerNotifiedAt) { + await db.update(issueRows).set({ blockedOwnerNotifiedAt: ownerNotifiedAt }).where(and( + eq(issueRows.id, blockedIssue.id), + eq(issueRows.companyId, blockedIssue.companyId), + )); + issue = { ...blockedIssue, blockedOwnerNotifiedAt: ownerNotifiedAt }; + } + } + let cancelledStatusRunId: string | null = null; if (runToCancelForCancelledStatus) { try { diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index b419d1c858..f70c63d50c 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -39,6 +39,7 @@ import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js"; import { budgetService } from "./budgets.js"; import { issueService } from "./issues.js"; import { parseIssueExecutionState } from "./issue-execution-policy.js"; +import { isProspectiveBlockedTransition } from "./routable-blocked.js"; const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [ "approval", @@ -918,9 +919,40 @@ export function attentionService(db: Db) { const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id)); const blockedImageMap = await issueImageMap(db, companyId, blockedIssues.map((issue) => issue.id)); const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id)); - for (const issue of blockedIssues as Array) { + for (const issue of blockedIssues as Array) { + const descriptor = issue.unblockDescriptor; + const humanOwnerMatches = descriptor?.owner === "board" + || (descriptor?.owner && "userId" in descriptor.owner && descriptor.owner.userId === options.userId); + if (descriptor && humanOwnerMatches && isProspectiveBlockedTransition(issue)) { + const issueSummary = blockedIssueSummaries.get(issue.id) ?? null; + add(createItem({ + companyId, + sourceKind: "blocker_attention", + subject: issueSubject(prefix, issueSummary ?? issue), + whyNow: descriptor.action, + decisionVerbs: decisionVerbs( + { id: "unblock", label: "Unblock", description: descriptor.action }, + { id: "reassign", label: "Reassign", description: "Route this blocked issue to another owner." }, + ), + inlineResolvable: false, + entryRule: "blocked issue has a human-owned unblockDescriptor", + exitRule: "Issue leaves blocked status.", + dedupKey: `blocked-owner:${issue.id}:${issue.blockedTransitionAt.toISOString()}`, + severity: "high", + activityAt: toIso(issue.blockedTransitionAt), + createdAt: toIso(issue.createdAt), + updatedAt: toIso(issue.updatedAt), + relatedIssue: null, + ...issueContext(issueSummary), + detail: { kind: "blocker", blockingIssue: { id: issue.id, identifier: issue.identifier, title: issue.title }, images: issueImages(blockedImageMap, issue.id) }, + })); + } const blockerAttention = issue.blockerAttention; - if (blockerAttention?.state !== "stalled") continue; + if (blockerAttention?.state !== "stalled" && blockerAttention?.state !== "needs_attention") continue; const issueSummary = blockedIssueSummaries.get(issue.id) ?? null; const summarizedIssue = issueSummary ?? issue; const sample = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier ?? issue.identifier ?? issue.id; @@ -930,14 +962,16 @@ export function attentionService(db: Db) { companyId, sourceKind: "blocker_attention", subject: issueSubject(prefix, summarizedIssue), - whyNow: "Blocked dependency chain is stalled and needs a human to choose the next owner or action.", + whyNow: blockerAttention.state === "needs_attention" + ? "Blocked dependency chain needs human attention." + : "Blocked dependency chain is stalled and needs a human to choose the next owner or action.", decisionVerbs: decisionVerbs( { id: "unblock", label: "Unblock", description: "Repair or replace the stalled blocker path." }, { id: "reassign", label: "Reassign", description: "Assign the stalled blocker to a live owner." }, { id: "nudge", label: "Nudge", description: "Wake or prompt the current owner." }, ), inlineResolvable: false, - entryRule: "blocked issue has blockerAttention.state = 'stalled'", + entryRule: `blocked issue has blockerAttention.state = '${blockerAttention.state}'`, exitRule: "Blocker chain is no longer stalled or the issue leaves blocked status.", dedupKey, severity: "high", diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 9c83df4911..5460f82e16 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -2594,6 +2594,9 @@ const issueListSelect = { executionWorkspacePreference: issues.executionWorkspacePreference, executionWorkspaceSettings: sql`null`, sourceTrust: issues.sourceTrust, + unblockDescriptor: issues.unblockDescriptor, + blockedTransitionAt: issues.blockedTransitionAt, + blockedOwnerNotifiedAt: issues.blockedOwnerNotifiedAt, startedAt: issues.startedAt, completedAt: issues.completedAt, cancelledAt: issues.cancelledAt, @@ -6596,6 +6599,14 @@ export function issueService(db: Db) { ...issueData, updatedAt: new Date(), }; + if (existing.status !== "blocked" && issueData.status === "blocked") { + patch.blockedTransitionAt = patch.updatedAt; + patch.blockedOwnerNotifiedAt = null; + } else if (existing.status === "blocked" && issueData.status && issueData.status !== "blocked") { + patch.unblockDescriptor = null; + patch.blockedTransitionAt = null; + patch.blockedOwnerNotifiedAt = null; + } if (issueData.requestDepth !== undefined) { patch.requestDepth = clampIssueRequestDepth(issueData.requestDepth); } diff --git a/server/src/services/routable-blocked.ts b/server/src/services/routable-blocked.ts new file mode 100644 index 0000000000..d2b91b774c --- /dev/null +++ b/server/src/services/routable-blocked.ts @@ -0,0 +1,54 @@ +import type { IssueUnblockDescriptor } from "@paperclipai/shared"; + +export const ROUTABLE_BLOCKED_ROLLOUT_AT = new Date("2026-07-23T18:13:03.000Z"); + +type RoutableBlockedIssue = { + id: string; + status: string; + unblockDescriptor?: IssueUnblockDescriptor | null; + blockedTransitionAt?: Date | null; + blockedOwnerNotifiedAt?: Date | null; +}; + +type ProspectiveBlockedIssue = RoutableBlockedIssue & { + status: "blocked"; + blockedTransitionAt: Date; +}; + +export function isProspectiveBlockedTransition(issue: RoutableBlockedIssue): issue is ProspectiveBlockedIssue { + return issue.status === "blocked" && + Boolean(issue.blockedTransitionAt && issue.blockedTransitionAt >= ROUTABLE_BLOCKED_ROLLOUT_AT); +} + +export async function deliverAgentUnblockNotification(input: { + issue: RoutableBlockedIssue; + wakeup: (agentId: string, options: { + source: "automation"; + triggerDetail: "system"; + reason: "issue_unblock_requested"; + idempotencyKey: string; + payload: { issueId: string; action: string }; + contextSnapshot: { wakeReason: "issue_unblock_requested"; issueId: string; taskId: string }; + }) => Promise; + markNotified: (notifiedAt: Date) => Promise; + now?: () => Date; +}) { + const { issue } = input; + if (!isProspectiveBlockedTransition(issue) || !issue.unblockDescriptor || issue.blockedOwnerNotifiedAt) { + return false; + } + + const owner = issue.unblockDescriptor.owner; + if (owner === "board" || !("agentId" in owner)) return false; + + await input.wakeup(owner.agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_unblock_requested", + idempotencyKey: `issue-unblock:${issue.id}:${issue.blockedTransitionAt.toISOString()}`, + payload: { issueId: issue.id, action: issue.unblockDescriptor.action }, + contextSnapshot: { wakeReason: "issue_unblock_requested", issueId: issue.id, taskId: issue.id }, + }); + await input.markNotified((input.now ?? (() => new Date()))()); + return true; +}