diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index fdc3e7d4a94..fd335e466c9 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -426,6 +426,83 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { expect(events.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(0); }); + it("migrates an existing legacy liveness escalation to source-scoped recovery before removing its blocker", async () => { + await enableAutoRecovery(); + const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const legacyRecoveryIssueId = randomUUID(); + const incidentKey = [ + "harness_liveness", + companyId, + blockedIssueId, + "blocked_by_unassigned_issue", + blockerIssueId, + ].join(":"); + const leafFingerprint = [ + "harness_liveness_leaf", + companyId, + "blocked_by_unassigned_issue", + blockerIssueId, + ].join(":"); + await db.insert(issues).values({ + id: legacyRecoveryIssueId, + companyId, + title: "Legacy liveness escalation", + status: "todo", + priority: "high", + assigneeAgentId: managerId, + issueNumber: 3, + identifier: `LEGACY-${legacyRecoveryIssueId.slice(0, 8)}`, + originKind: "harness_liveness_escalation", + originId: incidentKey, + originFingerprint: leafFingerprint, + }); + await db.insert(issueRelations).values({ + companyId, + issueId: legacyRecoveryIssueId, + relatedIssueId: blockedIssueId, + type: "blocks", + }); + const heartbeat = heartbeatService(db); + + const first = await heartbeat.reconcileIssueGraphLiveness(); + const second = await heartbeat.reconcileIssueGraphLiveness(); + + expect(first.findings).toBe(1); + expect(first.sourceRecoveryActionsOpened).toBe(1); + expect(first.obsoleteRecoveriesRetired).toBe(1); + expect(first.obsoleteRecoveryBlockerRelationsRemoved).toBe(1); + expect(second.findings).toBe(0); + expect(second.sourceRecoveryActionsOpened).toBe(0); + expect(second.existingSourceRecoveryActions).toBe(0); + + const legacyRecoveries = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); + expect(legacyRecoveries).toHaveLength(1); + expect(legacyRecoveries[0]?.id).toBe(legacyRecoveryIssueId); + expect(legacyRecoveries[0]?.status).toBe("cancelled"); + + const actions = await db + .select() + .from(issueRecoveryActions) + .where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, blockerIssueId))); + expect(actions).toHaveLength(1); + expect(actions[0]).toMatchObject({ + kind: "issue_graph_liveness", + cause: "issue_graph_liveness", + status: "active", + ownerAgentId: managerId, + fingerprint: leafFingerprint, + }); + + const blockers = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where(eq(issueRelations.relatedIssueId, blockedIssueId)); + expect(blockers.map((row) => row.blockerIssueId).sort()).toEqual([blockerIssueId].sort()); + }); + it("skips budget-blocked direct owners and assigns recovery to the manager fallback", async () => { await enableAutoRecovery(); const { companyId, managerId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 7841b5f4338..54e0658ee5c 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -2380,21 +2380,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const openRecoveryIssues = recoveryIssueRows.flatMap((row) => { if (row.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation) { - const parsed = parseIssueGraphLivenessIncidentKey(row.originId); - if (!parsed || parsed.companyId !== row.companyId) return []; - if (parsed.state !== "blocked_by_assigned_backlog_issue") return []; - return [ - { - companyId: row.companyId, - issueId: parsed.issueId, - status: row.status, - }, - { - companyId: row.companyId, - issueId: parsed.leafIssueId, - status: row.status, - }, - ]; + return []; } const issueId = readNonEmptyString(row.originId); @@ -2574,6 +2560,32 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return Boolean(contextRun || issueRun); } + async function hasActiveIssueGraphLivenessActionForLegacyRecovery(recovery: typeof issues.$inferSelect) { + const parsed = parseLivenessIncidentKey(recovery.originId); + if (!parsed || parsed.companyId !== recovery.companyId) return false; + const expectedFingerprint = recovery.originFingerprint && recovery.originFingerprint !== "default" + ? recovery.originFingerprint + : livenessRecoveryLeafKey(parsed.companyId, parsed.state, parsed.leafIssueId); + + const row = await db + .select({ id: issueRecoveryActions.id }) + .from(issueRecoveryActions) + .where( + and( + eq(issueRecoveryActions.companyId, recovery.companyId), + eq(issueRecoveryActions.sourceIssueId, parsed.leafIssueId), + eq(issueRecoveryActions.kind, "issue_graph_liveness"), + eq(issueRecoveryActions.cause, "issue_graph_liveness"), + eq(issueRecoveryActions.fingerprint, expectedFingerprint), + inArray(issueRecoveryActions.status, ["active", "escalated"]), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + + return Boolean(row); + } + async function retireObsoleteLivenessRecoveryIssues(findings: IssueLivenessFinding[]) { const currentIncidentKeys = new Set(findings.map((finding) => finding.incidentKey)); const currentLeafKeys = new Set( @@ -2603,14 +2615,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) }; for (const recovery of openRecoveries) { - if (recovery.originId && currentIncidentKeys.has(recovery.originId)) continue; const parsed = parseLivenessIncidentKey(recovery.originId); if (!parsed) continue; - if ( - currentLeafKeys.has( - livenessRecoveryLeafKey(parsed.companyId, parsed.state, parsed.leafIssueId), - ) - ) { + const hasCurrentIncidentFinding = Boolean(recovery.originId && currentIncidentKeys.has(recovery.originId)); + const hasCurrentLeafFinding = currentLeafKeys.has( + livenessRecoveryLeafKey(parsed.companyId, parsed.state, parsed.leafIssueId), + ); + const sourceScopedActionTookOver = await hasActiveIssueGraphLivenessActionForLegacyRecovery(recovery); + if ((hasCurrentIncidentFinding || hasCurrentLeafFinding) && !sourceScopedActionTookOver) { continue; } if (await removeRecoveryBlockerFromSource(recovery)) { @@ -3309,6 +3321,12 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } } + const postRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings); + result.obsoleteRecoveriesRetired += postRecoveryCleanup.retired; + result.obsoleteRecoveriesActiveSkipped += postRecoveryCleanup.activeSkipped; + result.obsoleteRecoveryBlockerRelationsRemoved += postRecoveryCleanup.blockerRelationsRemoved; + result.retiredRecoveryIssueIds.push(...postRecoveryCleanup.retiredIssueIds); + return result; }