mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Guard liveness recovery by issue creation time
This commit is contained in:
1 parent
41a989e00c
commit
398d85488c
2 files changed
+58
-17
No files matched your search
@@ -117,7 +117,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function seedBlockedChain(opts: { outsideLookback?: boolean } = {}) {
|
||||
async function seedBlockedChain(opts: { outsideLookback?: boolean; freshUpdateOutsideLookback?: boolean } = {}) {
|
||||
const companyId = randomUUID();
|
||||
const managerId = randomUUID();
|
||||
const coderId = randomUUID();
|
||||
@@ -161,6 +161,9 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
const issueTimestamp = opts.outsideLookback === true
|
||||
? new Date(Date.now() - 25 * 60 * 60 * 1000)
|
||||
: new Date(Date.now() - 60 * 60 * 1000);
|
||||
const updatedAt = opts.freshUpdateOutsideLookback === true
|
||||
? new Date(Date.now() - 60 * 60 * 1000)
|
||||
: issueTimestamp;
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockedIssueId,
|
||||
@@ -172,7 +175,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
createdAt: issueTimestamp,
|
||||
updatedAt: issueTimestamp,
|
||||
updatedAt,
|
||||
},
|
||||
{
|
||||
id: blockerIssueId,
|
||||
@@ -183,7 +186,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
issueNumber: 2,
|
||||
identifier: `${issuePrefix}-2`,
|
||||
createdAt: issueTimestamp,
|
||||
updatedAt: issueTimestamp,
|
||||
updatedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -236,6 +239,27 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
expect(escalations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not create recovery issues for old legacy findings that were only updated recently", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId } = await seedBlockedChain({
|
||||
outsideLookback: true,
|
||||
freshUpdateOutsideLookback: true,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.findings).toBe(1);
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
expect(result.skippedOutsideLookback).toBe(1);
|
||||
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("suppresses liveness escalation when the source issue is under an active pause hold", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, blockedIssueId } = await seedBlockedChain();
|
||||
|
||||
@@ -2414,31 +2414,36 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
return `${companyId}:${issueId}`;
|
||||
}
|
||||
|
||||
async function loadLivenessDependencyUpdatedAtByIssue(findings: IssueLivenessFinding[]) {
|
||||
type LivenessDependencyTimestamps = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
async function loadLivenessDependencyTimestampsByIssue(findings: IssueLivenessFinding[]) {
|
||||
const issueIds = [
|
||||
...new Set(
|
||||
findings.flatMap((finding) => finding.dependencyPath.map((entry) => entry.issueId)),
|
||||
),
|
||||
];
|
||||
if (issueIds.length === 0) return new Map<string, Date>();
|
||||
if (issueIds.length === 0) return new Map<string, LivenessDependencyTimestamps>();
|
||||
const rows = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId, updatedAt: issues.updatedAt })
|
||||
.select({ id: issues.id, companyId: issues.companyId, createdAt: issues.createdAt, updatedAt: issues.updatedAt })
|
||||
.from(issues)
|
||||
.where(inArray(issues.id, issueIds));
|
||||
return new Map(rows.map((row) => [
|
||||
livenessDependencyIssueKey(row.companyId, row.id),
|
||||
row.updatedAt,
|
||||
{ createdAt: row.createdAt, updatedAt: row.updatedAt },
|
||||
]));
|
||||
}
|
||||
|
||||
function latestDependencyUpdatedAtForLivenessFinding(
|
||||
finding: IssueLivenessFinding,
|
||||
updatedAtByIssueKey: Map<string, Date>,
|
||||
timestampsByIssueKey: Map<string, LivenessDependencyTimestamps>,
|
||||
) {
|
||||
const dependencyIssueIds = [...new Set(finding.dependencyPath.map((entry) => entry.issueId))];
|
||||
if (dependencyIssueIds.length === 0) return null;
|
||||
const timestamps = dependencyIssueIds.map((issueId) =>
|
||||
updatedAtByIssueKey.get(livenessDependencyIssueKey(finding.companyId, issueId)) ?? null
|
||||
timestampsByIssueKey.get(livenessDependencyIssueKey(finding.companyId, issueId))?.updatedAt ?? null
|
||||
);
|
||||
if (timestamps.some((timestamp) => !timestamp)) return null;
|
||||
const [firstTimestamp, ...remainingTimestamps] = timestamps as Date[];
|
||||
@@ -2447,13 +2452,22 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
firstTimestamp!);
|
||||
}
|
||||
|
||||
function recoveryIssueCreatedAtForLivenessFinding(
|
||||
finding: IssueLivenessFinding,
|
||||
timestampsByIssueKey: Map<string, LivenessDependencyTimestamps>,
|
||||
) {
|
||||
return timestampsByIssueKey.get(
|
||||
livenessDependencyIssueKey(finding.companyId, finding.recoveryIssueId),
|
||||
)?.createdAt ?? null;
|
||||
}
|
||||
|
||||
function isLivenessFindingInsideAutoRecoveryLookback(
|
||||
finding: IssueLivenessFinding,
|
||||
cutoff: Date,
|
||||
updatedAtByIssueKey: Map<string, Date>,
|
||||
timestampsByIssueKey: Map<string, LivenessDependencyTimestamps>,
|
||||
) {
|
||||
const latestUpdatedAt = latestDependencyUpdatedAtForLivenessFinding(finding, updatedAtByIssueKey);
|
||||
return Boolean(latestUpdatedAt && latestUpdatedAt >= cutoff);
|
||||
const recoveryIssueCreatedAt = recoveryIssueCreatedAtForLivenessFinding(finding, timestampsByIssueKey);
|
||||
return Boolean(recoveryIssueCreatedAt && recoveryIssueCreatedAt >= cutoff);
|
||||
}
|
||||
|
||||
async function buildIssueGraphLivenessAutoRecoveryPreview(
|
||||
@@ -2463,7 +2477,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
const lookbackHours = normalizeIssueGraphLivenessAutoRecoveryLookbackHours(opts?.lookbackHours);
|
||||
const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
const findings = await collectIssueGraphLivenessFindings();
|
||||
const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings);
|
||||
const timestampsByIssueKey = await loadLivenessDependencyTimestampsByIssue(findings);
|
||||
const issueIds = [...new Set(findings.map((finding) => finding.recoveryIssueId))];
|
||||
const recoveryRows = issueIds.length > 0
|
||||
? await db
|
||||
@@ -2478,9 +2492,12 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
for (const finding of findings) {
|
||||
const latestDependencyUpdatedAt = latestDependencyUpdatedAtForLivenessFinding(
|
||||
finding,
|
||||
updatedAtByIssueKey,
|
||||
timestampsByIssueKey,
|
||||
);
|
||||
if (!latestDependencyUpdatedAt || latestDependencyUpdatedAt < cutoff) {
|
||||
if (
|
||||
!latestDependencyUpdatedAt ||
|
||||
!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, timestampsByIssueKey)
|
||||
) {
|
||||
skippedOutsideLookback += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -2790,7 +2807,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 updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings);
|
||||
const timestampsByIssueKey = await loadLivenessDependencyTimestampsByIssue(findings);
|
||||
const result = {
|
||||
findings: findings.length,
|
||||
autoRecoveryEnabled,
|
||||
@@ -2815,7 +2832,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
}
|
||||
|
||||
for (const finding of findings) {
|
||||
if (!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, updatedAtByIssueKey)) {
|
||||
if (!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, timestampsByIssueKey)) {
|
||||
result.skippedOutsideLookback += 1;
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
|
||||
Reference in new issue
Block a user