From 6bdcb70244bf26068672b5275fe6d0f309da496b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 25 Oct 2026 21:32:21 -0700 Subject: [PATCH] perf: reduce unused task payloads in recovery state checks (#17694) Use the existing disposition-state fields for source and child task checks. Preserve full action evidence and the current full-task reload before repair. Verified 61 focused recovery tests, root typecheck, server build, the base counterfactual, full CI, and fresh Apex 5/5 review. Co-Authored-By: Paperclip --- .../services/recovery/disposition-repair.ts | 11 +++ .../recovery/issue-state-read.test.ts | 79 +++++++++++++++++++ server/src/services/recovery/service.ts | 15 +++- 3 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 server/src/services/recovery/issue-state-read.test.ts diff --git a/server/src/services/recovery/disposition-repair.ts b/server/src/services/recovery/disposition-repair.ts index ed315f19e1f..b3ac840187a 100644 --- a/server/src/services/recovery/disposition-repair.ts +++ b/server/src/services/recovery/disposition-repair.ts @@ -30,6 +30,17 @@ type DispositionRepairIssue = Pick< | "monitorNextCheckAt" >; +export const dispositionRepairIssueFields = { + id: issues.id, + companyId: issues.companyId, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + executionPolicy: issues.executionPolicy, + executionState: issues.executionState, + monitorNextCheckAt: issues.monitorNextCheckAt, +}; + export type DispositionRepairSourceState = { fingerprint: string; dependencyIssueIds: string[]; diff --git a/server/src/services/recovery/issue-state-read.test.ts b/server/src/services/recovery/issue-state-read.test.ts new file mode 100644 index 00000000000..f032c77f2eb --- /dev/null +++ b/server/src/services/recovery/issue-state-read.test.ts @@ -0,0 +1,79 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { companies, createDb, issueRecoveryActions, issues } from "@paperclipai/db"; +import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { recoveryService } from "./service.js"; + +const support = await getEmbeddedPostgresTestSupport(); + +describe.skipIf(!support.supported)("recovery issue state reads", () => { + let database: Awaited>; + let db: ReturnType; + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("recovery-issue-state-"); + db = createDb(database.connectionString); + }); + afterAll(async () => { + await db?.$client.end(); + await database?.cleanup(); + }); + + it.each([false, true])("does not load unused source and child descriptions (child=%s)", async (withChild) => { + const [company] = await db.insert(companies).values({ + name: "Recovery state reads", + issuePrefix: `R${randomUUID().slice(0, 7).toUpperCase()}`, + }).returning(); + const description = randomBytes(128 * 1024).toString("base64"); + const [parent] = await db.insert(issues).values({ + companyId: company.id, + title: "Await the board decision", + description, + status: "blocked", + }).returning(); + const child = withChild + ? (await db.insert(issues).values({ + companyId: company.id, + title: "A child that also needs a decision", + description, + status: "blocked", + parentId: parent.id, + }).returning())[0] + : null; + const [action] = await db.insert(issueRecoveryActions).values({ + companyId: company.id, + sourceIssueId: parent.id, + kind: "stranded_assigned_issue", + cause: "stranded_assigned_issue", + fingerprint: randomUUID(), + status: "active", + ownerType: "board", + wakePolicy: { type: "board_escalation" }, + nextAction: "Choose the next disposition", + evidence: { retainedControl: { proof: "unchanged" } }, + }).returning(); + const enqueueWakeup = vi.fn(async () => { + throw new Error("Board-owned work must remain pending"); + }); + const service = recoveryService(db, { enqueueWakeup }); + const events: Array> = []; + const now = Date.now(); + const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event)); + installDatabaseResponseCapture(db, { issues, issueRecoveryActions }, capture); + try { + await service.reconcileStrandedAssignedIssues(); + } finally { + capture.flush(); + } + expect(Number(events.at(-1)?.completedQueries)).toBeGreaterThan(5); + expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(16_384); + expect(enqueueWakeup).not.toHaveBeenCalled(); + expect((await db.select().from(issues).where(eq(issues.id, parent.id)))[0]).toEqual(parent); + if (child) { + expect((await db.select().from(issues).where(eq(issues.id, child.id)))[0]).toEqual(child); + } + expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id)))[0]).toEqual(action); + }); +}); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 3bbf40ab0d2..5a4ce412cd3 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -159,6 +159,7 @@ import { withRecoveryContext } from "./status-only-context.js"; import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js"; import { collectDispositionRepairSourceState, + dispositionRepairIssueFields, dispositionRepairDelayMs, DISPOSITION_REPAIR_MAX_ATTEMPTS, } from "./disposition-repair.js"; @@ -2967,10 +2968,13 @@ export function recoveryService( ); } - async function healthyOpenChildIssues(issue: typeof issues.$inferSelect, sameWorkspaceOnly = false) { + async function healthyOpenChildIssues( + issue: Pick, + sameWorkspaceOnly = false, + ) { if (sameWorkspaceOnly && !issue.projectWorkspaceId) return []; const childCandidates = await db - .select() + .select({ ...dispositionRepairIssueFields, identifier: issues.identifier }) .from(issues) .where( and( @@ -3483,7 +3487,10 @@ export function recoveryService( async function reconcileActiveRecoveryActions() { const rows = await db - .select({ action: issueRecoveryActions, issue: issues }) + .select({ + action: issueRecoveryActions, + issue: { ...dispositionRepairIssueFields, projectWorkspaceId: issues.projectWorkspaceId }, + }) .from(issueRecoveryActions) .innerJoin( issues, @@ -3869,7 +3876,7 @@ export function recoveryService( } async function reconcileDispositionRepair( - issue: typeof issues.$inferSelect, + issue: Pick, latestRun: LatestIssueRun, options: { historicalAttemptCount?: number; legacyEpisode?: LegacyDispositionEpisode } = {}, ): Promise<"queued" | "escalated" | "covered" | "skipped"> {