From 3590e26d6b90a110b13389ebf94de535c5f4990e Mon Sep 17 00:00:00 2001 From: nickyleach <331803+nickyleach@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:42:53 +0000 Subject: [PATCH] fix(server): add a real retrieval path for the omitted resolved recovery-action history The wake prompt told the agent to fetch the recovery-action history for recovery outcomes dropped by the item cap. GET /issues/:id/recovery-actions returned only the active action, so the agent had no way to recover the dropped rows. Add a status=resolved query to the same route, backed by a new listResolvedForIssue lookup, and update the prompt to name the exact call. Co-authored-by: Paperclip --- packages/adapter-utils/src/server-utils.ts | 2 +- .../__tests__/issue-recovery-actions.test.ts | 36 +++++++++++++++++++ server/src/routes/issues.ts | 17 ++++++--- .../services/execution-continuation.test.ts | 5 +++ server/src/services/issue-recovery-actions.ts | 28 ++++++++++++++- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index eb1f7e36d6..bbf82a120f 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -2448,7 +2448,7 @@ export function renderPaperclipWakePrompt( ? [`- omitted unresolved interactions: ${unresolvedInteractionIdsOmittedCount}; more pending interactions exist than the list below shows`] : []), ...(recoveryOutcomesOmittedCount > 0 - ? [`- omitted recovery outcomes: ${recoveryOutcomesOmittedCount}; fetch the recovery-action history for the rest`] + ? [`- omitted recovery outcomes: ${recoveryOutcomesOmittedCount}; call GET /issues/{id}/recovery-actions?status=resolved for the rest`] : []), "Completed actions contain durable results from prior runs. Use those results as completed work; do not issue the same mutation again under a new call id."); const { interactionOutcomes, completedActions, completedWork, recoveryOutcomes, ...requestContext } = continuation; diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 75ea06fe50..8763d41b28 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -1590,6 +1590,42 @@ describeEmbeddedPostgres("issue recovery actions", () => { expect(list.body.actions).toHaveLength(1); }); + it("recovers the resolved recovery-action history the wake payload's item cap omits", async () => { + const { sourceIssueId, companyId } = await seedCompany(); + const resolvedIds = Array.from({ length: 34 }, () => randomUUID()); + await db.insert(issueRecoveryActions).values( + resolvedIds.map((id, index) => ({ + id, + companyId, + sourceIssueId, + kind: "liveness" as const, + status: "resolved" as const, + cause: "process_lost", + fingerprint: `fp-${index}`, + evidence: { executionReconciliation: { decision: "retry" } }, + nextAction: "none", + createdAt: new Date(Date.UTC(2026, 8, 3, 0, index)), + })), + ); + const app = createApp(); + + // The default view stays unchanged: it reports only the active action, + // never the resolved history, so a caller who does not ask for history + // does not get a much larger response by surprise. + const defaultView = await request(app).get(`/api/issues/${sourceIssueId}/recovery-actions`).expect(200); + expect(defaultView.body.active).toBeNull(); + expect(defaultView.body.actions).toEqual([]); + + // `status=resolved` is the retrieval path the wake prompt now names for + // the omitted rows: it must return every resolved action, not just the + // newest 30 the wake payload keeps, in the same oldest-first order. + const history = await request(app) + .get(`/api/issues/${sourceIssueId}/recovery-actions?status=resolved`) + .expect(200); + expect(history.body.actions).toHaveLength(34); + expect(history.body.actions.map((row: { id: string }) => row.id)).toEqual(resolvedIds); + }); + it("projects recovery action metadata into the structured wake payload", async () => { const { companyId, managerId, coderId, sourceIssueId } = await seedCompany(); const action = await issueRecoveryActionService(db).upsertSourceScoped({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0cc963a4e0..2e27c6bde0 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -8919,10 +8919,19 @@ export function issueRoutes( trigger: "read_projection", actor: getActorInfo(req), }); - res.json({ - active, - actions: active ? [active] : [], - }); + // The default response reports only the active action. The wake payload + // caps its resolved-recovery-outcome list and tells the agent to fetch + // the rest here with `?status=resolved`; return the full resolved + // history, oldest first, so that fetch actually recovers the omitted + // rows instead of repeating the capped active-only view. + const wantsResolvedHistory = + typeof req.query.status === "string" && req.query.status.trim().toLowerCase() === "resolved"; + const actions = wantsResolvedHistory + ? await recoveryActionsSvc.listResolvedForIssue(issue.companyId, issue.id) + : active + ? [active] + : []; + res.json({ active, actions }); }); router.post( diff --git a/server/src/services/execution-continuation.test.ts b/server/src/services/execution-continuation.test.ts index c1e7e78ca5..6064fe4d13 100644 --- a/server/src/services/execution-continuation.test.ts +++ b/server/src/services/execution-continuation.test.ts @@ -17,6 +17,7 @@ import { startEmbeddedPostgresTestDatabase, } from "../__tests__/helpers/embedded-postgres.js"; import { buildExecutionContinuation, currentContinuationOrigins } from "./execution-continuation.js"; +import { issueRecoveryActionService } from "./issue-recovery-actions.js"; const support = await getEmbeddedPostgresTestSupport(); (support.supported ? describe : describe.skip)( "authorized continuation context", @@ -548,6 +549,10 @@ const support = await getEmbeddedPostgresTestSupport(); recoveryActionIds.slice(10), ); }); + it("recovers every dropped recovery outcome through the resolved-history retrieval path", async () => { + const resolved = await issueRecoveryActionService(db).listResolvedForIssue(companyId, issueId); + expect(resolved.map((row) => row.id)).toEqual(recoveryActionIds); + }); }, ); diff --git a/server/src/services/issue-recovery-actions.ts b/server/src/services/issue-recovery-actions.ts index 51d5df49ad..989ee737af 100644 --- a/server/src/services/issue-recovery-actions.ts +++ b/server/src/services/issue-recovery-actions.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, asc, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { issueRecoveryActions } from "@paperclipai/db"; import type { @@ -170,6 +170,31 @@ export function issueRecoveryActionService(db: Db) { return row ? toReadModel(row) : null; } + // The wake payload keeps only the newest WAKE_CONTEXT_ITEM_CAP resolved + // recovery actions and reports the rest as an omitted count. This is the + // retrieval path for that omitted history: the same source rows, in the + // same order, with no cap. Match the resolved-status filter and the + // createdAt/id order the wake payload uses, so a caller can reconstruct + // the full list the cap drew from. + async function listResolvedForIssue( + companyId: string, + sourceIssueId: string, + dbOrTx: DbOrTransaction = db, + ): Promise { + const rows = await dbOrTx + .select() + .from(issueRecoveryActions) + .where( + and( + eq(issueRecoveryActions.companyId, companyId), + eq(issueRecoveryActions.sourceIssueId, sourceIssueId), + eq(issueRecoveryActions.status, "resolved"), + ), + ) + .orderBy(asc(issueRecoveryActions.createdAt), asc(issueRecoveryActions.id)); + return rows.map(toReadModel); + } + async function listActiveForIssues(companyId: string, sourceIssueIds: string[]) { if (sourceIssueIds.length === 0) return new Map(); const rows = await db @@ -501,6 +526,7 @@ export function issueRecoveryActionService(db: Db) { return { getActiveForIssue, listActiveForIssues, + listResolvedForIssue, resolveActiveForIssue, upsertSourceScoped, };