mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
fix(recovery): continue Retry after completed file preservation (#18845)
Reuse completed file preservation for task Retry and show bounded admission explanations, including newer saved messages and identifier routes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
519b345d69
commit
a80eec1164
13 files changed
+276
-18
No files matched your search
@@ -7,6 +7,28 @@ const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: n
|
||||
resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } };
|
||||
const agent = { status: "idle", pauseReason: null };
|
||||
|
||||
it("explains a persisted Retry wait without exposing the saved payload", () => {
|
||||
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
|
||||
const wait = { id: "retry", requestedAt: new Date(), sourceRunId: failed.id,
|
||||
executionWaitReason: "workspace_repair_required" };
|
||||
const presentation = projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false });
|
||||
expect(presentation).toMatchObject({ state: "failed", canRetry: true,
|
||||
retryWait: { reason: "workspace_repair_required", message: "Retry is waiting for Paperclip to recover the saved files. Your message is saved." } });
|
||||
expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, sourceRunId: "another-run" }, paused: false, approval: false }))
|
||||
.not.toHaveProperty("retryWait");
|
||||
expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, executionWaitReason: "secret-payload" }, paused: false, approval: false }))
|
||||
.toMatchObject({ retryWait: { reason: "execution_recovery", message: "Retry is waiting for execution checks. Your message is saved." } });
|
||||
expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: true, approval: false }))
|
||||
.toMatchObject({ state: "waiting_for_user", userAction: "resume" });
|
||||
});
|
||||
|
||||
it.each(["cleanup_quarantined", "cancelled_by_operator"])("does not offer Retry for terminal Retry waits: %s", reason => {
|
||||
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
|
||||
expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false,
|
||||
wait: { id: "retry", requestedAt: new Date(), sourceRunId: failed.id, executionWaitReason: reason } }))
|
||||
.toMatchObject({ state: "failed", canRetry: false, retryWait: { reason } });
|
||||
});
|
||||
|
||||
it("preserves the actual workspace preparation error without changing retry authority", () => {
|
||||
const failedRun = { ...run, errorCode: "workspace_validation_failed",
|
||||
error: "The selected project has no repository source for a remote workspace.", resultJson: null };
|
||||
|
||||
@@ -19,10 +19,12 @@ type ProjectionIssue = Pick<typeof issues.$inferSelect, "id" | "companyId" | "st
|
||||
type ProjectionRun = Pick<typeof heartbeatRuns.$inferSelect,
|
||||
"id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> &
|
||||
Partial<Pick<typeof heartbeatRuns.$inferSelect, "agentId" | "error" | "companyId" | "nativeIssueId" | "runtimeMode">>;
|
||||
type ProjectionWait = { id: string; requestedAt: Date; sourceRunId?: string | null; executionWaitReason?: string | null };
|
||||
type ProjectionState = {
|
||||
run?: ProjectionRun;
|
||||
action?: typeof issueRecoveryActions.$inferSelect;
|
||||
wait?: { id: string; requestedAt: Date };
|
||||
wait?: ProjectionWait;
|
||||
retryWait?: ProjectionWait;
|
||||
agent?: Pick<typeof agents.$inferSelect, "status" | "pauseReason">;
|
||||
projectAccessAllowed?: boolean;
|
||||
projectAccessProjectId?: string | null;
|
||||
@@ -34,9 +36,27 @@ type ProjectionState = {
|
||||
};
|
||||
|
||||
function boundedJsonString(value: SQL, limit: number) {
|
||||
return sql`case when jsonb_typeof(${value}) = 'string' then left(${value} #>> '{}', ${limit}) else null end`;
|
||||
return sql<string | null>`case when jsonb_typeof(${value}) = 'string' then left(${value} #>> '{}', ${limit}) else null end`;
|
||||
}
|
||||
|
||||
// Persisted wait messages can contain diagnostics. Ordinary task views expose
|
||||
// only these stable categories, never arbitrary payloads or source identifiers.
|
||||
const retryWaitMessages: Record<string, string> = {
|
||||
workspace_repair_required: "Retry is waiting for Paperclip to recover the saved files. Your message is saved.",
|
||||
remote_cleanup: "Retry is waiting for the previous environment to stop. Your message is saved.",
|
||||
local_cleanup: "Retry is waiting for the previous execution to finish cleanup. Your message is saved.",
|
||||
cleanup_pending: "Retry is waiting for the previous execution to finish cleanup. Your message is saved.",
|
||||
execution_settling: "Retry is waiting for the previous execution to stop. Your message is saved.",
|
||||
controller_settling: "Retry is waiting for the previous execution to finish recovery. Your message is saved.",
|
||||
process_identity_missing: "Retry is waiting for Paperclip to verify that the previous execution stopped. Your message is saved.",
|
||||
process_running: "Retry is waiting for the previous execution to stop. Your message is saved.",
|
||||
execution_active: "Retry is waiting for the current run to finish. Your message is saved.",
|
||||
decision_pending: "Retry is waiting for a pending approval or question to be resolved. Your message is saved.",
|
||||
finalizing_saved_result: "Paperclip is finishing the saved result before Retry can continue.",
|
||||
cleanup_quarantined: "Retry cannot continue this attempt. Send a new message after the previous execution has stopped.",
|
||||
cancelled_by_operator: "This attempt was stopped. Send a new message to continue from saved work.",
|
||||
};
|
||||
|
||||
/** Recognize old persisted failures without displaying arbitrary run error text. */
|
||||
function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined {
|
||||
const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary.";
|
||||
@@ -51,6 +71,7 @@ function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string |
|
||||
* locks. The same projection is used in detail, lists and attention surfaces. */
|
||||
export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null {
|
||||
const { run, wait, action, agent, project, company } = input;
|
||||
const retryWait = input.retryWait ?? wait;
|
||||
if (!run) return null;
|
||||
const system = readSystemRecovery(action?.evidence);
|
||||
const base: TaskInterruptionPresentation = { intentId: wait?.id ?? null, responsibility: "system",
|
||||
@@ -90,6 +111,13 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
|
||||
const activeObservation = action?.evidence.systemRecoveryActiveAt;
|
||||
if (system?.state === "running" && typeof activeObservation === "number" &&
|
||||
activeObservation > now - 45_000) return { ...base, state: "working" };
|
||||
if (retryWait?.sourceRunId === run.id && retryWait.executionWaitReason) {
|
||||
const reason = Object.hasOwn(retryWaitMessages, retryWait.executionWaitReason) ? retryWait.executionWaitReason : "execution_recovery";
|
||||
// Keep the latest input receipt so message submission can settle independently.
|
||||
return { ...base, state: "failed",
|
||||
canRetry: !["cleanup_quarantined", "cancelled_by_operator"].includes(reason), retryWait: { reason,
|
||||
message: retryWaitMessages[reason] ?? "Retry is waiting for execution checks. Your message is saved." } };
|
||||
}
|
||||
if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted")
|
||||
return { ...base, state: "working" };
|
||||
return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) };
|
||||
@@ -118,6 +146,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
|
||||
const latestRuns = db.select({ id: candidates.id }).from(candidates)
|
||||
.orderBy(desc(candidates.createdAt), desc(candidates.id)).limit(1).as("latest_interruption_runs");
|
||||
const wakeIssue = sql<string>`${agentWakeupRequests.payload}->>'issueId'`;
|
||||
const isRetryWait = sql<boolean>`coalesce(jsonb_typeof(${agentWakeupRequests.payload}#>'{taskRetry,sourceRunId}') = 'string', false)`;
|
||||
const agentIds = selected.flatMap(issue => issue.assigneeAgentId ? [issue.assigneeAgentId] : []);
|
||||
const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []);
|
||||
const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows] = await Promise.all([
|
||||
@@ -148,10 +177,14 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
|
||||
}).from(requested).innerJoinLateral(latestRuns, sql`true`).innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, latestRuns.id)))
|
||||
.leftJoin(agents, and(eq(agents.companyId, heartbeatRuns.companyId), eq(agents.id, heartbeatRuns.agentId)))
|
||||
.orderBy(requestedIssueId),
|
||||
db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt })
|
||||
// Keep at most two candidates per task: latest Retry and latest other input.
|
||||
// A new message must not hide an existing Retry's admission explanation.
|
||||
db.selectDistinctOn([wakeIssue, isRetryWait], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt,
|
||||
sourceRunId: boundedJsonString(sql`${agentWakeupRequests.payload}#>'{taskRetry,sourceRunId}'`, 160),
|
||||
executionWaitReason: boundedJsonString(sql`${agentWakeupRequests.payload}#>'{executionWait,reason}'`, 80) })
|
||||
.from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), inArray(wakeIssue, ids),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"), eq(agentWakeupRequests.requestedByActorType, "user")))
|
||||
.orderBy(wakeIssue, desc(agentWakeupRequests.requestedAt)),
|
||||
.orderBy(wakeIssue, isRetryWait, desc(agentWakeupRequests.requestedAt), desc(agentWakeupRequests.id)),
|
||||
agentIds.length ? db.select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason }).from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))) : [],
|
||||
projectIds.length ? db.select({ id: projects.id, pausedAt: projects.pausedAt, pauseReason: projects.pauseReason }).from(projects)
|
||||
@@ -173,7 +206,13 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
|
||||
sql`${issueRecoveryActions.evidence} ? 'systemRecovery'`,
|
||||
)).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : [];
|
||||
const byIssue = <T extends { issueId: string }>(rows: T[]) => new Map(rows.map(row => [row.issueId, row]));
|
||||
const runMap = byIssue(runs), waitMap = byIssue(waits);
|
||||
const runMap = byIssue(runs), waitMap = new Map<string, typeof waits[number]>();
|
||||
const retryWaitMap = byIssue(waits.filter(wait => wait.sourceRunId));
|
||||
for (const wait of waits) {
|
||||
const current = waitMap.get(wait.issueId);
|
||||
if (!current || wait.requestedAt > current.requestedAt ||
|
||||
(wait.requestedAt.getTime() === current.requestedAt.getTime() && wait.id > current.id)) waitMap.set(wait.issueId, wait);
|
||||
}
|
||||
// Recheck current authority, including standard trust, root-issue grants and
|
||||
// project policy. Do not reuse the failed run's frozen policy for a new retry.
|
||||
// Only denied tasks need these reads; deduplicate projects and batch per agent.
|
||||
@@ -205,7 +244,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
|
||||
const required = requirements.get(issue.id);
|
||||
const denied = required?.projectIds.filter(projectId => currentAccess.get(`${issue.assigneeAgentId}:${projectId}`) !== true);
|
||||
const presentation = projectTaskInterruption(issue, {
|
||||
run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id),
|
||||
run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id), retryWait: retryWaitMap.get(issue.id),
|
||||
projectAccessAllowed: denied?.length === 0,
|
||||
projectAccessProjectId: denied?.includes(issue.projectId ?? "") ? issue.projectId : null,
|
||||
workspaceSelectionAvailable: required?.available ?? false,
|
||||
|
||||
@@ -331,8 +331,11 @@ export async function requestTaskRetry(input: {
|
||||
if (!busy) {
|
||||
for (const action of records) {
|
||||
const previous = readSystemRecovery(action.evidence);
|
||||
if (!previous || previous.state === "complete") continue;
|
||||
if (previous.state === "running") {
|
||||
if (!previous) continue;
|
||||
// A completed preservation still owns its snapshot evidence. Bind the
|
||||
// new intent so sibling holds can select that same continuation, without
|
||||
// repeating recovery or renewing its attempt budget.
|
||||
if (previous.state === "complete" || previous.state === "running") {
|
||||
await updateSystemRecovery(db, { companyId: issue.companyId, issueId: issue.id, runId: latest.id,
|
||||
actionId: action.id }, previous, { ...previous, intentId: active?.id ?? id });
|
||||
continue;
|
||||
|
||||
@@ -27,7 +27,7 @@ import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { workspaceOperationService } from "./workspace-operations.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { projectTaskInterruption } from "./task-interruption-presentation.js";
|
||||
import { projectTaskInterruption, taskInterruptionPresentation } from "./task-interruption-presentation.js";
|
||||
import * as nativeWorkspaceSync from "./native-runtime/native-workspace-sync.js";
|
||||
import { GitShallowMetadataError } from "@paperclipai/adapter-utils/git-workspace-sync";
|
||||
import { readSystemRecovery } from "./system-recovery-state.js";
|
||||
@@ -1674,6 +1674,66 @@ describe("automatic retained workspace recovery", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.each(["stopped", "unverified_stop"])("retries a recovered snapshot with sibling holds once: %s", async state => {
|
||||
const f = await seed("native", false);
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: {
|
||||
schema: "paperclip.native-workspace-sync/v3", state: "prepared", descriptorSha256: "a".repeat(64),
|
||||
baselineSha256: "b".repeat(64), finalHostSha256: null, workspaceId: f.issueId,
|
||||
leaseId: f.lease.id, providerLeaseId: f.providerLeaseId, remoteCwd: f.remote, resourceDisposition: "keep_running",
|
||||
} } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId,
|
||||
kind: "active_run_watchdog", status: "resolved", ownerType: "board", outcome: "blocked",
|
||||
cause: "provider_failure_meaning_unverified", fingerprint: randomUUID(), nextAction: "Inspect original failure",
|
||||
evidence: { runId: f.runId, automaticRecovery: { replay: "blocked" } } });
|
||||
const source = await run(f.runId);
|
||||
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: source.agentId, status: "running" });
|
||||
await recoverRetainedWorkspace(f.input);
|
||||
const recovered = readSystemRecovery((await action(f.actionId)).evidence)!;
|
||||
expect(recovered.state).toBe("complete");
|
||||
expect(recovered.intentId).toBeNull();
|
||||
if (state === "unverified_stop") await db.update(environmentLeases).set({ metadata: f.lease.metadata })
|
||||
.where(eq(environmentLeases.id, f.lease.id));
|
||||
const receipt = await requestTaskRetry({ db, companyId: f.companyId, issueId: f.issueId,
|
||||
actorId: "board", actorSource: "local_implicit", idempotencyKey: randomUUID(), observedRunId: f.runId });
|
||||
const service = heartbeatService(db);
|
||||
await Promise.all([service.resumeTaskRetry(f.companyId, receipt.requestId!), service.resumeTaskRetry(f.companyId, receipt.requestId!)]);
|
||||
const turns = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")));
|
||||
if (state === "unverified_stop") {
|
||||
expect(turns).toHaveLength(0);
|
||||
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.requestId!));
|
||||
expect(wake.payload?.executionWait).toMatchObject({ reason: "remote_cleanup" });
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
expect(await taskInterruptionPresentation(db, task)).toMatchObject({ state: "failed", canRetry: true,
|
||||
retryWait: { reason: "remote_cleanup", message: "Retry is waiting for the previous environment to stop. Your message is saved." } });
|
||||
const commentId = randomUUID(), messageWakeId = randomUUID();
|
||||
await db.insert(issueComments).values({ id: commentId, companyId: f.companyId, issueId: f.issueId,
|
||||
authorType: "user", authorUserId: "board", body: "Keep my saved message while the previous environment stops." });
|
||||
await db.insert(agentWakeupRequests).values({ id: messageWakeId, companyId: f.companyId, agentId: wake.agentId,
|
||||
source: "automation", reason: "issue_commented", status: "deferred_issue_execution",
|
||||
requestedByActorType: "user", requestedByActorId: "board", requestedAt: new Date(wake.requestedAt.getTime() + 1),
|
||||
payload: { issueId: f.issueId, commentId } });
|
||||
expect(await taskInterruptionPresentation(db, task)).toMatchObject({ intentId: messageWakeId,
|
||||
retryWait: { reason: "remote_cleanup" } });
|
||||
await db.update(agentWakeupRequests).set({ status: "coalesced" }).where(eq(agentWakeupRequests.id, receipt.requestId!));
|
||||
const newInput = await taskInterruptionPresentation(db, task);
|
||||
expect(newInput).toMatchObject({ state: "working", intentId: messageWakeId });
|
||||
expect(newInput).not.toHaveProperty("retryWait");
|
||||
return;
|
||||
}
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0].contextSnapshot).toMatchObject({ forceFreshSession: true, previousRunId: f.runId, taskRetryRequestId: receipt.requestId });
|
||||
expect(await readFreshWorkspaceReadiness(db, turns[0])).toMatchObject({ mode: "fresh_isolated_workspace", sourceRunId: f.runId });
|
||||
const fresh = path.join(root, randomUUID()); await mkdir(fresh);
|
||||
const attached = await attachPreservedWorkspaceSnapshots({ db, companyId: f.companyId, issueId: f.issueId,
|
||||
sourceRunIds: [turns[0].id], cwd: fresh });
|
||||
expect(await readFile(path.join(attached[0].path, "files", "saved.txt"), "utf8")).toBe("finished work\n");
|
||||
expect(await readFile(path.join(f.host, "newer.txt"), "utf8")).toBe("newer local work\n");
|
||||
const final = readSystemRecovery((await action(f.actionId)).evidence)!;
|
||||
expect(final).toMatchObject({ generationId: recovered.generationId, attempts: recovered.attempts,
|
||||
retriesUsed: recovered.retriesUsed, preservationOperationIds: recovered.preservationOperationIds, intentId: receipt.requestId });
|
||||
expect((await run(f.runId)).status).toBe("failed");
|
||||
}, 30_000);
|
||||
|
||||
it.each(["copyback_unknown", "source_unavailable", "executor_unknown"])("ordinary task Retry evaluates fresh isolation independently: %s", async failure => {
|
||||
const f = await seed("legacy", false);
|
||||
const source = await run(f.runId);
|
||||
|
||||
Reference in new issue
Block a user