fix(server): throttle serial recovery repeats (#9651)

## Thinking Path

> - Paperclip is the open source control plane people use to coordinate
AI-agent companies.
> - Its recovery services create productivity reviews and liveness
escalations when work stops making progress.
> - Existing uniqueness guards prevent concurrent duplicates, but
terminal recovery tasks can still be recreated serially without enough
time for conditions to change.
> - That creates noisy review churn for persistently stalled issues and
immediate liveness re-escalation after a recovery task closes.
> - This pull request adds bounded, configurable cooldown and no-action
suppression behavior to those two recovery paths.
> - The benefit is quieter recovery automation that still resumes
automatically after source activity or cooldown expiry.

## Linked Issues or Issue Description

### Pre-submission checklist

- [x] I have searched existing open and closed issues and this is not a
duplicate.
- [x] I can reproduce this behavior on `master`.
- [x] I have confirmed the behavior originates in Paperclip core
recovery orchestration, not an adapter, provider, or local
configuration.

### What happened?

Recovery reconciliation can serially recreate equivalent system-origin
tasks after previous tasks become terminal. Productivity reviews allowed
multiple creations for the same source issue within a rolling day, and a
closed liveness escalation could be recreated immediately for the same
incident or recovery leaf.

### Expected behavior

Productivity review creation should be limited to once per rolling 24
hours, repeated completed reviews that produced no source action should
eventually suppress further creation until activity resumes, and
recently terminal liveness escalations should receive a short cooldown
before recreation.

### Steps to reproduce

1. Create a stalled assigned issue that meets productivity-review
eligibility.
2. Complete repeated productivity-review tasks without adding
source-issue activity, then reconcile again within 24 hours.
3. Create and close a liveness escalation for a blocked issue graph,
then immediately reconcile the same graph.
4. Observe that equivalent system tasks can be recreated serially
without a meaningful state change.

### Paperclip version or commit

`5588ddf68175eea448f9d19677b97d7393c38c3d` (`master` when reproduced)

### Deployment mode

Local dev (`pnpm dev`)

### Installation method

Built from source (`pnpm dev` / `pnpm build`)

### Agent adapter(s) involved

- [x] Not adapter-specific (core bug)

### Database mode

Embedded PGlite (default — `DATABASE_URL` unset)

### Access context

Unclear / not applicable

### Node.js version

Current repository-supported Node.js runtime.

### Operating system

Linux development environment.

### Relevant logs or output

No error is emitted; the bug is repeated task creation visible in
persisted issue history.

### Relevant config (if applicable)

No special configuration is required.

### Additional context

The concurrent/open-task uniqueness guards work as designed; this change
targets serial repeats after matching tasks become terminal.

### Privacy checklist

- [x] I have reviewed all pasted output for PII and redacted where
necessary.

## What Changed

- Tightened the productivity-review creation cap to one review per
source issue in a rolling 24-hour window.
- Added configurable suppression after three consecutive completed
reviews with no source-issue activity, with automatic reset when source
activity occurs.
- Added a configurable one-hour default cooldown for matching terminal
liveness escalations.
- Exposed the liveness reconciliation clock/cooldown inputs for
deterministic orchestration tests.
- Added focused tests for daily enforcement, no-action suppression and
reset, and cooldown expiry.

## Verification

- `pnpm exec vitest run
server/src/__tests__/productivity-review-service.test.ts
server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts` — 36
tests passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- Confirm the focused tests demonstrate creation after source activity
and after the liveness cooldown expires.

## Risks

- Low-to-moderate behavioral risk: recovery tasks intentionally appear
less often, so overly aggressive thresholds could delay intervention for
a persistently stalled issue.
- Thresholds are configurable through reconciliation inputs, and source
activity resets productivity-review suppression.
- No database migration, public API change, telemetry contract change,
or UI behavior change.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex using GPT-5.3-Codex, with reasoning, repository/terminal
tool use, code execution, and test execution. The runtime did not expose
a reliable context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-16 09:22:50 -05:00 committed by GitHub
parent da549123cc
commit 85404b46c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 263 additions and 9 deletions

View File

@ -66,6 +66,7 @@ import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { issueService } from "../services/issues.ts";
import { runningProcesses } from "../adapters/index.ts";
import { DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS } from "../services/recovery/service.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -1153,10 +1154,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
);
});
it("creates a fresh escalation when the previous matching escalation is terminal", async () => {
it("holds a recently closed matching escalation, then re-escalates after the cooldown", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const now = new Date();
const incidentKey = [
"harness_liveness",
companyId,
@ -1178,9 +1180,18 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
identifier: "CLOSED-3",
originKind: "harness_liveness_escalation",
originId: incidentKey,
createdAt: new Date(now.getTime() - 30 * 60 * 1000),
updatedAt: now,
});
const result = await heartbeat.reconcileIssueGraphLiveness();
const held = await heartbeat.reconcileIssueGraphLiveness({ now });
expect(held.escalationsCreated).toBe(0);
expect(held.skippedReescalationCooldown).toBe(1);
const result = await heartbeat.reconcileIssueGraphLiveness({
now: new Date(now.getTime() + DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS + 1),
});
expect(result.escalationsCreated).toBe(1);
expect(result.existingEscalations).toBe(0);
@ -1211,6 +1222,41 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true);
});
it("re-escalates immediately after a matching escalation is cancelled", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const now = new Date();
const incidentKey = [
"harness_liveness",
companyId,
blockedIssueId,
"blocked_by_unassigned_issue",
blockerIssueId,
].join(":");
await db.insert(issues).values({
id: randomUUID(),
companyId,
title: "Cancelled escalation",
status: "cancelled",
priority: "high",
parentId: blockedIssueId,
assigneeAgentId: managerId,
issueNumber: 3,
identifier: "CANCELLED-3",
originKind: "harness_liveness_escalation",
originId: incidentKey,
createdAt: new Date(now.getTime() - 30 * 60 * 1000),
updatedAt: now,
});
const result = await heartbeat.reconcileIssueGraphLiveness({ now });
expect(result.escalationsCreated).toBe(1);
expect(result.skippedReescalationCooldown).toBe(0);
});
it("removes closed liveness escalations from blocker relations during reconciliation", async () => {
await enableAutoRecovery();
const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain();

View File

@ -255,7 +255,44 @@ describeEmbeddedPostgres("productivity review service", () => {
expect(await listRefreshComments(review!.id)).toHaveLength(DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS);
});
it("caps productivity review creation per source issue in the rolling creation window", async () => {
it("allows only one productivity review per source issue in 24 hours", async () => {
const now = new Date("2026-04-28T12:00:00.000Z");
const seeded = await seedAssignedIssue();
await insertRuns({
companyId: seeded.companyId,
agentId: seeded.coderId,
issueId: seeded.issueId,
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
now,
});
const createdAt = new Date(now.getTime() - 8 * 60 * 60 * 1000);
await db.insert(issues).values({
id: randomUUID(),
companyId: seeded.companyId,
title: "Completed productivity review",
status: "done",
priority: "high",
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
originId: seeded.issueId,
originFingerprint: `productivity-review:${seeded.issueId}`,
parentId: seeded.issueId,
issueNumber: 2,
identifier: `${seeded.issuePrefix}-2`,
createdAt,
updatedAt: createdAt,
});
const result = await productivityReviewService(db).reconcileProductivityReviews({
now,
companyId: seeded.companyId,
});
expect(result.created).toBe(0);
expect(result.creationCapped).toBe(1);
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(1);
});
it("suppresses creation after three consecutive completed reviews with no source action", async () => {
const now = new Date("2026-04-28T12:00:00.000Z");
const seeded = await seedAssignedIssue();
await insertRuns({
@ -266,12 +303,12 @@ describeEmbeddedPostgres("productivity review service", () => {
now,
});
await db.insert(issues).values(
[8, 9, 10].map((hoursAgo, index) => {
[96, 72, 48].map((hoursAgo, index) => {
const createdAt = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000);
return {
id: randomUUID(),
companyId: seeded.companyId,
title: `Completed productivity review ${index + 1}`,
title: `No-action productivity review ${index + 1}`,
status: "done",
priority: "high",
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
@ -281,7 +318,7 @@ describeEmbeddedPostgres("productivity review service", () => {
issueNumber: index + 2,
identifier: `${seeded.issuePrefix}-${index + 2}`,
createdAt,
updatedAt: createdAt,
updatedAt: new Date(createdAt.getTime() + 60 * 60 * 1000),
};
}),
);
@ -292,10 +329,62 @@ describeEmbeddedPostgres("productivity review service", () => {
});
expect(result.created).toBe(0);
expect(result.creationCapped).toBe(1);
expect(result.noActionSuppressed).toBe(1);
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(3);
});
it("resets no-action suppression for source action after a zero-duration review", async () => {
const now = new Date("2026-04-28T12:00:00.000Z");
const seeded = await seedAssignedIssue();
await insertRuns({
companyId: seeded.companyId,
agentId: seeded.coderId,
issueId: seeded.issueId,
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
now,
});
const reviewWindows = [96, 72, 48].map((hoursAgo, index) => {
const createdAt = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000);
return {
id: randomUUID(),
companyId: seeded.companyId,
title: `Productivity review ${index + 1}`,
status: "done" as const,
priority: "high" as const,
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
originId: seeded.issueId,
originFingerprint: `productivity-review:${seeded.issueId}`,
parentId: seeded.issueId,
issueNumber: index + 2,
identifier: `${seeded.issuePrefix}-${index + 2}`,
createdAt,
updatedAt: new Date(createdAt.getTime() + 60 * 60 * 1000),
};
});
const actedReview = reviewWindows[1]!;
actedReview.updatedAt = actedReview.createdAt;
await db.insert(issues).values(reviewWindows);
await db.insert(activityLog).values({
companyId: seeded.companyId,
actorType: "agent",
actorId: seeded.coderId,
agentId: seeded.coderId,
action: "issue.updated",
entityType: "issue",
entityId: seeded.issueId,
createdAt: new Date(actedReview.createdAt.getTime() + 2 * 60 * 60 * 1000),
});
const result = await productivityReviewService(db).reconcileProductivityReviews({
now,
companyId: seeded.companyId,
});
expect(result.created).toBe(1);
expect(result.noActionSuppressed).toBe(0);
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(4);
});
it("does not count cancelled productivity reviews toward the creation cap", async () => {
const now = new Date("2026-04-28T12:00:00.000Z");
const seeded = await seedAssignedIssue();

View File

@ -11540,6 +11540,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
runId?: string | null;
force?: boolean;
lookbackHours?: number;
now?: Date;
reescalationCooldownMs?: number;
}) {
return recovery.reconcileIssueGraphLiveness({ ...opts, issueCreatedAtGte: await getWorktreeExecutionCutoff() });
}

View File

@ -2,6 +2,7 @@ import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "d
import type { Db } from "@paperclipai/db";
import { clampIssueRequestDepth } from "@paperclipai/shared";
import {
activityLog,
agents,
companies,
costEvents,
@ -30,7 +31,8 @@ export const DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS = 6 * 60 * 60 * 1000
export const DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS = 3;
export const DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS = 24 * 60 * 60 * 1000;
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW = 3;
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW = 1;
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS = 3;
const TERMINAL_RUN_STATUSES = ["succeeded", "interrupted", "failed", "cancelled", "timed_out"] as const;
const ACTIVE_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
@ -54,6 +56,7 @@ type ProductivityReviewThresholds = {
maxRefreshComments: number;
creationWindowMs: number;
maxCreationsPerWindow: number;
maxConsecutiveNoActionReviews: number;
};
type ProductivityReviewEvidence = {
@ -177,6 +180,10 @@ function buildThresholds(overrides?: Partial<ProductivityReviewThresholds>): Pro
overrides?.maxCreationsPerWindow ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW,
DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW,
),
maxConsecutiveNoActionReviews: readPositiveInteger(
overrides?.maxConsecutiveNoActionReviews ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS,
DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS,
),
};
}
@ -307,6 +314,55 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
.then((rows) => Number(rows[0]?.count ?? 0));
}
async function countConsecutiveNoActionProductivityReviews(
companyId: string,
sourceIssueId: string,
thresholds: ProductivityReviewThresholds,
) {
const completedReviews = await db
.select({
createdAt: issues.createdAt,
})
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
eq(issues.originId, sourceIssueId),
eq(issues.status, "done"),
visibleIssueCondition(),
),
)
.orderBy(desc(issues.updatedAt), desc(issues.id))
.limit(thresholds.maxConsecutiveNoActionReviews);
const earliestReviewCreatedAt = completedReviews.at(-1)?.createdAt;
if (!earliestReviewCreatedAt) return 0;
const sourceActions = await db
.select({ createdAt: activityLog.createdAt })
.from(activityLog)
.where(
and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
eq(activityLog.entityId, sourceIssueId),
gte(activityLog.createdAt, earliestReviewCreatedAt),
),
);
let streak = 0;
for (const [index, review] of completedReviews.entries()) {
const nextNewerReviewCreatedAt = completedReviews[index - 1]?.createdAt ?? null;
const sourceAction = sourceActions.some((activity) => {
if (activity.createdAt < review.createdAt) return false;
return !nextNewerReviewCreatedAt || activity.createdAt < nextNewerReviewCreatedAt;
});
if (sourceAction) break;
streak += 1;
}
return streak;
}
async function getRefreshCommentState(companyId: string, reviewIssueId: string) {
return db
.select({
@ -679,6 +735,15 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
return { kind: "creation_capped" as const, reviewIssueId: null };
}
const consecutiveNoActionReviews = await countConsecutiveNoActionProductivityReviews(
evidence.sourceIssue.companyId,
evidence.sourceIssue.id,
opts.thresholds,
);
if (consecutiveNoActionReviews >= opts.thresholds.maxConsecutiveNoActionReviews) {
return { kind: "no_action_suppressed" as const, reviewIssueId: null };
}
const ownerAgentId = await resolveReviewOwnerAgentId(evidence.sourceIssue, evidence.sourceAgent);
let review: Awaited<ReturnType<typeof issuesSvc.create>>;
try {
@ -791,6 +856,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
existing: 0,
snoozed: 0,
creationCapped: 0,
noActionSuppressed: 0,
skipped: 0,
failed: 0,
reviewIssueIds: [] as string[],
@ -831,6 +897,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
if (outcome.kind === "created") result.created += 1;
else if (outcome.kind === "updated") result.updated += 1;
else if (outcome.kind === "creation_capped") result.creationCapped += 1;
else if (outcome.kind === "no_action_suppressed") result.noActionSuppressed += 1;
else result.existing += 1;
if (outcome.reviewIssueId) result.reviewIssueIds.push(outcome.reviewIssueId);
} catch (err) {

View File

@ -75,6 +75,7 @@ const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["interrupted", "failed", "
export const ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS = 60 * 60 * 1000;
export const ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS = 4 * 60 * 60 * 1000;
export const ACTIVE_RUN_OUTPUT_CONTINUE_REARM_MS = 30 * 60 * 1000;
export const DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS = 60 * 60 * 1000;
const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024;
const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery;
const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation;
@ -4019,6 +4020,34 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
}) ?? null;
}
async function findRecentCompletedLivenessRecoveryIssue(
finding: IssueLivenessFinding,
now: Date,
cooldownMs: number,
) {
if (cooldownMs <= 0) return null;
const cutoff = new Date(now.getTime() - cooldownMs);
return db
.select({ id: issues.id })
.from(issues)
.where(
and(
eq(issues.companyId, finding.companyId),
eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation),
or(
eq(issues.originId, finding.incidentKey),
eq(issues.originFingerprint, livenessRecoveryLeafFingerprint(finding)),
),
visibleIssueCondition(),
eq(issues.status, "done"),
gte(issues.updatedAt, cutoff),
),
)
.orderBy(desc(issues.updatedAt), desc(issues.id))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function removeRecoveryBlockerFromSource(recovery: typeof issues.$inferSelect) {
const parsed = parseLivenessIncidentKey(recovery.originId);
if (!parsed) return false;
@ -4374,6 +4403,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
async function createIssueGraphLivenessEscalation(input: {
finding: IssueLivenessFinding;
runId?: string | null;
now: Date;
reescalationCooldownMs: number;
}) {
const issue = await db
.select()
@ -4404,6 +4435,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
});
return { kind: "existing" as const, escalationIssueId: existing.id };
}
if (await findRecentCompletedLivenessRecoveryIssue(
input.finding,
input.now,
input.reescalationCooldownMs,
)) {
return { kind: "cooldown" as const };
}
const ownerSelection = await resolveEscalationOwnerAgentId(input.finding, recoveryIssue);
if (!ownerSelection) return { kind: "skipped" as const };
@ -4778,6 +4816,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
force?: boolean;
lookbackHours?: number;
issueCreatedAtGte?: Date | null;
now?: Date;
reescalationCooldownMs?: number;
}) {
let findings = await collectIssueGraphLivenessFindings();
if (opts?.issueCreatedAtGte) {
@ -4804,7 +4844,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
const lookbackHours = normalizeIssueGraphLivenessAutoRecoveryLookbackHours(
opts?.lookbackHours ?? experimentalSettings.issueGraphLivenessAutoRecoveryLookbackHours,
);
const now = new Date();
const now = opts?.now ?? new Date();
const reescalationCooldownMs = Math.max(
0,
Math.floor(asNumber(opts?.reescalationCooldownMs, DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS)),
);
const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings);
const doneRecoveryBlockerCleanup = await retireDoneLivenessRecoveryBlockers();
@ -4819,6 +4863,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
skipped: 0,
skippedAutoRecoveryDisabled: 0,
skippedOutsideLookback: 0,
skippedReescalationCooldown: 0,
obsoleteRecoveriesRetired: obsoleteRecoveryCleanup.retired,
obsoleteRecoveriesActiveSkipped: obsoleteRecoveryCleanup.activeSkipped,
obsoleteRecoveryBlockerRelationsRemoved: obsoleteRecoveryCleanup.blockerRelationsRemoved,
@ -4868,6 +4913,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
const escalation = await createIssueGraphLivenessEscalation({
finding,
runId: opts?.runId ?? null,
now,
reescalationCooldownMs,
});
if (escalation.kind === "created") {
result.escalationsCreated += 1;
@ -4877,6 +4924,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
result.existingEscalations += 1;
result.issueIds.push(finding.issueId);
result.escalationIssueIds.push(escalation.escalationIssueId);
} else if (escalation.kind === "cooldown") {
result.skippedReescalationCooldown += 1;
result.skipped += 1;
} else {
result.skipped += 1;
}