mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Address recovery resolution lifecycle gaps
This commit is contained in:
1 parent
5b0a65980f
commit
114ef087e1
5 files changed
+121
-36
No files matched your search
@@ -3,6 +3,7 @@ import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
createIssueSchema,
|
||||
resolveIssueRecoveryActionSchema,
|
||||
respondIssueThreadInteractionSchema,
|
||||
suggestedTaskDraftSchema,
|
||||
updateIssueSchema,
|
||||
@@ -46,6 +47,25 @@ describe("issue validators", () => {
|
||||
expect(parsed.comment).toBe("Done\n\n- Verified the route");
|
||||
});
|
||||
|
||||
it("allows false-positive recovery resolutions to atomically restore the source issue status", () => {
|
||||
expect(
|
||||
resolveIssueRecoveryActionSchema.parse({
|
||||
outcome: "false_positive",
|
||||
sourceIssueStatus: "in_review",
|
||||
}),
|
||||
).toMatchObject({
|
||||
outcome: "false_positive",
|
||||
sourceIssueStatus: "in_review",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveIssueRecoveryActionSchema.safeParse({
|
||||
outcome: "false_positive",
|
||||
sourceIssueStatus: "blocked",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes escaped line breaks in issue comment bodies", () => {
|
||||
const parsed = addIssueCommentSchema.parse({
|
||||
body: "Progress update\\r\\n\\r\\nNext action.",
|
||||
|
||||
@@ -239,6 +239,22 @@ export const resolveIssueRecoveryActionSchema = z.object({
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.outcome === "false_positive") {
|
||||
if (
|
||||
value.sourceIssueStatus !== undefined &&
|
||||
value.sourceIssueStatus !== null &&
|
||||
value.sourceIssueStatus !== "done" &&
|
||||
value.sourceIssueStatus !== "in_review"
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "False-positive recovery actions may only move the source issue to done or in_review",
|
||||
path: ["sourceIssueStatus"],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.sourceIssueStatus !== undefined && value.sourceIssueStatus !== null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
||||
@@ -685,6 +685,47 @@ describeEmbeddedPostgres("issue recovery actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows false-positive recovery resolution to restore a blocked source issue in the same request", async () => {
|
||||
const { companyId, managerId, sourceIssueId } = await seedCompany();
|
||||
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, sourceIssueId));
|
||||
const recoveryActionSvc = issueRecoveryActionService(db);
|
||||
const action = await recoveryActionSvc.upsertSourceScoped({
|
||||
companyId,
|
||||
sourceIssueId,
|
||||
kind: "issue_graph_liveness",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: managerId,
|
||||
cause: "issue_graph_liveness",
|
||||
fingerprint: "graph-liveness:false-positive-unblock",
|
||||
evidence: { latestIssueStatus: "blocked" },
|
||||
nextAction: "Confirm whether the issue is actually stranded.",
|
||||
wakePolicy: { type: "manual" },
|
||||
});
|
||||
const app = createApp();
|
||||
|
||||
const resolved = await request(app)
|
||||
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
|
||||
.send({
|
||||
actionId: action.id,
|
||||
outcome: "false_positive",
|
||||
sourceIssueStatus: "in_review",
|
||||
resolutionNote: "Recovery signal was stale; return to review.",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(resolved.body.issue).toMatchObject({
|
||||
id: sourceIssueId,
|
||||
status: "in_review",
|
||||
activeRecoveryAction: null,
|
||||
});
|
||||
expect(resolved.body.recoveryAction).toMatchObject({
|
||||
id: action.id,
|
||||
status: "resolved",
|
||||
outcome: "false_positive",
|
||||
resolutionNote: "Recovery signal was stale; return to review.",
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces company scope when resolving recovery actions", async () => {
|
||||
const { companyId, managerId, sourceIssueId } = await seedCompany();
|
||||
const recoveryActionSvc = issueRecoveryActionService(db);
|
||||
|
||||
@@ -2181,8 +2181,35 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
}
|
||||
|
||||
async function collectIssueGraphLivenessFindings() {
|
||||
const issueRows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
projectId: issues.projectId,
|
||||
goalId: issues.goalId,
|
||||
parentId: issues.parentId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionState: issues.executionState,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorAttemptCount: issues.monitorAttemptCount,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]),
|
||||
),
|
||||
);
|
||||
const issueIdsUnderAnalysis = issueRows.map((row) => row.id);
|
||||
|
||||
const [
|
||||
issueRows,
|
||||
relationRows,
|
||||
agentRows,
|
||||
activeRunRows,
|
||||
@@ -2193,32 +2220,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
recoveryIssueRows,
|
||||
recoveryActionRows,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
projectId: issues.projectId,
|
||||
goalId: issues.goalId,
|
||||
parentId: issues.parentId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionState: issues.executionState,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorAttemptCount: issues.monitorAttemptCount,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
companyId: issueRelations.companyId,
|
||||
@@ -2308,14 +2309,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({
|
||||
companyId: issueRecoveryActions.companyId,
|
||||
issueId: issueRecoveryActions.sourceIssueId,
|
||||
status: issueRecoveryActions.status,
|
||||
})
|
||||
.from(issueRecoveryActions)
|
||||
.where(inArray(issueRecoveryActions.status, ["active", "escalated"])),
|
||||
issueIdsUnderAnalysis.length === 0
|
||||
? Promise.resolve([])
|
||||
: db
|
||||
.select({
|
||||
companyId: issueRecoveryActions.companyId,
|
||||
issueId: issueRecoveryActions.sourceIssueId,
|
||||
status: issueRecoveryActions.status,
|
||||
})
|
||||
.from(issueRecoveryActions)
|
||||
.where(
|
||||
and(
|
||||
inArray(issueRecoveryActions.status, ["active", "escalated"]),
|
||||
inArray(issueRecoveryActions.sourceIssueId, issueIdsUnderAnalysis),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const openRecoveryIssues = recoveryIssueRows.flatMap((row) => {
|
||||
|
||||
@@ -2966,7 +2966,7 @@ export function IssueDetail() {
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" });
|
||||
return;
|
||||
case "false_positive":
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive" });
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "in_review" });
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user