From 114ef087e1732eba38d95d50c09f7744887fa280 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 9 May 2026 22:25:59 -0500 Subject: [PATCH] Address recovery resolution lifecycle gaps --- packages/shared/src/validators/issue.test.ts | 20 +++++ packages/shared/src/validators/issue.ts | 16 ++++ .../__tests__/issue-recovery-actions.test.ts | 41 ++++++++++ server/src/services/recovery/service.ts | 78 ++++++++++--------- ui/src/pages/IssueDetail.tsx | 2 +- 5 files changed, 121 insertions(+), 36 deletions(-) diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index ba5cd73c240..968121c3dad 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -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.", diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 2df73bf645c..122b5dbf618 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -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, diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 9cd3fd8822a..b0621a19490 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -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); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index b8a6d139a61..3d6300ecf49 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -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) => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a8605cfb52a..4eaa325049c 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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; } },