diff --git a/server/src/services/recovery/issue-state-read.test.ts b/server/src/services/recovery/issue-state-read.test.ts index f032c77f2eb..5a8ad5acf28 100644 --- a/server/src/services/recovery/issue-state-read.test.ts +++ b/server/src/services/recovery/issue-state-read.test.ts @@ -1,5 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { companies, createDb, issueRecoveryActions, issues } from "@paperclipai/db"; +import { agents, 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"; @@ -76,4 +76,42 @@ describe.skipIf(!support.supported)("recovery issue state reads", () => { } expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id)))[0]).toEqual(action); }); + + it("keeps a candidate waiting for agent setup without reading its description", async () => { + const [company] = await db.insert(companies).values({ + name: "Recovery candidate read", + issuePrefix: `R${randomUUID().slice(0, 7).toUpperCase()}`, + }).returning(); + const [agent] = await db.insert(agents).values({ + companyId: company.id, + name: "Preparing agent", + lifecycleState: "preparing", + }).returning(); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Wait for agent setup", + description: randomBytes(128 * 1024).toString("base64"), + status: "todo", + assigneeAgentId: agent.id, + }).returning(); + const enqueueWakeup = vi.fn(async () => { + throw new Error("Setup still owns this task"); + }); + 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, { agents, issues, issueRecoveryActions }, capture); + try { + expect(await service.reconcileStrandedAssignedIssues()).toMatchObject({ skipped: 1 }); + } 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, issue.id)))[0]).toEqual(issue); + expect((await db.select().from(agents).where(eq(agents.id, agent.id)))[0]).toEqual(agent); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.companyId, company.id))).toEqual([]); + }); }); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 5a4ce412cd3..4b21e406c4e 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -864,7 +864,7 @@ function formatIssueLinksForComment( } function isStrandedIssueRecoveryIssue( - issue: Pick, + issue: Pick, ) { return isStrandedIssueRecoveryOriginKind(issue.originKind); } @@ -936,6 +936,10 @@ function isRepeatedProductiveContinuationRecovery( ); } +type RecoveryIssue = Omit; + +const { description: _unusedIssueDescription, ...recoveryIssueFields } = getTableColumns(issues); + export function recoveryService( db: Db, deps: { @@ -1251,7 +1255,7 @@ export function recoveryService( * Keep it separate from the broader monitor/delegated/legacy wait predicate. */ async function hasCurrentNativePassiveWait( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, latestRun: LatestIssueRun, ): Promise { if ( @@ -1461,7 +1465,7 @@ export function recoveryService( } async function hasPersistedDurableWaitPath( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, latestRun: LatestIssueRun, ) { if (issue.monitorNextCheckAt) return true; @@ -1526,7 +1530,7 @@ export function recoveryService( } async function nativeBlockedUnblockAction( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, latestRun: LatestIssueRun, database: Db = db, ) { @@ -1597,7 +1601,7 @@ export function recoveryService( } async function repairNativeBlockedWait( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, latestRun: NonNullable, candidate: NonNullable< Awaited> @@ -1801,7 +1805,7 @@ export function recoveryService( } async function wasTodoHandedBackDuringOrAfterLatestRun( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, latestRun: LatestIssueRun, ) { if (issue.status !== "todo" || latestRun?.status !== "succeeded") @@ -2133,7 +2137,7 @@ export function recoveryService( } async function enqueueInitialAssignedTodoDispatch( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, agentId: string, ) { return deps.enqueueWakeup(agentId, { @@ -2166,7 +2170,7 @@ export function recoveryService( // contract is that nothing runs until the user types. Until a user-authored // comment exists on it, the issue is intentionally idle rather than stranded. async function isOnboardingFirstTaskAwaitingUser( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, ) { if (issue.originKind !== ONBOARDING_FIRST_TASK_ORIGIN_KIND) return false; const [result] = await db.execute<{ awaiting: boolean }>(sql`SELECT @@ -2175,7 +2179,7 @@ export function recoveryService( } async function isInvocationBudgetBlocked( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, agentId: string, ) { const budgetBlock = await budgets.getInvocationBlock( @@ -2409,12 +2413,12 @@ export function recoveryService( } } - function isStrandedIssueRecoveryIssue(issue: typeof issues.$inferSelect) { + function isStrandedIssueRecoveryIssue(issue: RecoveryIssue) { return issue.originKind === STRANDED_ISSUE_RECOVERY_ORIGIN_KIND; } async function buildNestedStrandedRecoveryLine( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, prefix: string, ) { const sourceIssueId = readNonEmptyString(issue.originId); @@ -2445,7 +2449,7 @@ export function recoveryService( } function resolveStrandedRecoveryRouting(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; latestRun: LatestIssueRun; }) { const originalAgentId = @@ -2468,7 +2472,7 @@ export function recoveryService( } function strandedRecoveryActionFingerprint(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; recoveryCause: StrandedRecoveryCause; latestRun: LatestIssueRun; }) { @@ -2514,7 +2518,7 @@ export function recoveryService( } function buildStrandedRecoveryActionEvidence(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; latestRun: LatestIssueRun; previousStatus: StrandedPreviousStatus; recoveryCause: StrandedRecoveryCause; @@ -2549,7 +2553,7 @@ export function recoveryService( } async function ensureSourceScopedStrandedRecoveryAction(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; latestRun: LatestIssueRun; previousStatus: StrandedPreviousStatus; recoveryCause?: StrandedRecoveryCause; @@ -2680,7 +2684,7 @@ export function recoveryService( } async function ensureProviderQuotaWaitRecoveryMonitor(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; latestRun: LatestIssueRun; actionId: string; agentId: string; @@ -2780,7 +2784,7 @@ export function recoveryService( } function buildRecoveryIssueInPlaceEscalationComment(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; previousStatus: StrandedPreviousStatus; latestRun: LatestIssueRun; prefix: string; @@ -2815,7 +2819,7 @@ export function recoveryService( } async function escalateStrandedRecoveryIssueInPlace(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; previousStatus: StrandedPreviousStatus; latestRun: LatestIssueRun; }, transaction?: { @@ -2954,7 +2958,7 @@ export function recoveryService( ); } - async function openChildIssues(issue: typeof issues.$inferSelect) { + async function openChildIssues(issue: RecoveryIssue) { return db .select({ id: issues.id, identifier: issues.identifier }) .from(issues) @@ -2969,7 +2973,7 @@ export function recoveryService( } async function healthyOpenChildIssues( - issue: Pick, + issue: Pick, sameWorkspaceOnly = false, ) { if (sameWorkspaceOnly && !issue.projectWorkspaceId) return []; @@ -3001,7 +3005,7 @@ export function recoveryService( } async function resolveContinuationWaitingOnReview( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, ) { const [existingBlockers, openChildren] = await Promise.all([ existingUnresolvedBlockerIssues(issue.companyId, issue.id), @@ -3102,7 +3106,7 @@ export function recoveryService( } async function resolveDispositionRepairActionAsCovered( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, reason: string, ) { const active = await recoveryActionsSvc.getActiveForIssue( @@ -3136,7 +3140,7 @@ export function recoveryService( } async function ensureDispositionRepairAction(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; latestRun: LatestIssueRun; fingerprint: string; attemptCount: number; @@ -3214,7 +3218,7 @@ export function recoveryService( } async function scheduleDispositionRepairAttempt(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; latestRun: LatestIssueRun; action: Awaited>; fingerprint: string; @@ -3651,7 +3655,7 @@ export function recoveryService( } async function escalateDispositionRepair(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; latestRun: LatestIssueRun; fingerprint: string; attemptCount: number; @@ -3796,7 +3800,7 @@ export function recoveryService( } async function decidePersistedLegacyContinuation( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, runId: string, state: Awaited>, episode: LegacyDispositionEpisode, @@ -3876,7 +3880,7 @@ export function recoveryService( } async function reconcileDispositionRepair( - issue: Pick, + issue: Pick, latestRun: LatestIssueRun, options: { historicalAttemptCount?: number; legacyEpisode?: LegacyDispositionEpisode } = {}, ): Promise<"queued" | "escalated" | "covered" | "skipped"> { @@ -4023,7 +4027,7 @@ export function recoveryService( } async function escalateStrandedAssignedIssue(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; previousStatus: StrandedPreviousStatus; latestRun: LatestIssueRun; comment?: string; @@ -4390,7 +4394,7 @@ export function recoveryService( } async function scheduleProviderQuotaRecoveryMonitor(input: { - issue: typeof issues.$inferSelect; + issue: RecoveryIssue; latestRun: NonNullable; classification: Extract< NonNullable, @@ -4473,7 +4477,7 @@ export function recoveryService( } function getAdapterFailureRecoveryTargetAgentId( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, ) { if (issue.status !== "in_review") return issue.assigneeAgentId; @@ -4488,7 +4492,7 @@ export function recoveryService( } function hasPendingProviderQuotaRecoveryMonitor( - issue: typeof issues.$inferSelect, + issue: RecoveryIssue, latestRun: LatestIssueRun, now: Date, ) { @@ -4510,7 +4514,7 @@ export function recoveryService( issueCreatedAtGte?: Date | null; }) { const candidates = await db - .select() + .select(recoveryIssueFields) .from(issues) .where( and(