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:
parent
81f47e70a6
commit
148a5b11f5
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -833,6 +833,8 @@ export type {
|
|||
IssueInboxAttentionKind,
|
||||
IssueBlockedInboxAction,
|
||||
IssueBlockedInboxAttention,
|
||||
IssueUnblockDescriptor,
|
||||
IssueUnblockOwner,
|
||||
IssueBlockedInboxIssueRef,
|
||||
IssueBlockedInboxOwner,
|
||||
IssueBlockedInboxOwnerType,
|
||||
|
|
|
|||
|
|
@ -561,6 +561,8 @@ export type {
|
|||
IssueInboxAttentionKind,
|
||||
IssueBlockedInboxAction,
|
||||
IssueBlockedInboxAttention,
|
||||
IssueUnblockDescriptor,
|
||||
IssueUnblockOwner,
|
||||
IssueBlockedInboxIssueRef,
|
||||
IssueBlockedInboxOwner,
|
||||
IssueBlockedInboxOwnerType,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue