mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Harden graph liveness legacy retirement
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
aa0d8b9a31
commit
06a7177d4d
2 files changed
+116
-21
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user