diff --git a/server/src/__tests__/issue-liveness.test.ts b/server/src/__tests__/issue-liveness.test.ts index 53e1c8d387..ed594f7a69 100644 --- a/server/src/__tests__/issue-liveness.test.ts +++ b/server/src/__tests__/issue-liveness.test.ts @@ -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: [ diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 639be853c2..e8622c62c3 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -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)); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e51af10035..02e5b9fdfb 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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, diff --git a/server/src/services/recovery/issue-graph-liveness.ts b/server/src/services/recovery/issue-graph-liveness.ts index acf32b0a2b..e9f566df50 100644 --- a/server/src/services/recovery/issue-graph-liveness.ts +++ b/server/src/services/recovery/issue-graph-liveness.ts @@ -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 | 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 | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -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) ||