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`]
|
? [`- 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;
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue