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 <noreply@paperclip.ing>
This commit is contained in:
nickyleach 2026-09-10 09:42:53 +00:00 committed by Nicky Leach
parent 53bf0667b8
commit 3590e26d6b
No known key found for this signature in database
GPG Key ID: 4861541D36B2037E
5 changed files with 82 additions and 6 deletions

View File

@ -2448,7 +2448,7 @@ export function renderPaperclipWakePrompt(
? [`- omitted unresolved interactions: ${unresolvedInteractionIdsOmittedCount}; more pending interactions exist than the list below shows`] ? [`- omitted unresolved interactions: ${unresolvedInteractionIdsOmittedCount}; more pending interactions exist than the list below shows`]
: []), : []),
...(recoveryOutcomesOmittedCount > 0 ...(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."); "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; const { interactionOutcomes, completedActions, completedWork, recoveryOutcomes, ...requestContext } = continuation;

View File

@ -1590,6 +1590,42 @@ describeEmbeddedPostgres("issue recovery actions", () => {
expect(list.body.actions).toHaveLength(1); 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 () => { it("projects recovery action metadata into the structured wake payload", async () => {
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany(); const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
const action = await issueRecoveryActionService(db).upsertSourceScoped({ const action = await issueRecoveryActionService(db).upsertSourceScoped({

View File

@ -8919,10 +8919,19 @@ export function issueRoutes(
trigger: "read_projection", trigger: "read_projection",
actor: getActorInfo(req), actor: getActorInfo(req),
}); });
res.json({ // The default response reports only the active action. The wake payload
active, // caps its resolved-recovery-outcome list and tells the agent to fetch
actions: active ? [active] : [], // 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( router.post(

View File

@ -17,6 +17,7 @@ import {
startEmbeddedPostgresTestDatabase, startEmbeddedPostgresTestDatabase,
} from "../__tests__/helpers/embedded-postgres.js"; } from "../__tests__/helpers/embedded-postgres.js";
import { buildExecutionContinuation, currentContinuationOrigins } from "./execution-continuation.js"; import { buildExecutionContinuation, currentContinuationOrigins } from "./execution-continuation.js";
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
const support = await getEmbeddedPostgresTestSupport(); const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)( (support.supported ? describe : describe.skip)(
"authorized continuation context", "authorized continuation context",
@ -548,6 +549,10 @@ const support = await getEmbeddedPostgresTestSupport();
recoveryActionIds.slice(10), 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);
});
}, },
); );

View File

@ -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 type { Db } from "@paperclipai/db";
import { issueRecoveryActions } from "@paperclipai/db"; import { issueRecoveryActions } from "@paperclipai/db";
import type { import type {
@ -170,6 +170,31 @@ export function issueRecoveryActionService(db: Db) {
return row ? toReadModel(row) : null; 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<IssueRecoveryAction[]> {
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[]) { async function listActiveForIssues(companyId: string, sourceIssueIds: string[]) {
if (sourceIssueIds.length === 0) return new Map<string, IssueRecoveryAction>(); if (sourceIssueIds.length === 0) return new Map<string, IssueRecoveryAction>();
const rows = await db const rows = await db
@ -501,6 +526,7 @@ export function issueRecoveryActionService(db: Db) {
return { return {
getActiveForIssue, getActiveForIssue,
listActiveForIssues, listActiveForIssues,
listResolvedForIssue,
resolveActiveForIssue, resolveActiveForIssue,
upsertSourceScoped, upsertSourceScoped,
}; };