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:
parent
53bf0667b8
commit
3590e26d6b
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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[]) {
|
||||
if (sourceIssueIds.length === 0) return new Map<string, IssueRecoveryAction>();
|
||||
const rows = await db
|
||||
|
|
@ -501,6 +526,7 @@ export function issueRecoveryActionService(db: Db) {
|
|||
return {
|
||||
getActiveForIssue,
|
||||
listActiveForIssues,
|
||||
listResolvedForIssue,
|
||||
resolveActiveForIssue,
|
||||
upsertSourceScoped,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue