Guard assigned backlog liveness (#5428)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The issue graph and liveness recovery system decide whether assigned
work is executable or parked
> - Assigned issues created without an explicit status could silently
land in backlog, making parents look blocked with no productive wake
path
> - The server, shared validators, recovery analysis, and UI all need to
agree on that execution semantic
> - This pull request makes assigned issue creation default to `todo`,
flags assigned backlog blockers, and surfaces the state in the board
> - The benefit is that parked assigned work becomes intentional and
visible instead of creating silent liveness stalls

## What Changed

- Adds contract tests for assigned issue creation defaults.
- Defaults assigned issue creation to `todo` when status is omitted
while preserving explicit `backlog` parking.
- Exposes `resolveCreateIssueStatusDefault` through shared validators.
- Teaches liveness/blocker attention paths to distinguish assigned
backlog blockers.
- Adds UI notices, row/header badges, and issue detail safeguards for
assigned backlog blockers.
- Adds Storybook fixtures and execution-semantics documentation for the
assigned-backlog behavior.

## Verification

- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/issue.test.ts
server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts
server/src/__tests__/issue-blocker-attention.test.ts
server/src/__tests__/issue-liveness.test.ts
server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts
ui/src/components/IssueAssignedBacklogNotice.test.tsx
ui/src/components/IssueRow.test.tsx` — 50 passed, 23 skipped.
- Skipped tests were embedded Postgres suites on this host with the repo
skip message: `Postgres init script exited with code null. Please check
the logs for extra info. The data directory might already exist.`
- Pairwise merge check against the issue-controls PR branch completed
without conflicts via `git merge --no-commit --no-ff` in a temporary
worktree.
- Screenshots for assigned-backlog UI states:
[light](docs/pr-screenshots/pr-5428/assigned-backlog-light.png),
[dark](docs/pr-screenshots/pr-5428/assigned-backlog-dark.png).
- Follow-up checks: `pnpm --filter /ui typecheck`; `pnpm --filter
/mcp-server build`; `pnpm --filter /mcp-server test`; `pnpm exec vitest
run packages/shared/src/validators/issue.test.ts`; focused UI component
tests.
- Remote PR checks on head `6300b3c`: policy, verify, serialized server
shards 1/4-4/4, Canary Dry Run, e2e, Greptile Review, and Snyk all
passed.

## Risks

- Medium: changes status defaulting for assigned issue creation when the
caller omits status. Explicit `backlog` remains supported, and
server/shared tests cover both paths.
- Medium: liveness classification changes can affect blocker attention
labels; focused service and UI tests cover the new assigned-backlog
state.

> 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 coding agent, GPT-5 model family (`gpt-5`), tool-enabled
Paperclip heartbeat environment. Context window and internal reasoning
mode are not exposed by the runtime.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [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-05-07 12:25:26 -05:00
committed by GitHub
parent 6f30003421
commit e400315cbf
28 changed files with 1303 additions and 22 deletions
+10
View File
@@ -183,6 +183,16 @@ A healthy dispatch state means at least one of these is true:
An assigned `todo` issue is stalled when dispatch was interrupted, no wake remains queued or running, and no recovery path has been opened.
### Agent-assigned `backlog`
This is parked state, not dispatch state.
Assigning an issue normally implies executable intent. When create APIs receive an assignee and no explicit status, Paperclip defaults the issue to `todo` so the assignee has a wake path instead of silently inheriting the unassigned `backlog` default.
An explicit assigned `backlog` issue remains valid when the creator is deliberately parking the work. It must not wake the assignee just because it has an assignee. Paperclip should make that choice visible in activity and UI so operators can distinguish intentional parking from a missed handoff.
An assigned `backlog` issue becomes a liveness problem when another issue is blocked on it and there is no explicit waiting path such as a human owner, active run, queued wake, pending interaction or approval, monitor, or open recovery issue. In that case the blocked parent should surface "blocked by parked work" rather than treating the dependency chain as healthy.
### Agent-assigned `in_progress`
This is active-work state.
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+26
View File
@@ -87,6 +87,32 @@ describe("paperclip MCP tools", () => {
});
});
it("allows create issue requests to omit status so the API applies assignee defaults", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "issue-1", status: "todo" }),
);
vi.stubGlobal("fetch", fetchMock);
const tool = getTool("paperclipCreateIssue");
await tool.execute({
title: "Assigned follow-up",
assigneeAgentId: "22222222-2222-2222-2222-222222222222",
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(url)).toBe(
"http://localhost:3100/api/companies/11111111-1111-1111-1111-111111111111/issues",
);
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({
title: "Assigned follow-up",
workMode: "standard",
priority: "medium",
assigneeAgentId: "22222222-2222-2222-2222-222222222222",
requestDepth: 0,
});
});
it("defaults issue document format to markdown", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ key: "plan", latestRevisionNumber: 2 }),
+2 -2
View File
@@ -4,7 +4,7 @@ import {
askUserQuestionsPayloadSchema,
checkoutIssueSchema,
createApprovalSchema,
createIssueSchema,
createIssueInputSchema,
issueThreadInteractionContinuationPolicySchema,
requestConfirmationPayloadSchema,
suggestTasksPayloadSchema,
@@ -95,7 +95,7 @@ const upsertDocumentToolSchema = z.object({
const createIssueToolSchema = z.object({
companyId: companyIdOptional,
}).merge(createIssueSchema);
}).merge(createIssueInputSchema);
const updateIssueToolSchema = z.object({
issueId: issueIdSchema,
+2
View File
@@ -719,7 +719,9 @@ export {
COMPANY_SEARCH_MAX_TOKENS,
type CompanySearchQuery,
createIssueSchema,
createIssueInputSchema,
createChildIssueSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
updateIssueSchema,
issueExecutionPolicySchema,
+2
View File
@@ -149,7 +149,9 @@ export {
export {
createIssueSchema,
createIssueInputSchema,
createChildIssueSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
updateIssueSchema,
issueExecutionPolicySchema,
@@ -129,6 +129,19 @@ describe("issue validators", () => {
expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
});
it("defaults omitted create status to todo when an assignee is present", () => {
expect(createIssueSchema.parse({
title: "Assigned work",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
}).status).toBe("todo");
expect(createIssueSchema.parse({ title: "Unassigned work" }).status).toBe("backlog");
expect(createIssueSchema.parse({
title: "Deliberately parked",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
status: "backlog",
}).status).toBe("backlog");
});
it("defaults issue work mode to standard and accepts planning", () => {
expect(createIssueSchema.parse({ title: "Plan first" }).workMode).toBe("standard");
expect(createIssueSchema.parse({ title: "Plan first", workMode: "planning" }).workMode).toBe("planning");
+52 -5
View File
@@ -173,7 +173,48 @@ const issueRequestDepthInputSchema = z
.nonnegative()
.transform((value) => clampIssueRequestDepth(value));
export const createIssueSchema = z.object({
type IssueCreateStatusDefaultInput = {
status?: unknown;
assigneeAgentId?: unknown;
assigneeUserId?: unknown;
};
export function resolveCreateIssueStatusDefault(input: IssueCreateStatusDefaultInput): {
status: (typeof ISSUE_STATUSES)[number];
defaulted: boolean;
reason: "explicit" | "assigned_omitted_status" | "unassigned_omitted_status";
} {
if (typeof input.status === "string") {
return {
status: input.status as (typeof ISSUE_STATUSES)[number],
defaulted: false,
reason: "explicit",
};
}
const hasAssignee =
(typeof input.assigneeAgentId === "string" && input.assigneeAgentId.length > 0)
|| (typeof input.assigneeUserId === "string" && input.assigneeUserId.length > 0);
return {
status: hasAssignee ? "todo" : "backlog",
defaulted: true,
reason: hasAssignee ? "assigned_omitted_status" : "unassigned_omitted_status",
};
}
function withCreateIssueStatusDefault<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
return z.preprocess((input) => {
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
const raw = input as Record<string, unknown>;
if (raw.status !== undefined) return input;
return {
...raw,
status: resolveCreateIssueStatusDefault(raw).status,
};
}, schema);
}
const createIssueBaseSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
projectWorkspaceId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
@@ -182,7 +223,7 @@ export const createIssueSchema = z.object({
inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(),
title: z.string().min(1),
description: multilineTextSchema.optional().nullable(),
status: z.enum(ISSUE_STATUSES).optional().default("backlog"),
status: z.enum(ISSUE_STATUSES),
workMode: z.enum(ISSUE_WORK_MODES).optional().default("standard"),
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
assigneeAgentId: z.string().uuid().optional().nullable(),
@@ -197,9 +238,15 @@ export const createIssueSchema = z.object({
labelIds: z.array(z.string().uuid()).optional(),
});
export const createIssueInputSchema = createIssueBaseSchema.extend({
status: createIssueBaseSchema.shape.status.optional(),
});
export const createIssueSchema = withCreateIssueStatusDefault(createIssueBaseSchema);
export type CreateIssue = z.infer<typeof createIssueSchema>;
export const createChildIssueSchema = createIssueSchema
export const createChildIssueSchema = withCreateIssueStatusDefault(createIssueBaseSchema
.omit({
parentId: true,
inheritExecutionWorkspaceFromIssueId: true,
@@ -207,7 +254,7 @@ export const createChildIssueSchema = createIssueSchema
.extend({
acceptanceCriteria: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
blockParentUntilDone: z.boolean().optional().default(false),
});
}));
export type CreateChildIssue = z.infer<typeof createChildIssueSchema>;
@@ -218,7 +265,7 @@ export const createIssueLabelSchema = z.object({
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({
export const updateIssueSchema = createIssueBaseSchema.partial().extend({
requestDepth: issueRequestDepthInputSchema.optional(),
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
comment: multilineTextSchema.pipe(z.string().min(1)).optional(),
@@ -723,7 +723,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
executionLockedAt: null,
});
expect(readyRun?.status).toBe("succeeded");
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(1);
});
it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => {
@@ -117,7 +117,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
});
}
async function seedBlockedChain(opts: { outsideLookback?: boolean } = {}) {
async function seedBlockedChain(opts: {
outsideLookback?: boolean;
blockerStatus?: string;
blockerAssigneeAgentId?: "coder" | "manager" | null;
} = {}) {
const companyId = randomUUID();
const managerId = randomUUID();
const coderId = randomUUID();
@@ -178,8 +182,13 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
id: blockerIssueId,
companyId,
title: "Missing unblock owner",
status: "todo",
status: opts.blockerStatus ?? "todo",
priority: "medium",
assigneeAgentId: opts.blockerAssigneeAgentId === "coder"
? coderId
: opts.blockerAssigneeAgentId === "manager"
? managerId
: null,
issueNumber: 2,
identifier: `${issuePrefix}-2`,
createdAt: issueTimestamp,
@@ -283,6 +292,46 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(result.escalationsCreated).toBe(0);
});
it("creates one bounded escalation for an assigned backlog blocker leaf", async () => {
await enableAutoRecovery();
const { companyId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain({
blockerStatus: "backlog",
blockerAssigneeAgentId: "coder",
});
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(first.findings).toBe(1);
expect(first.escalationsCreated).toBe(1);
expect(second.findings).toBe(0);
expect(second.escalationsCreated).toBe(0);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
expect(escalations[0]).toMatchObject({
parentId: blockerIssueId,
assigneeAgentId: coderId,
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"blocked_by_assigned_backlog_issue",
blockerIssueId,
].join(":"),
originFingerprint: [
"harness_liveness_leaf",
companyId,
"blocked_by_assigned_backlog_issue",
blockerIssueId,
].join(":"),
});
});
it("creates one manager escalation, preserves blockers, and records owner selection", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
@@ -0,0 +1,313 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const assigneeAgentId = "22222222-2222-4222-8222-222222222222";
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueService = vi.hoisted(() => ({
create: vi.fn(),
createChild: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(async () => null),
getComment: vi.fn(),
getCommentCursor: vi.fn(),
getRelationSummaries: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
findMentionedAgents: vi.fn(async () => []),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
}),
documentService: () => ({
getIssueDocumentPayload: vi.fn(async () => ({})),
}),
executionWorkspaceService: () => ({
getById: vi.fn(async () => null),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
}),
goalService: () => ({
getById: vi.fn(async () => null),
getDefaultCompanyGoal: vi.fn(async () => null),
}),
heartbeatService: () => ({
wakeup: mockWakeup,
reportRunActivity: vi.fn(async () => undefined),
}),
getIssueContinuationSummaryDocument: vi.fn(async () => null),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({
getById: vi.fn(async () => null),
listByIds: vi.fn(async () => []),
}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({
listForIssue: vi.fn(async () => []),
}),
}));
async function createApp() {
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"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
function makeIssue(input: {
id: string;
title: string;
status?: string;
parentId?: string | null;
assigneeAgentId?: string | null;
}) {
return {
id: input.id,
companyId: "company-1",
identifier: input.id === "child-1" ? "PAP-3701" : "PAP-3700",
title: input.title,
description: null,
status: input.status ?? "todo",
priority: "medium",
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: "local-board",
executionWorkspaceId: null,
labels: [],
labelIds: [],
};
}
function expectClearAssignedStatusValidation(res: request.Response) {
expect([400, 422]).toContain(res.status);
expect(String(res.body?.error ?? res.text)).toMatch(/assign|assignee|status|backlog|todo/i);
}
describe("assigned backlog creation contract", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue({
id: "parent-1",
title: "Parent issue",
status: "blocked",
assigneeAgentId,
}));
mockIssueService.create.mockImplementation(async (_companyId: string, data: Record<string, unknown>) =>
makeIssue({
id: "issue-1",
title: String(data.title),
status: String(data.status),
assigneeAgentId: data.assigneeAgentId as string | null | undefined,
}));
mockIssueService.createChild.mockImplementation(async (_parentId: string, data: Record<string, unknown>) => ({
issue: makeIssue({
id: "child-1",
title: String(data.title),
status: String(data.status),
parentId: "parent-1",
assigneeAgentId: data.assigneeAgentId as string | null | undefined,
}),
parentBlockerAdded: Boolean(data.blockParentUntilDone),
}));
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
});
it("does not silently create a top-level assigned issue as backlog when status is omitted", async () => {
const res = await request(await createApp())
.post("/api/companies/company-1/issues")
.send({
title: "Assigned executable work",
assigneeAgentId,
});
if (res.status !== 201) {
expectClearAssignedStatusValidation(res);
expect(mockIssueService.create).not.toHaveBeenCalled();
expect(mockWakeup).not.toHaveBeenCalled();
return;
}
expect(mockIssueService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Assigned executable work",
assigneeAgentId,
status: "todo",
}),
);
expect(res.body).toEqual(expect.objectContaining({
assigneeAgentId,
status: "todo",
}));
expect(mockWakeup).toHaveBeenCalledWith(
assigneeAgentId,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({ mutation: "create" }),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.created",
details: expect.objectContaining({
status: "todo",
statusDefaulted: true,
statusDefaultReason: "assigned_omitted_status",
assignmentWakeSkipped: false,
}),
}),
);
});
it("does not let a parent-blocking assigned child become an unwoken backlog leaf by default", async () => {
const res = await request(await createApp())
.post("/api/issues/parent-1/children")
.send({
title: "Assigned child blocker",
assigneeAgentId,
blockParentUntilDone: true,
});
if (res.status !== 201) {
expectClearAssignedStatusValidation(res);
expect(mockIssueService.createChild).not.toHaveBeenCalled();
expect(mockWakeup).not.toHaveBeenCalled();
return;
}
expect(mockIssueService.createChild).toHaveBeenCalledWith(
"parent-1",
expect.objectContaining({
title: "Assigned child blocker",
assigneeAgentId,
blockParentUntilDone: true,
status: "todo",
}),
);
expect(res.body).toEqual(expect.objectContaining({
assigneeAgentId,
parentId: "parent-1",
status: "todo",
}));
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.child_created",
details: expect.objectContaining({
status: "todo",
statusDefaulted: true,
statusDefaultReason: "assigned_omitted_status",
assignmentWakeSkipped: false,
parentBlockerAdded: true,
}),
}),
);
expect(mockWakeup).toHaveBeenCalledWith(
assigneeAgentId,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({ mutation: "create" }),
}),
);
});
it("preserves deliberate assigned backlog as parked work without assignment wakeup", async () => {
const res = await request(await createApp())
.post("/api/companies/company-1/issues")
.send({
title: "Parked assigned work",
assigneeAgentId,
status: "backlog",
});
expect(res.status).toBe(201);
expect(mockIssueService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Parked assigned work",
assigneeAgentId,
status: "backlog",
}),
);
expect(res.body).toEqual(expect.objectContaining({
assigneeAgentId,
status: "backlog",
}));
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.created",
entityId: "issue-1",
details: expect.objectContaining({
status: "backlog",
statusDefaulted: false,
statusDefaultReason: "explicit",
assignmentWakeSkipped: true,
assignmentWakeSkipReason: "assigned_backlog",
}),
}),
);
expect(mockWakeup).not.toHaveBeenCalled();
});
});
@@ -76,6 +76,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
status: string;
parentId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
originKind?: string | null;
originId?: string | null;
originFingerprint?: string | null;
@@ -90,6 +91,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
priority: "medium",
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: input.assigneeUserId ?? null,
originKind: input.originKind ?? "manual",
originId: input.originId ?? null,
originFingerprint: input.originFingerprint ?? "default",
@@ -147,6 +149,55 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
});
it("classifies an assigned backlog blocker leaf without a waiting path as attention-needed", async () => {
const { companyId, agentId } = await createCompany("PBB");
const parentId = await insertIssue({ companyId, identifier: "PBB-1", title: "Parent", status: "blocked" });
const blockerId = await insertIssue({
companyId,
identifier: "PBB-2",
title: "Parked assigned blocker",
status: "backlog",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PBB-2",
});
});
it("treats a human-owned backlog blocker as a covered waiting path", async () => {
const { companyId } = await createCompany("PBU");
const parentId = await insertIssue({ companyId, identifier: "PBU-1", title: "Parent", status: "blocked" });
const blockerId = await insertIssue({
companyId,
identifier: "PBU-2",
title: "Human-owned parked blocker",
status: "backlog",
assigneeUserId: "board-user-1",
});
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PBU-2",
});
});
it("keeps mixed blockers attention-required when any path lacks active work", async () => {
const { companyId, agentId } = await createCompany("PBM");
const parentId = await insertIssue({ companyId, identifier: "PBM-1", title: "Parent", status: "blocked" });
@@ -152,6 +152,73 @@ describe("issue graph liveness classifier", () => {
expect(findings).toEqual([]);
});
it("detects an assigned backlog blocker leaf with no action path", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Parked assigned unblock work",
status: "backlog",
assigneeAgentId: "blocker-agent",
}),
],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: blockedId,
identifier: "PAP-1703",
state: "blocked_by_assigned_backlog_issue",
recoveryIssueId: blockerId,
recommendedOwnerAgentId: "blocker-agent",
dependencyPath: [
expect.objectContaining({ issueId: blockedId }),
expect.objectContaining({ issueId: blockerId, status: "backlog" }),
],
incidentKey: `harness_liveness:${companyId}:${blockedId}:blocked_by_assigned_backlog_issue:${blockerId}`,
});
});
it("does not flag an assigned backlog blocker that has an explicit waiting path", () => {
const backlogBlocker = issue({
id: blockerId,
identifier: "PAP-1704",
title: "Explicitly parked unblock work",
status: "backlog",
assigneeAgentId: "blocker-agent",
});
const baseInput = {
issues: [issue(), backlogBlocker],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
],
};
expect(classifyIssueGraphLiveness({
...baseInput,
issues: [issue(), { ...backlogBlocker, assigneeAgentId: null, assigneeUserId: "board-user-1" }],
})).toEqual([]);
expect(classifyIssueGraphLiveness({
...baseInput,
activeRuns: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "running" }],
})).toEqual([]);
expect(classifyIssueGraphLiveness({
...baseInput,
openRecoveryIssues: [{ companyId, issueId: blockerId, status: "todo" }],
})).toEqual([]);
});
it("does not flag an unassigned blocker that already has an active execution path", () => {
const findings = classifyIssueGraphLiveness({
issues: [
+43 -2
View File
@@ -17,6 +17,7 @@ import {
checkoutIssueSchema,
createChildIssueSchema,
createIssueSchema,
resolveCreateIssueStatusDefault,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
@@ -137,6 +138,44 @@ type SuccessfulRunHandoffActivityRow = {
createdAt: Date;
};
function applyCreateIssueStatusDefault(req: Request, res: Response, next: () => void) {
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
next();
return;
}
const resolution = resolveCreateIssueStatusDefault(req.body as Record<string, unknown>);
res.locals.createIssueStatusDefault = resolution;
if (resolution.defaulted) {
req.body = {
...req.body,
status: resolution.status,
};
}
next();
}
function buildCreateIssueActivityStatusDetails(
issue: { assigneeAgentId: string | null; status: string },
res: Response,
) {
const statusDefault = res.locals.createIssueStatusDefault as
| ReturnType<typeof resolveCreateIssueStatusDefault>
| undefined;
const assignmentWakeSkipped = !issue.assigneeAgentId || issue.status === "backlog";
return {
status: issue.status,
statusDefaulted: statusDefault?.defaulted ?? false,
statusDefaultReason: statusDefault?.reason ?? "explicit",
assignmentWakeSkipped,
assignmentWakeSkipReason: assignmentWakeSkipped
? issue.assigneeAgentId
? "assigned_backlog"
: "no_agent_assignee"
: null,
};
}
const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_required",
"issue.successful_run_handoff_resolved",
@@ -2249,7 +2288,7 @@ export function issueRoutes(
res.json({ ok: true });
});
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
router.post("/companies/:companyId/issues", applyCreateIssueStatusDefault, validate(createIssueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
@@ -2289,6 +2328,7 @@ export function issueRoutes(
details: {
title: issue.title,
identifier: issue.identifier,
...buildCreateIssueActivityStatusDetails(issue, res),
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
...summarizeIssueReferenceActivityDetails({
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
@@ -2338,7 +2378,7 @@ export function issueRoutes(
});
});
router.post("/issues/:id/children", validate(createChildIssueSchema), async (req, res) => {
router.post("/issues/:id/children", applyCreateIssueStatusDefault, validate(createChildIssueSchema), async (req, res) => {
const parentId = req.params.id as string;
const parent = await svc.getById(parentId);
if (!parent) {
@@ -2380,6 +2420,7 @@ export function issueRoutes(
parentId: parent.id,
identifier: issue.identifier,
title: issue.title,
...buildCreateIssueActivityStatusDetails(issue, res),
inheritedExecutionWorkspaceFromIssueId: parent.id,
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
...(parentBlockerAdded ? { parentBlockerAdded: true } : {}),
+6
View File
@@ -1309,6 +1309,9 @@ async function listIssueBlockerAttentionMap(
if (explicitWaitingIssueIds.has(node.id)) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (node.assigneeUserId && node.status !== "cancelled") {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (node.status === "in_review") {
const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId);
if (hasWaitingPath) {
@@ -1322,6 +1325,9 @@ async function listIssueBlockerAttentionMap(
if (node.status === "cancelled") {
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (node.status === "backlog" && node.assigneeAgentId) {
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
if (downstream.length > 0) {
@@ -4,6 +4,7 @@ export type IssueLivenessSeverity = "warning" | "critical";
export type IssueLivenessState =
| "blocked_by_unassigned_issue"
| "blocked_by_assigned_backlog_issue"
| "blocked_by_uninvokable_assignee"
| "blocked_by_cancelled_issue"
| "invalid_review_participant"
@@ -498,6 +499,21 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
return reviewFinding(source, blocker, dependencyPath);
}
if (blocker.status === "backlog" && blocker.assigneeAgentId) {
return finding({
issue: source,
state: "blocked_by_assigned_backlog_issue",
reason: `${issueLabel(source)} is blocked by assigned backlog issue ${issueLabel(blocker)} with no wake, active run, human owner, interaction, approval, monitor, or recovery issue owning the next action.`,
dependencyPath,
recoveryIssue: blocker,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Review ${issueLabel(blocker)} and either move it to todo so the assignee wakes, assign a human owner or interaction if it is intentionally parked, or remove it from ${issueLabel(source)}'s blockers if it is no longer required.`,
blockerIssueId: blocker.id,
});
}
if (!blocker.assigneeAgentId && !blocker.assigneeUserId) {
return finding({
issue: source,
+23 -1
View File
@@ -2089,19 +2089,41 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
companyId: issues.companyId,
id: issues.id,
status: issues.status,
originKind: issues.originKind,
originId: issues.originId,
})
.from(issues)
.where(
and(
isNull(issues.hiddenAt),
eq(issues.originKind, STRANDED_ISSUE_RECOVERY_ORIGIN_KIND),
inArray(issues.originKind, [
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
]),
notInArray(issues.status, ["done", "cancelled"]),
),
),
]);
const openRecoveryIssues = recoveryIssueRows.flatMap((row) => {
if (row.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation) {
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
if (!parsed || parsed.companyId !== row.companyId) return [];
if (parsed.state !== "blocked_by_assigned_backlog_issue") return [];
return [
{
companyId: row.companyId,
issueId: parsed.issueId,
status: row.status,
},
{
companyId: row.companyId,
issueId: parsed.leafIssueId,
status: row.status,
},
];
}
const issueId = readNonEmptyString(row.originId);
if (!issueId) return [];
return [{
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { Agent } from "@paperclipai/shared";
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const baseAgent = {
id: "agent-1",
companyId: "co-1",
name: "ClaudeCoder",
role: "engineer",
status: "active",
} as unknown as Agent;
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
describe("IssueAssignedBacklogNotice", () => {
it("renders nothing when status is not backlog", () => {
act(() => {
root.render(
<IssueAssignedBacklogNotice
issueStatus="todo"
assigneeAgent={baseAgent}
assigneeUserId={null}
/>,
);
});
expect(container.querySelector('[data-testid="issue-assigned-backlog-notice"]')).toBeNull();
});
it("renders nothing when there is no assignee", () => {
act(() => {
root.render(
<IssueAssignedBacklogNotice
issueStatus="backlog"
assigneeAgent={null}
assigneeUserId={null}
/>,
);
});
expect(container.querySelector('[data-testid="issue-assigned-backlog-notice"]')).toBeNull();
});
it("warns when an agent is assigned and the issue is parked in backlog", () => {
act(() => {
root.render(
<IssueAssignedBacklogNotice
issueStatus="backlog"
assigneeAgent={baseAgent}
assigneeUserId={null}
/>,
);
});
const notice = container.querySelector('[data-testid="issue-assigned-backlog-notice"]');
expect(notice).not.toBeNull();
expect(notice?.textContent).toContain("Parked");
expect(notice?.textContent).toContain("ClaudeCoder");
});
it("calls onResume when the resume button is clicked", () => {
const onResume = vi.fn();
act(() => {
root.render(
<IssueAssignedBacklogNotice
issueStatus="backlog"
assigneeAgent={baseAgent}
assigneeUserId={null}
onResume={onResume}
/>,
);
});
const button = container.querySelector('[data-testid="issue-assigned-backlog-resume"]') as HTMLButtonElement | null;
expect(button).not.toBeNull();
act(() => {
button?.click();
});
expect(onResume).toHaveBeenCalledTimes(1);
});
it("disables the resume button while resuming", () => {
act(() => {
root.render(
<IssueAssignedBacklogNotice
issueStatus="backlog"
assigneeAgent={baseAgent}
assigneeUserId={null}
onResume={() => undefined}
resuming
/>,
);
});
const button = container.querySelector('[data-testid="issue-assigned-backlog-resume"]') as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.disabled).toBe(true);
expect(button?.textContent).toContain("Resuming");
});
});
@@ -0,0 +1,63 @@
import { Flag } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
interface IssueAssignedBacklogNoticeProps {
issueStatus: string;
assigneeAgent: Agent | null;
assigneeUserId?: string | null;
onResume?: () => void;
resuming?: boolean;
}
export function IssueAssignedBacklogNotice({
issueStatus,
assigneeAgent,
assigneeUserId,
onResume,
resuming,
}: IssueAssignedBacklogNoticeProps) {
if (issueStatus !== "backlog") return null;
if (!assigneeAgent && !assigneeUserId) return null;
const assigneeLabel = assigneeAgent?.name ?? "the assignee";
return (
<div
data-testid="issue-assigned-backlog-notice"
data-issue-status={issueStatus}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<div className="flex items-start gap-2">
<Flag className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1 space-y-1.5">
<p className="leading-5">
<span className="font-medium">Parked</span> {" "}
<span className="font-medium">{assigneeLabel}</span> will not be woken until status changes to{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">todo</code> or{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">in_progress</code>.
</p>
{assigneeAgent ? (
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
Comments still wake the assignee for questions or triage. Leave this parked only if the work is intentionally on hold.
</p>
) : null}
{onResume ? (
<div className="pt-0.5">
<Button
size="sm"
variant="outline"
className="h-7 border-amber-400/70 bg-background/80 text-amber-950 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
onClick={onResume}
disabled={resuming}
data-testid="issue-assigned-backlog-resume"
>
{resuming ? "Resuming…" : "Resume now"}
</Button>
</div>
) : null}
</div>
</div>
</div>
);
}
+32 -1
View File
@@ -1,8 +1,9 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { AlertTriangle, Flag } from "lucide-react";
import { Link } from "@/lib/router";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
export function IssueBlockedNotice({
issueStatus,
@@ -27,6 +28,24 @@ export function IssueBlockedNotice({
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
const isStalled = blockerAttention?.state === "stalled";
const parkedBlockers = (() => {
const seen = new Set<string>();
const collected: IssueRelationIssueSummary[] = [];
const sources: IssueRelationIssueSummary[] = [...blockers];
for (const blocker of blockers) {
for (const terminal of blocker.terminalBlockers ?? []) {
sources.push(terminal);
}
}
for (const blocker of sources) {
if (!isAssignedBacklogBlocker(blocker)) continue;
if (seen.has(blocker.id)) continue;
seen.add(blocker.id);
collected.push(blocker);
}
return collected;
})();
const showParkedRow = parkedBlockers.length > 0;
const stalledLeafIdentifier =
blockerAttention?.sampleStalledBlockerIdentifier ?? blockerAttention?.sampleBlockerIdentifier ?? null;
const stalledLeafBlockers = (() => {
@@ -148,6 +167,18 @@ export function IssueBlockedNotice({
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
{showParkedRow ? (
<div
data-testid="issue-blocked-notice-parked-row"
className="flex flex-wrap items-center gap-1.5 pt-0.5"
>
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-800 dark:text-amber-200">
<Flag className="h-3 w-3" aria-hidden />
Blocked by parked work
</span>
{parkedBlockers.map(renderBlockerChip)}
</div>
) : null}
</>
) : null}
</div>
+14
View File
@@ -133,6 +133,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Textarea } from "@/components/ui/textarea";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
interface IssueChatMessageContext {
feedbackDataSharingPreference: FeedbackDataSharingPreference;
@@ -296,6 +297,9 @@ interface IssueChatThreadProps {
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
assigneeUserId?: string | null;
onResumeFromBacklog?: () => Promise<void> | void;
resumeFromBacklogPending?: boolean;
companyId?: string | null;
projectId?: string | null;
issueStatus?: string;
@@ -3650,6 +3654,9 @@ export function IssueChatThread({
issueWorkMode,
onWorkModeChange,
onRefreshLatestComments,
assigneeUserId = null,
onResumeFromBacklog,
resumeFromBacklogPending = false,
}: IssueChatThreadProps) {
const location = useLocation();
const lastScrolledHashRef = useRef<string | null>(null);
@@ -4230,6 +4237,13 @@ export function IssueChatThread({
)}
{showComposer ? (
<div data-testid="issue-chat-thread-notices" className="space-y-2">
<IssueAssignedBacklogNotice
issueStatus={issueStatus ?? ""}
assigneeAgent={assignedAgent}
assigneeUserId={assigneeUserId}
onResume={onResumeFromBacklog}
resuming={resumeFromBacklogPending}
/>
<IssueBlockedNotice
issueStatus={issueStatus}
blockers={unresolvedBlockers}
+56
View File
@@ -258,4 +258,60 @@ describe("IssueRow", () => {
root.unmount();
});
});
it("flags rows blocked by an assigned-backlog leaf with a parked-work badge", () => {
const root = createRoot(container);
const issue = createIssue({
blockedBy: [
{
id: "blocker-1",
identifier: "PAP-2",
title: "Parked child",
status: "backlog",
priority: "high",
assigneeAgentId: "agent-99",
assigneeUserId: null,
},
],
});
act(() => {
root.render(<IssueRow issue={issue} />);
});
const badges = container.querySelectorAll('[data-testid="issue-row-parked-blocker"]');
expect(badges.length).toBeGreaterThan(0);
expect(badges[0]?.textContent).toContain("Blocked by parked work");
act(() => {
root.unmount();
});
});
it("does not show the parked-work badge when assigned blocker is not in backlog", () => {
const root = createRoot(container);
const issue = createIssue({
blockedBy: [
{
id: "blocker-1",
identifier: "PAP-2",
title: "Active child",
status: "in_progress",
priority: "high",
assigneeAgentId: "agent-99",
assigneeUserId: null,
},
],
});
act(() => {
root.render(<IssueRow issue={issue} />);
});
expect(container.querySelector('[data-testid="issue-row-parked-blocker"]')).toBeNull();
act(() => {
root.unmount();
});
});
});
+14 -1
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Eye, X } from "lucide-react";
import { Eye, Flag, X } from "lucide-react";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
@@ -10,6 +10,7 @@ import {
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
type UnreadState = "hidden" | "visible" | "fading";
@@ -91,6 +92,16 @@ export function IssueRow({
Planning
</span>
) : null;
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
<span
data-testid="issue-row-parked-blocker"
className="ml-1.5 inline-flex shrink-0 items-center gap-0.5 rounded-full border border-amber-500/60 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300"
title="Blocked by parked work — at least one assigned blocker is in backlog and will not wake its assignee."
>
<Flag className="h-2.5 w-2.5" aria-hidden />
Blocked by parked work
</span>
) : null;
return (
<Link
@@ -113,6 +124,7 @@ export function IssueRow({
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
{productivityReviewIndicator}
{planningModeIndicator}
{parkedBlockerIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
@@ -138,6 +150,7 @@ export function IssueRow({
{identifier}
</span>
{planningModeIndicator}
{parkedBlockerIndicator}
</>
)}
{mobileMeta ? (
+39 -7
View File
@@ -54,6 +54,7 @@ import {
Calendar,
Paperclip,
FileText,
Flag,
Loader2,
ListTree,
X,
@@ -218,9 +219,19 @@ function formatFileSize(file: File) {
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`;
}
const statuses = [
{ value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault },
{ value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault },
const statuses: ReadonlyArray<{ value: string; label: string; color: string; description?: string }> = [
{
value: "backlog",
label: "Backlog",
color: issueStatusText.backlog ?? issueStatusTextDefault,
description: "Parked — assignee will not be woken",
},
{
value: "todo",
label: "Todo",
color: issueStatusText.todo ?? issueStatusTextDefault,
description: "Executable — assignee will be woken",
},
{ value: "in_progress", label: "In Progress", color: issueStatusText.in_progress ?? issueStatusTextDefault },
{ value: "in_review", label: "In Review", color: issueStatusText.in_review ?? issueStatusTextDefault },
{ value: "done", label: "Done", color: issueStatusText.done ?? issueStatusTextDefault },
@@ -1337,6 +1348,10 @@ export function NewIssueDialog() {
trackRecentAssignee(nextAssignee.assigneeAgentId);
}
setAssigneeValue(value);
const hasAssignee = Boolean(nextAssignee.assigneeAgentId || nextAssignee.assigneeUserId);
if (hasAssignee && status === "backlog") {
setStatus("todo");
}
}}
onConfirm={() => {
if (projectId) {
@@ -1828,18 +1843,23 @@ export function NewIssueDialog() {
{currentStatus.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
<PopoverContent className="w-56 p-1" align="start">
{statuses.map((s) => (
<button
key={s.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
"flex w-full items-start gap-2 px-2 py-1.5 text-xs rounded hover:bg-accent/50",
s.value === status && "bg-accent"
)}
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
>
<CircleDot className={cn("h-3 w-3", s.color)} />
{s.label}
<CircleDot className={cn("h-3 w-3 mt-0.5 shrink-0", s.color)} />
<span className="flex flex-col text-left leading-tight">
<span>{s.label}</span>
{s.description ? (
<span className="text-[10px] text-muted-foreground">{s.description}</span>
) : null}
</span>
</button>
))}
</PopoverContent>
@@ -1964,6 +1984,18 @@ export function NewIssueDialog() {
</Popover>
</div>
{assigneeValue && status === "backlog" ? (
<div
data-testid="new-issue-assigned-backlog-note"
className="mx-4 mb-2 flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<Flag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
<span className="leading-snug">
Assigning implies executable intent leave status as <span className="font-medium">Backlog</span> only to deliberately park this. The assignee will not be woken until status moves to <span className="font-medium">Todo</span> or <span className="font-medium">In Progress</span>.
</span>
</div>
) : null}
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border shrink-0">
<Button
+16
View File
@@ -0,0 +1,16 @@
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
export function isAssignedBacklogBlocker(blocker: IssueRelationIssueSummary): boolean {
return blocker.status === "backlog" && Boolean(blocker.assigneeAgentId);
}
export function hasAssignedBacklogBlocker(
blockers: IssueRelationIssueSummary[] | undefined | null,
): boolean {
if (!blockers || blockers.length === 0) return false;
return blockers.some((blocker) => {
if (isAssignedBacklogBlocker(blocker)) return true;
if (blocker.terminalBlockers?.some(isAssignedBacklogBlocker)) return true;
return false;
});
}
+31
View File
@@ -110,6 +110,7 @@ import {
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
successfulRunHandoffActivityTone,
} from "../lib/successful-run-handoff";
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
import {
Activity as ActivityIcon,
AlertTriangle,
@@ -120,6 +121,7 @@ import {
Copy,
Eye,
EyeOff,
Flag,
Hexagon,
ListTree,
MessageSquare,
@@ -644,6 +646,9 @@ type IssueDetailChatTabProps = {
answers: AskUserQuestionsAnswer[],
) => Promise<void>;
onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise<void>;
assigneeUserId: string | null;
onResumeFromBacklog?: () => Promise<void> | void;
resumeFromBacklogPending?: boolean;
};
const IssueDetailChatTab = memo(function IssueDetailChatTab({
@@ -694,6 +699,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
assigneeUserId,
onResumeFromBacklog,
resumeFromBacklogPending,
}: IssueDetailChatTabProps) {
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
@@ -901,6 +909,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
: undefined}
onImageClick={onImageClick}
onRefreshLatestComments={onRefreshLatestComments}
assigneeUserId={assigneeUserId}
onResumeFromBacklog={onResumeFromBacklog}
resumeFromBacklogPending={resumeFromBacklogPending}
/>
</div>
);
@@ -2894,6 +2905,10 @@ export function IssueDetail() {
const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => {
await cancelInteraction.mutateAsync({ interaction });
}, [cancelInteraction]);
const canResumeFromBacklog = issue?.status === "backlog" && Boolean(issue.assigneeAgentId || issue.assigneeUserId);
const handleResumeFromBacklog = useCallback(async () => {
await updateIssue.mutateAsync({ status: "todo" });
}, [updateIssue.mutateAsync]);
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
@@ -3240,6 +3255,17 @@ export function IssueDetail() {
</span>
) : null}
{hasAssignedBacklogBlocker(issue.blockedBy) ? (
<span
data-testid="issue-detail-parked-blocker"
className="inline-flex items-center gap-1 rounded-full border border-amber-500/60 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
title="Blocked by parked work — at least one assigned blocker is in backlog and will not wake its assignee."
>
<Flag className="h-3 w-3" />
Blocked by parked work
</span>
) : null}
{issue.projectId ? (
<Link
to={`/projects/${issue.projectId}`}
@@ -3805,6 +3831,11 @@ export function IssueDetail() {
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
onCancelInteraction={handleCancelInteraction}
assigneeUserId={issue.assigneeUserId ?? null}
onResumeFromBacklog={canResumeFromBacklog ? handleResumeFromBacklog : undefined}
resumeFromBacklogPending={
updateIssue.isPending && updateIssue.variables?.status === "todo"
}
/>
) : null}
</TabsContent>
@@ -0,0 +1,245 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import { CircleDot, Flag, MoreHorizontal, Paperclip } from "lucide-react";
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
import { IssueAssignedBacklogNotice } from "@/components/IssueAssignedBacklogNotice";
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { IssueRow } from "@/components/IssueRow";
import { storybookAgents, createIssue } from "../fixtures/paperclipData";
const codexAgent = storybookAgents.find((agent) => agent.id === "agent-codex") ?? storybookAgents[0]!;
const qaAgent = storybookAgents.find((agent) => agent.id === "agent-qa") ?? storybookAgents[0]!;
function StoryFrame({ title, children }: { title: string; children: ReactNode }) {
return (
<main className="min-h-screen bg-background p-4 text-foreground sm:p-8">
<div className="mx-auto max-w-5xl space-y-5">
<div>
<div className="text-xs font-medium uppercase text-muted-foreground">Assigned-backlog UI safeguards</div>
<h1 className="mt-1 text-2xl font-semibold">{title}</h1>
</div>
{children}
</div>
</main>
);
}
function CreationFormPanel() {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">A. Issue creation chip bar with intent note</div>
<div className="space-y-3 rounded-md border border-border/60 bg-background p-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="w-6 shrink-0 text-center">For</span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
ClaudeCoder
</span>
<span>in</span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
Paperclip App
</span>
</div>
<div className="space-y-1.5">
<div className="text-sm font-semibold">Fix flaky deploy step on the worker pipeline</div>
<div className="text-xs text-muted-foreground">
Investigate the intermittent timeout the worker pipeline hit during the last release rehearsal.
</div>
</div>
<div className="flex items-center gap-1.5 border-t border-border pt-3 flex-wrap">
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-amber-100/40 px-2 py-1 text-xs dark:bg-amber-500/10">
<CircleDot className="h-3 w-3 text-purple-500" />
Backlog
</span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
<CircleDot className="h-3 w-3 text-amber-500" />
High
</span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">
<Paperclip className="h-3 w-3" />
Upload
</span>
<span className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs text-muted-foreground">
<MoreHorizontal className="h-3 w-3" />
</span>
</div>
<div className="flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
<Flag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
<span className="leading-snug">
Assigning implies executable intent leave status as <span className="font-medium">Backlog</span> only to deliberately park this. The assignee will not be woken until status moves to <span className="font-medium">Todo</span> or <span className="font-medium">In Progress</span>.
</span>
</div>
</div>
<div className="mt-4 rounded-md border border-border bg-popover p-1 text-xs">
<div className="px-2 pb-1 text-[10px] uppercase text-muted-foreground">Status options</div>
<div className="flex w-full items-start gap-2 rounded px-2 py-1.5 hover:bg-accent/50">
<CircleDot className="h-3 w-3 mt-0.5 shrink-0 text-purple-500" />
<span className="flex flex-col text-left leading-tight">
<span>Backlog</span>
<span className="text-[10px] text-muted-foreground">Parked assignee will not be woken</span>
</span>
</div>
<div className="flex w-full items-start gap-2 rounded bg-accent px-2 py-1.5">
<CircleDot className="h-3 w-3 mt-0.5 shrink-0 text-blue-500" />
<span className="flex flex-col text-left leading-tight">
<span>Todo</span>
<span className="text-[10px] text-muted-foreground">Executable assignee will be woken</span>
</span>
</div>
</div>
</div>
);
}
function AssignedBacklogNoticePanel() {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">B. Issue panel banner parked with assignee</div>
<IssueAssignedBacklogNotice
issueStatus="backlog"
assigneeAgent={qaAgent}
assigneeUserId={null}
onResume={() => undefined}
/>
</div>
);
}
function BlockedByParkedWorkPanel() {
const parkedBlocker: IssueRelationIssueSummary = {
id: "blocker-parked",
identifier: "PAP-3683",
title: "Adapter restart fails after upgrade",
status: "backlog",
priority: "critical",
assigneeAgentId: codexAgent.id,
assigneeUserId: null,
};
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">C. Parent issue blocked by parked work</div>
<IssueBlockedNotice
issueStatus="blocked"
blockers={[parkedBlocker]}
blockerAttention={{
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: parkedBlocker.identifier,
sampleStalledBlockerIdentifier: null,
}}
/>
</div>
);
}
function ListRowsPanel() {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">D. Issue list row indicators</div>
<div className="rounded-md border border-border">
<IssueRow
issue={createIssue({
id: "issue-blocked-parent",
identifier: "PAP-3643",
issueNumber: 3643,
title: "Restart deploy run after fixed adapter",
status: "blocked",
priority: "high",
blockedBy: [
{
id: "blocker-parked-leaf",
identifier: "PAP-3683",
title: "Adapter restart fails after upgrade",
status: "backlog",
priority: "critical",
assigneeAgentId: codexAgent.id,
assigneeUserId: null,
},
],
blockerAttention: {
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-3683",
sampleStalledBlockerIdentifier: null,
},
})}
/>
<IssueRow
issue={createIssue({
id: "issue-healthy",
identifier: "PAP-3644",
issueNumber: 3644,
title: "Compute new deploy budget for next cycle",
status: "in_progress",
priority: "medium",
blockedBy: [],
})}
/>
</div>
</div>
);
}
function AllStates() {
return (
<StoryFrame title="Assigned-backlog liveness UI">
<section className="grid gap-4 lg:grid-cols-[1fr_1fr]">
<CreationFormPanel />
<AssignedBacklogNoticePanel />
</section>
<section className="grid gap-4 lg:grid-cols-[1fr_1fr]">
<BlockedByParkedWorkPanel />
<ListRowsPanel />
</section>
</StoryFrame>
);
}
const meta = {
title: "Paperclip/Assigned Backlog Safeguards",
component: AllStates,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AllStates>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Overview: Story = {};
export const CreationForm: Story = {
render: () => (
<StoryFrame title="Issue creation chip bar with intent note">
<CreationFormPanel />
</StoryFrame>
),
};
export const AssignedBacklogBanner: Story = {
render: () => (
<StoryFrame title="Issue panel banner — parked with assignee">
<AssignedBacklogNoticePanel />
</StoryFrame>
),
};
export const BlockedByParkedWork: Story = {
render: () => (
<StoryFrame title="Parent issue blocked by parked work">
<BlockedByParkedWorkPanel />
</StoryFrame>
),
};
export const ListRows: Story = {
render: () => (
<StoryFrame title="Issue list row indicators">
<ListRowsPanel />
</StoryFrame>
),
};