fix(issues): honor external unblock descriptors in liveness
This commit is contained in:
parent
889947c238
commit
6e523229f9
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
Loading…
Reference in New Issue