Address recovery resolution lifecycle gaps

This commit is contained in:
Dotta committed 2026-05-09 22:25:59 -05:00
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.",
+16
View File
@@ -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);
+43 -35
View File
@@ -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) => {
+1 -1
View File
@@ -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;
}
},