From 53493eb3e9e4c9c841ce8bce1e58df746c854463 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 17 May 2026 11:30:44 -0500 Subject: [PATCH] Unblock closed liveness recovery issues in graph reconciliation --- ...eartbeat-issue-liveness-escalation.test.ts | 39 +++++++++++++++++++ server/src/services/recovery/service.ts | 24 ++++++++++++ 2 files changed, 63 insertions(+) diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index e6b2dc990d6..2c2e8fc0925 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -724,4 +724,43 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false); expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true); }); + + it("removes closed liveness escalations from blocker relations during reconciliation", async () => { + await enableAutoRecovery(); + const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const heartbeat = heartbeatService(db); + + const first = await heartbeat.reconcileIssueGraphLiveness(); + expect(first.escalationsCreated).toBe(1); + + const escalations = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, "harness_liveness_escalation"), + ), + ); + expect(escalations).toHaveLength(1); + + await db + .update(issues) + .set({ status: "done", blockedByIssueIds: [] }) + .where(eq(issues.id, escalations[0]!.id)); + await db + .update(issues) + .set({ status: "done", blockedByIssueIds: [] }) + .where(eq(issues.id, blockerIssueId)); + + const second = await heartbeat.reconcileIssueGraphLiveness(); + expect(second.obsoleteRecoveryBlockerRelationsRemoved).toBe(0); + expect(second.doneRecoveryBlockerRelationsRemoved).toBe(1); + + const blockers = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where(eq(issueRelations.relatedIssueId, blockedIssueId)); + expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false); + }); }); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index f94d5b52c0a..9b51a64ebfb 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -2590,6 +2590,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return result; } + async function retireDoneLivenessRecoveryBlockers() { + const closedRecoveries = await db + .select() + .from(issues) + .where( + and( + eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), + isNull(issues.hiddenAt), + inArray(issues.status, ["done", "cancelled"]), + ), + ); + + let blockerRelationsRemoved = 0; + for (const recovery of closedRecoveries) { + if (await removeRecoveryBlockerFromSource(recovery)) { + blockerRelationsRemoved += 1; + } + } + + return { blockerRelationsRemoved }; + } + function normalizeIssueGraphLivenessAutoRecoveryLookbackHours(raw: unknown) { const numeric = Math.floor(asNumber(raw, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)); return Math.min( @@ -2985,6 +3007,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const now = new Date(); const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings); + const doneRecoveryBlockerCleanup = await retireDoneLivenessRecoveryBlockers(); const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings); const result = { findings: findings.length, @@ -2999,6 +3022,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) obsoleteRecoveriesRetired: obsoleteRecoveryCleanup.retired, obsoleteRecoveriesActiveSkipped: obsoleteRecoveryCleanup.activeSkipped, obsoleteRecoveryBlockerRelationsRemoved: obsoleteRecoveryCleanup.blockerRelationsRemoved, + doneRecoveryBlockerRelationsRemoved: doneRecoveryBlockerCleanup.blockerRelationsRemoved, issueIds: [] as string[], escalationIssueIds: [] as string[], retiredRecoveryIssueIds: obsoleteRecoveryCleanup.retiredIssueIds,