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
@@ -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 [{