Route blocked transitions to explicit unblock owners (#10112)

## Thinking Path

> - Paperclip is the open source control plane people use to coordinate
AI-agent companies.
> - Issue status transitions determine whether work keeps moving or
silently stalls.
> - A blocked issue previously could rely on prose alone, leaving the
intended unblock owner unstructured and unnotified.
> - Existing blocker-attention classification could identify stalled
chains, but the signal was not delivered to the board attention feed.
> - Blocked transitions also need rollout-safe deduplication so upgrades
do not notify for historical issues and repeated processing does not
create notification storms.
> - This pull request adds structured unblock descriptors, prospective
transition timestamps, owner delivery, and board attention routing with
focused authorization controls.
> - The benefit is that newly blocked work has an explicit, routable
next action without weakening company boundaries or allowing agents to
inject arbitrary human attention items.

## Linked Issues or Issue Description

Related documentation PR: #10094.

### Subsystem affected

Cross-cutting: `server/`, `packages/db`, and `packages/shared`.

### Problem or motivation

An issue can enter `blocked` without a machine-readable unblock path.
Prose-only ownership does not reliably wake the responsible agent or
surface human-owned work, while the existing `blockerAttention`
classifier is not delivered to an operator-facing attention feed.

### Proposed solution

Require new transitions into `blocked` to have unresolved blockers, a
pending interaction/approval, or a structured `{ owner, action }`
descriptor. Notify an allowed owner once per prospective transition,
route human-owned cases to board attention, and leave pre-rollout
blocked issues untouched.

### Alternatives considered

- Keep prose-only blockers: rejected because ownership remains
unroutable.
- Backfill all historical blocked issues: rejected because upgrades
would create notification storms.
- Let agents target arbitrary users or the board: rejected after
security review because it creates an attention-injection channel.

### Roadmap alignment

Aligns with `ROADMAP.md` → “Enforced Outcomes (watchdogs, recovery
actions, review gates)” by making blocked work carry an explicit
continuation path.

### Additional context

The implementation is prospective-only and deduplicated per blocked
transition. Agent-authored descriptors are limited to the acting agent;
board actors retain human-owner routing.

## What Changed

- Added persisted unblock descriptors and prospective blocked-transition
delivery timestamps with an idempotent migration.
- Added shared types and validation for board, user, and agent unblock
owners.
- Enforced valid blocked transitions and same-company owner validation
in the issue update route.
- Restricted agent-authored descriptors to the acting agent itself,
preventing board/user attention injection by compromised agents.
- Added one-per-transition agent wake delivery and prospective-only
rollout gating.
- Routed human-owned blocker attention into the board attention feed.
- Added focused tests for validation, prospective delivery, flap
deduplication, attention routing, route authorization, and stop-relay
compatibility.

## Verification

- `pnpm -r typecheck`
- `pnpm exec vitest run
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/routable-blocked.test.ts
server/src/__tests__/attention-service.test.ts
packages/shared/src/validators/issue.test.ts`
- `AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= pnpm test:run`
- `pnpm build`
- `pnpm --filter @paperclipai/db check:migrations`

## Risks

- Behavioral shift: new `blocked` transitions without a real blocker,
pending governed action, or structured descriptor now return `422`.
- Notification abuse is constrained by same-company validation, agent
self-only routing, prospective rollout gating, and transition-scoped
deduplication.
- Migration risk is low: columns are additive, nullable, and use `IF NOT
EXISTS`; historical blocked issues are not backfilled or notified.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex CLI with GPT-5.4, reasoning-enabled tool use and code
execution. The runtime did not expose a context-window value.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-23 15:49:28 -05:00 committed by GitHub
parent 81f47e70a6
commit 148a5b11f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 501 additions and 15 deletions

View File

@ -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;

View File

@ -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
}
]
}

View File

@ -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<Record<string, unknown>>(),
sourceTrust: jsonb("source_trust").$type<SourceTrustMetadata | null>(),
unblockDescriptor: jsonb("unblock_descriptor").$type<IssueUnblockDescriptor | null>(),
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 }),

View File

@ -833,6 +833,8 @@ export type {
IssueInboxAttentionKind,
IssueBlockedInboxAction,
IssueBlockedInboxAttention,
IssueUnblockDescriptor,
IssueUnblockOwner,
IssueBlockedInboxIssueRef,
IssueBlockedInboxOwner,
IssueBlockedInboxOwnerType,

View File

@ -561,6 +561,8 @@ export type {
IssueInboxAttentionKind,
IssueBlockedInboxAction,
IssueBlockedInboxAttention,
IssueUnblockDescriptor,
IssueUnblockOwner,
IssueBlockedInboxIssueRef,
IssueBlockedInboxOwner,
IssueBlockedInboxOwnerType,

View File

@ -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;

View File

@ -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",

View File

@ -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<typeof createIssueSchema>;
@ -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<typeof createChildIssueSchema>;

View File

@ -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<string, unknown> | 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();

View File

@ -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<string, unknown>) => {
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<string, unknown>) => ({
...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<string, unknown>) => {
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),

View File

@ -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<string, unknown> = {};
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<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../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(() => {

View File

@ -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);

View File

@ -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()),
});
});
});

View File

@ -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<ReturnType<typeof svc.addStopRelayCommentIfNeeded>>;
} = { value: null };
let issue;
let issue: Awaited<ReturnType<typeof svc.update>>;
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 {

View File

@ -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<IssueSubjectRow & { blockerAttention?: { state?: string; sampleStalledBlockerIdentifier?: string | null; sampleBlockerIdentifier?: string | null } | null }>) {
for (const issue of blockedIssues as Array<IssueSubjectRow & {
blockerAttention?: { state?: string; sampleStalledBlockerIdentifier?: string | null; sampleBlockerIdentifier?: string | null } | null;
unblockDescriptor?: { owner: { userId: string } | { agentId: string } | "board"; action: string } | null;
blockedTransitionAt?: Date | null;
}>) {
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",

View File

@ -2594,6 +2594,9 @@ const issueListSelect = {
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: sql<null>`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);
}

View File

@ -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<unknown>;
markNotified: (notifiedAt: Date) => Promise<unknown>;
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;
}