fix(issues): honor external unblock descriptors in liveness

This commit is contained in:
xXKillerNoobYT 2026-09-10 09:27:11 -06:00
parent 889947c238
commit 6e523229f9
4 changed files with 142 additions and 2 deletions

View File

@ -152,6 +152,74 @@ describe("issue graph liveness classifier", () => {
expect(findings).toEqual([]);
});
it.each([
["board", "board"],
["user", { userId: "board-user-1" }],
["agent", { agentId: "00000000-0000-4000-8000-000000000001" }],
])("treats a valid %s unblock descriptor as the external path for a blocked leaf", (_ownerKind, owner) => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Externally owned unblock work",
status: "blocked",
assigneeAgentId: null,
unblockDescriptor: { owner, action: "Obtain the external approval" },
}),
],
relations: blocks,
agents: [agent(), manager],
});
expect(findings).toEqual([]);
});
it("does not accept malformed descriptors as a comment-free unblock path", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Malformed external unblock work",
status: "blocked",
assigneeAgentId: null,
unblockDescriptor: { owner: "board", action: " " },
}),
],
relations: blocks,
agents: [agent(), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]?.state).toBe("blocked_by_unassigned_issue");
});
it("keeps terminal dependency findings ahead of an external descriptor on the blocked issue", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue({ unblockDescriptor: { owner: "board", action: "Review the external dependency" } }),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Cancelled first-class dependency",
status: "cancelled",
assigneeAgentId: null,
}),
],
relations: blocks,
agents: [agent(), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
state: "blocked_by_cancelled_issue",
recoveryIssueId: blockerId,
});
});
it("detects an assigned backlog blocker leaf with no action path", () => {
const findings = classifyIssueGraphLiveness({
issues: [

View File

@ -1051,6 +1051,66 @@ describeEmbeddedPostgres(
}
});
it("persists valid external unblock descriptors on create and comment-plus-status updates", async () => {
const fixture = await seedLowTrustFixture(db);
const app = createApp(db, boardActor(fixture));
const unblockDescriptor = {
owner: "board",
action: "Review the external dependency",
} as const;
const created = await request(app)
.post(`/api/companies/${fixture.company.id}/issues`)
.send({
projectId: fixture.projects.allowed.id,
title: "Externally blocked during creation",
status: "blocked",
unblockDescriptor,
});
expect(created.status, JSON.stringify(created.body)).toBe(201);
const [createdRow] = await db
.select({
status: issues.status,
unblockDescriptor: issues.unblockDescriptor,
})
.from(issues)
.where(eq(issues.id, created.body.id));
expect(createdRow).toMatchObject({ status: "blocked", unblockDescriptor });
await db
.delete(issueApprovals)
.where(eq(issueApprovals.issueId, fixture.issues.assignedReview.id));
const transitionComment =
"External unblock path recorded with the status transition.";
const transitioned = await request(app)
.patch(`/api/issues/${fixture.issues.assignedReview.id}`)
.send({ status: "blocked", comment: transitionComment, unblockDescriptor });
expect(transitioned.status, JSON.stringify(transitioned.body)).toBe(200);
const [transitionedRow] = await db
.select({
status: issues.status,
unblockDescriptor: issues.unblockDescriptor,
})
.from(issues)
.where(eq(issues.id, fixture.issues.assignedReview.id));
const [persistedComment] = await db
.select({ body: issueComments.body })
.from(issueComments)
.where(
and(
eq(issueComments.issueId, fixture.issues.assignedReview.id),
eq(issueComments.body, transitionComment),
),
);
expect(transitionedRow).toMatchObject({
status: "blocked",
unblockDescriptor,
});
expect(persistedComment?.body).toBe(transitionComment);
});
it("relays blocked and cancelled stops once without laundering child prose", async () => {
const fixture = await seedLowTrustFixture(db);
const app = createApp(db, boardActor(fixture));

View File

@ -4742,6 +4742,7 @@ async function listIssueReviewAttentionMap(
executionState: issue.executionState,
monitorNextCheckAt: issue.monitorNextCheckAt,
monitorAttemptCount: issue.monitorAttemptCount,
unblockDescriptor: issue.unblockDescriptor,
})),
relations: [],
agents: agentRows,
@ -5838,6 +5839,7 @@ async function listIssueBlockedInboxAttentionMap(
executionState: issue.executionState,
monitorNextCheckAt: issue.monitorNextCheckAt,
monitorAttemptCount: issue.monitorAttemptCount,
unblockDescriptor: issue.unblockDescriptor,
})),
relations: graphRelations,
agents: companyAgents,

View File

@ -1,4 +1,4 @@
import { getAgentWorkEligibility, isAgentInvokable } from "@paperclipai/shared";
import { getAgentWorkEligibility, isAgentInvokable, updateIssueSchema } from "@paperclipai/shared";
import { buildIssueGraphLivenessIncidentKey } from "./origins.js";
export type IssueLivenessSeverity = "warning" | "critical";
@ -28,6 +28,7 @@ export interface IssueLivenessIssueInput {
executionState?: Record<string, unknown> | null;
monitorNextCheckAt?: Date | string | null;
monitorAttemptCount?: number | null;
unblockDescriptor?: unknown;
}
export interface IssueLivenessRelationInput {
@ -168,6 +169,14 @@ function hasWaitingPath(
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
}
function hasValidExternalUnblockPath(issue: IssueLivenessIssueInput) {
if (issue.status !== "blocked" || issue.unblockDescriptor == null) return false;
return updateIssueSchema.safeParse({
status: "blocked",
unblockDescriptor: issue.unblockDescriptor,
}).success;
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
@ -512,7 +521,8 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
}
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
return Boolean(issue.assigneeUserId) ||
return hasValidExternalUnblockPath(issue) ||
Boolean(issue.assigneeUserId) ||
hasScheduledIssueMonitorPath(issue, nowMs) ||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||