Files
PaperClipAI/server/src/services/native-runtime/native-failed-workspace-recovery.ts
T
Devin FoleyandPaperclip 7840d894fa fix: retain files after accepted remote copy-back exhausts retries (#19491)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Remote workspaces keep unsaved files bound to the run that wrote
them.
> - An accepted result can exhaust its copy-back retries before those
files are saved.
> - Failed-workspace preservation rejects every accepted result, so a
stopped writer can keep the workspace held after a newer setup attempt
fails.
> - This pull request lets the existing owner retain files from that
exact stopped remote writer.
> - File recovery can then release the hold without applying the old
result or repeating its provider work.

## Linked Issues or Issue Description

**What happened?**

A failed native remote writer had an accepted result and exhausted
copy-back retries. Its sandbox was stopped with a valid retained-source
receipt. A newer attempt failed on the writer's unsaved workspace. Task
Retry could not register the missing file-recovery obligation because
preservation returned as soon as it found the old accepted result.

**Expected behavior**

Task Retry must let the existing recovery owner save the exact retained
files. The accepted result, failed attempts and current task must stay
intact. The old provider work must not run again.

**Steps to reproduce**

1. Finish native work in a remote task workspace and accept its
completion result.
2. Exhaust copy-back retries, stop and retain that exact sandbox, and
leave the workspace dirty.
3. Let a newer setup attempt fail on the old writer's hold.
4. Use ordinary task Retry. Before this fix, the missing file obligation
is never recorded.

**Paperclip version or commit**

Reproduced against master at `51a65af9b81f3c60f8210047e9a3eeaada7bc155`
with isolated PostgreSQL and real files.

Related: #19385 preserves accepted copy-back interrupted by Stop. #19422
admits saved input after verified recovery. #19464 and #19466 repair
preparation and Retry continuation. This change covers an older remote
writer whose accepted copy-back has exhausted its retries.

## What Changed

- Admit only terminal, exhausted accepted copy-back on a failed remote
writer with a finished run and no live controller or finalizer.
- Require the exact stop-and-retain receipt, accepted completion
contract and current dirty workspace binding. Keep native-sync sources
on their existing export path.
- Use the existing file-preservation recorder and worker. Add no new
recovery worker, schema, polling or replay path.
- Cover bound and orphaned entry points, reusable leases, changed proof
and the ordinary HTTP Retry journey. Document the recovery behavior.

## Verification

- Causal baseline: the corrected bound-writer and orphaned-lease tests
both fail without the source change and pass with it.
- Assembled acceptance: authenticated HTTP Retry records the old
writer's missing obligation; the real recovery service saves its files;
concurrent continuation calls queue exactly one successor for the newer
failed setup. PostgreSQL, filesystem, tar, Git and the API/service path
are real. Provider transport is controlled, and no model turn is
dispatched.
- `pnpm check:module-boundaries`, `pnpm -r typecheck`, `pnpm build` and
`git diff --check` pass.
- Four full affected recovery suites pass: **659 tests**. Command: `pnpm
--dir server exec vitest run --config vitest.config.ts
src/services/workspace-recovery.test.ts
src/services/native-runtime/native-failed-workspace-recovery.test.ts
src/services/native-runtime/native-cancelled-workspace-preservation.test.ts
src/services/native-runtime/native-workspace-export-retry.test.ts`. This
includes the final 16 new regression, refusal and assembled journey
cases.
- `pnpm verify --explain` completed. It selected broad transitive
coverage; this is planning evidence only. Hosted CI will supply the
remaining broad coverage.
- Dependency cost: extra indexed source/result reads run only for this
exact terminal retained-result path, inside the existing bounded
transaction. Existing notifications activate the existing recovery
owner. No idle scan is added.
- Fixture failures are accounted for: the first fixture used an
active-only stop helper on a released lease. The corrected fixture
produced the causal baseline above. One negative fixture tried to remove
a contract while an accepted result referenced it and failed the
database foreign key. That impossible fixture was removed; the positive
journey passed in the same run. Neither fixture failure is reported as a
product regression or a passing full run.
- Hosted CI is fully green at the reviewed commit: 54 successful checks
and 3 intentional skips, with no incomplete or failed checks. Greptile
is 5/5 with no unresolved threads.
- Hosted rollout and a live task continuation remain pending. Local API
admission does not prove a deployed model run.

## Risks

This expands file preservation for a narrow terminal failure. Wrong
source identity could retain unrelated files; unchanged writer, epoch,
location, provider lease, stop receipt and accepted contract checks fail
closed. Active ownership, unsafe archives, rejected results and
native-sync sources stay on their existing paths. Existing recovery owns
physical source locking, copy verification and bounded retries. It
preserves unknown action outcomes and leaves the accepted result
uncommitted. There is no migration or configuration change.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, xhigh reasoning effort. Context window size
is unavailable. Used repository tools, isolated tests and code
execution. No delegated agents.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-06 08:23:36 -08:00

336 lines
24 KiB
TypeScript

import { readRemoteWorkspaceAuthority, remoteWorkspaceRunReferenceSchema } from "@paperclipai/shared";
import type { CancelledWorkspacePreservationScope } from "../cancelled-workspace-preservation-notifications.js";
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { environmentLeases, executionWorkspaces, heartbeatRuns, workspaceRestoreFailureCodesSql, issues, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import { recordRequiredWorkspaceRestoreFailure } from "../legacy-execution-recovery.js";
import { hasRequiredWorkspaceRecovery, nativeRunHasNoWorkspaceAccess } from "../workspace-restore-recovery-state.js";
import { logActivity } from "../activity-log.js";
import { readNativeWorkspaceSyncReference } from "./native-workspace-sync.js";
import type { NativeRestartRecoveryClaim } from "./native-restart-recovery.js";
import { NativeWorkspaceFinalizationBusyError } from "./native-workspace-finalization-ownership.js";
import { hasAcknowledgedNativeRunStopRequest } from "../acknowledged-native-stop.js";
import { logger } from "../../middleware/logger.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
import { preserveRemoteWorkspacePreparationFailure } from "./native-workspace-finalization-state.js";
import { pinFailedObservedWorkspace } from "./native-observed-workspace-preservation.js";
import { hasConfirmedSandboxStopAndRetain } from "../sandbox-stop-and-retain.js";
import { preparedRemoteWorkspaceSource } from "../remote-workspace-lifecycle.js";
const reusableLeasePolicies = ["reuse_by_environment", "reuse_by_execution_workspace"];
/** The exact source the restore recorder can retain. A released reusable lease
* qualifies only with a confirmed stop-and-retain receipt and the remote
* workspace binding proof. Native-sync descriptors have no such proof here. */
async function cancelledWorkspaceSource(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "runnerProfileJson">) {
const leases = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
cancelledWorkspaceSourceLease(),
));
for (const lease of leases) {
if (lease.status !== "released" || lease.leasePolicy === "ephemeral") return lease;
if (run.runnerProfileJson?.nativeWorkspaceSync == null && hasConfirmedSandboxStopAndRetain(lease)
&& await preparedRemoteWorkspaceSource(db, lease)) return lease;
}
return null;
}
function workspaceRecoveryReference(profile: Record<string, unknown> | null | undefined) {
const remote = remoteWorkspaceRunReferenceSchema.safeParse(profile?.remoteWorkspace);
return readNativeWorkspaceSyncReference(profile?.nativeWorkspaceSync) ?? (remote.success ? remote.data : null);
}
export async function preserveTaskRemoteWorkspaceWriter(db: Db, companyId: string, issueId: string) {
const [bound] = await db.select({ id: executionWorkspaces.id, metadata: executionWorkspaces.metadata }).from(issues)
.innerJoin(executionWorkspaces, and(eq(executionWorkspaces.id, issues.executionWorkspaceId),
eq(executionWorkspaces.companyId, issues.companyId)))
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)));
if (!bound) return;
const authority = readRemoteWorkspaceAuthority(bound.metadata);
if (!authority?.dirty || !authority.writerRunId) return;
const [run] = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId,
nativeIssueId: heartbeatRuns.nativeIssueId, issueId: heartbeatRuns.issueId,
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId')`,
runnerProfileJson: sql<Record<string, unknown>>`jsonb_build_object('remoteWorkspace', ${heartbeatRuns.runnerProfileJson}->'remoteWorkspace',
'nativeWorkspaceSync', ${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync')`,
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, authority.writerRunId),
eq(heartbeatRuns.runtimeMode, "native"), eq(heartbeatRuns.status, "failed"),
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.issueId}::text,
${heartbeatRuns.contextSnapshot}->>'issueId') = ${issueId}`,
));
const reference = remoteWorkspaceRunReferenceSchema.safeParse(run?.runnerProfileJson.remoteWorkspace);
if (!run || run.runnerProfileJson.nativeWorkspaceSync != null || !reference.success || reference.data.state !== "prepared"
|| reference.data.workspaceId !== bound.id || reference.data.epoch !== authority.epoch) return;
try { return await preserveNativeFailedWorkspace(db, run); }
catch (error) { if (!(error instanceof NativeWorkspaceFinalizationBusyError)) throw error; }
}
/** A Stop during an accepted result's copy-back retries ends that copy-back.
* No finalization exports those files afterwards, so they are kept like the
* files of any other stopped run. Other accepted results keep their own export
* repair path. */
function stoppedAcceptedCopyback(coordinator: Pick<typeof nativeRunFinalizations.$inferSelect, "phase" | "failureCode">) {
return coordinator.phase === "terminal_failure" && coordinator.failureCode === "native_retry_cancelled";
}
export async function preserveNativeFailedWorkspace(db: Db, selected: Pick<typeof heartbeatRuns.$inferSelect,
"id" | "companyId" | "nativeIssueId" | "issueId" | "contextSnapshot" | "runnerProfileJson">,
options: { allowPreterminal?: boolean; requireCancelledStop?: boolean; restartRecovery?: NativeRestartRecoveryClaim } = {}) {
if (workspaceRecoveryReference(selected.runnerProfileJson)?.state !== "prepared") return;
const issueId = selected.nativeIssueId ?? selected.issueId ?? selected.contextSnapshot?.issueId;
if (typeof issueId !== "string") return;
return db.transaction(async tx => {
await tx.execute(sql`select set_config('statement_timeout', '15000', true), set_config('lock_timeout', '1000', true)`);
const [task] = await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.id, issueId), eq(issues.companyId, selected.companyId),
)).for("update");
if (!task) return;
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.runId, selected.id), eq(nativeRunFinalizations.companyId, selected.companyId),
eq(nativeRunFinalizations.issueId, issueId),
)).for("update");
if (options.requireCancelledStop && coordinator?.phase !== "terminal_failure") return;
const claim = options.restartRecovery;
const reference = workspaceRecoveryReference(selected.runnerProfileJson)!;
const ownsClaim = Boolean(options.allowPreterminal && coordinator?.phase === "observed" && claim
&& claim.runId === selected.id && claim.leaseOwner === coordinator.leaseOwner
&& claim.controllerGeneration === coordinator.controllerGeneration && claim.providerAttempt === coordinator.attempt
&& coordinator.leaseExpiresAt && coordinator.leaseExpiresAt.getTime() > Date.now()
&& (claim.kind !== "reattach_remote_runner" || (claim.remote.kind !== "plugin_task" && claim.remote.providerLeaseId === reference.providerLeaseId
&& claim.remote.remoteCwd === reference.remoteCwd)));
if ((coordinator?.leaseOwner || (claim && coordinator?.phase === "observed")) && !ownsClaim) throw new NativeWorkspaceFinalizationBusyError();
const exhaustedAcceptedCopyback = Boolean(coordinator?.resultId && coordinator.phase === "terminal_failure"
&& coordinator.failureCode === "native_workspace_sync_out_retry_exhausted");
if (!coordinator || (coordinator.resultId && !exhaustedAcceptedCopyback
&& !(options.requireCancelledStop && stoppedAcceptedCopyback(coordinator)))
|| !(options.allowPreterminal ? ["observed", "terminal_failure"] : ["terminal_failure"]).includes(coordinator.phase)) return;
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, selected.id), eq(heartbeatRuns.companyId, selected.companyId),
)).for("update");
if (!run || run.runtimeMode !== "native" || (!options.allowPreterminal && run.status !== "failed")) return;
if (options.requireCancelledStop && (run.status !== "cancelled" || !hasAcknowledgedNativeRunStopRequest(run)
|| run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt.getTime() > Date.now())) return;
const currentReference = workspaceRecoveryReference(run.runnerProfileJson);
if (!currentReference || currentReference.state !== "prepared"
|| (claim?.kind === "reattach_remote_runner" && (claim.remote.kind === "plugin_task" || claim.remote.providerLeaseId !== currentReference.providerLeaseId
|| claim.remote.remoteCwd !== currentReference.remoteCwd))) return;
const [source] = await tx.select().from(environmentLeases).where(and(
eq(environmentLeases.id, currentReference.leaseId), eq(environmentLeases.companyId, run.companyId),
eq(environmentLeases.heartbeatRunId, run.id), eq(environmentLeases.providerLeaseId, currentReference.providerLeaseId),
)).limit(1);
if (!source || source.issueId !== issueId || source.metadata?.remoteCwd !== currentReference.remoteCwd
|| (source.executionWorkspaceId && source.executionWorkspaceId !== currentReference.workspaceId)) return;
if (exhaustedAcceptedCopyback) {
const remote = remoteWorkspaceRunReferenceSchema.safeParse(run.runnerProfileJson?.remoteWorkspace);
if (run.status !== "failed" || !run.finishedAt || !run.completionContractId
|| run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt.getTime() > Date.now()
|| run.runnerProfileJson?.nativeWorkspaceSync != null || !remote.success || remote.data.state !== "prepared"
|| !hasConfirmedSandboxStopAndRetain(source)
|| !await preparedRemoteWorkspaceSource(tx as unknown as Db, source)) return;
const [accepted] = await tx.select({ id: nativeRunResults.id }).from(nativeRunResults).where(and(
eq(nativeRunResults.companyId, run.companyId), eq(nativeRunResults.issueId, issueId),
eq(nativeRunResults.runId, run.id), eq(nativeRunResults.id, coordinator.resultId!),
eq(nativeRunResults.completionContractId, run.completionContractId), eq(nativeRunResults.schemaStatus, "accepted"),
)).limit(1);
if (!accepted) return;
}
// A stopped reusable sandbox is retained only with its exact stop receipt
// while the workspace is still bound to this writer. Otherwise record nothing.
if (!exhaustedAcceptedCopyback && source.status !== "active" && reusableLeasePolicies.includes(source.leasePolicy) && (!hasConfirmedSandboxStopAndRetain(source)
|| !await preparedRemoteWorkspaceSource(tx as unknown as Db, source))) return;
// Ownership settlement still applies to workspace-less external sessions.
// Their Runner descriptor is not evidence that user files need exporting.
const retainFiles = !nativeRunHasNoWorkspaceAccess(run);
const alreadyRetained = hasRequiredWorkspaceRecovery(run.resultJson);
if (retainFiles && !alreadyRetained) await notifyDeliveryWork(tx, DELIVERY_QUEUES.workspaceRecovery);
let retained = !retainFiles || alreadyRetained ? run : await recordRequiredWorkspaceRestoreFailure(tx as unknown as Db, run, { workspaceRestoreFailure: "restore_failed" }, source);
if (!retained || retainFiles && !hasRequiredWorkspaceRecovery(retained.resultJson)) return;
if (options.allowPreterminal && coordinator.phase === "observed") {
await notifyExecutionRunWork(tx, { companyId: run.companyId });
const [settled] = await tx.update(nativeRunFinalizations).set({ phase: "terminal_failure",
leaseOwner: null, leaseExpiresAt: null, controlDeadlineAt: null, nextAttemptAt: null,
recoveryState: "blocked", failureCode: coordinator.failureCode ?? "native_continuation_requires_reconciliation", updatedAt: new Date(),
}).where(and(eq(nativeRunFinalizations.runId, run.id), eq(nativeRunFinalizations.companyId, run.companyId),
eq(nativeRunFinalizations.issueId, issueId), eq(nativeRunFinalizations.phase, "observed"), isNull(nativeRunFinalizations.resultId),
ownsClaim && claim ? and(eq(nativeRunFinalizations.leaseOwner, claim.leaseOwner),
eq(nativeRunFinalizations.controllerGeneration, claim.controllerGeneration), eq(nativeRunFinalizations.attempt, claim.providerAttempt),
gt(nativeRunFinalizations.leaseExpiresAt, sql`now()`)) : isNull(nativeRunFinalizations.leaseOwner),
)).returning({ id: nativeRunFinalizations.runId });
if (!settled) throw new NativeWorkspaceFinalizationBusyError();
[retained] = await tx.update(heartbeatRuns).set({ nativePhase: "terminal_failure", nativePhaseUpdatedAt: new Date() })
.where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId))).returning();
}
if (retainFiles && !alreadyRetained) await logActivity(tx as unknown as Db, {
companyId: run.companyId, actorType: "system", actorId: "native-workspace-recovery",
action: "issue.workspace_recovery_required", entityType: "issue",
entityId: issueId,
agentId: run.agentId, runId: run.id,
details: { runId: run.id, leaseId: source.id, reason: "native_execution_failed_before_workspace_export" },
});
return retained;
});
}
export async function preserveOrphanedNativeWorkspace(db: Db, lease: Pick<typeof environmentLeases.$inferSelect,
"companyId" | "heartbeatRunId">, options: { onDeferredDeadline?: (at: number) => void } = {}): Promise<boolean> {
if (!lease.heartbeatRunId) return false;
const [run] = await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, lease.companyId), eq(heartbeatRuns.id, lease.heartbeatRunId),
inArray(heartbeatRuns.status, ["failed", "cancelled", "timed_out", "interrupted"]),
)).limit(1);
if (!run || hasRequiredWorkspaceRecovery(run.resultJson)) return false;
try {
if (run.runtimeMode === "legacy") {
const parsed = remoteWorkspaceRunReferenceSchema.safeParse(run.runnerProfileJson?.remoteWorkspace);
if (!parsed.success) return false;
const [source] = await db.select().from(environmentLeases).where(and(eq(environmentLeases.companyId, run.companyId),
eq(environmentLeases.id, parsed.data.leaseId), eq(environmentLeases.heartbeatRunId, run.id))).limit(1);
const retained = source && await preserveRemoteWorkspacePreparationFailure(db, source);
return Boolean(retained && hasRequiredWorkspaceRecovery(retained.resultJson));
}
if (run.status !== "failed") return false;
if (await pinFailedObservedWorkspace(db, run.companyId, run.id)) return true;
const retained = await preserveNativeFailedWorkspace(db, run);
return Boolean(retained && hasRequiredWorkspaceRecovery(retained.resultJson));
} catch (error) {
if (error instanceof NativeWorkspaceFinalizationBusyError) return false;
const code = retentionContentionCode(error);
if (!code) throw error;
options.onDeferredDeadline?.(Date.now() + 30_000);
logger.warn({ runId: run.id, code }, "Native workspace retention is waiting for its current owner");
return false;
}
}
function retentionContentionCode(error: unknown): string | null {
let current = error;
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) {
const value = current as { code?: unknown; cause?: unknown };
if (value.code === "55P03" || value.code === "57014") return value.code;
current = value.cause;
}
return null;
}
const cancelledWorkspaceCursors = new WeakMap<Db, string>();
/** Leases that may still hold a cancelled writer's prepared files. A reusable
* (warm) lease that ends without success is stopped and kept `released` under
* its reusable policy; a reassignment Stop leaves the writer's unsaved files
* there. Admission only finds candidates: retention still requires the exact
* stop-and-retain receipt and the workspace still bound to this writer. */
function cancelledWorkspaceSourceLease() {
return or(eq(environmentLeases.status, "active"),
and(inArray(environmentLeases.status, ["pending_cleanup", "released"]), eq(environmentLeases.leasePolicy, "ephemeral")),
and(eq(environmentLeases.status, "released"), eq(environmentLeases.cleanupStatus, "success"),
inArray(environmentLeases.leasePolicy, reusableLeasePolicies),
sql`${environmentLeases.metadata}->'remoteExecutionTermination'->>'state' = 'stopped'`,
sql`${environmentLeases.metadata} ? 'sandboxStopAndRetainReceipt'`));
}
/** Older controllers could acknowledge Stop without pinning prepared files.
* Record only the file obligation; the existing stop-only cleanup worker owns
* provider teardown. This never resumes a turn or changes the task's status. */
export async function preserveCancelledNativeWorkspaces(db: Db) {
const select = (cursor?: string) => db.selectDistinct({ runId: heartbeatRuns.id })
.from(environmentLeases).innerJoin(heartbeatRuns, and(
eq(heartbeatRuns.id, environmentLeases.heartbeatRunId), eq(heartbeatRuns.companyId, environmentLeases.companyId),
)).innerJoin(nativeRunFinalizations, and(eq(nativeRunFinalizations.runId, heartbeatRuns.id),
eq(nativeRunFinalizations.companyId, heartbeatRuns.companyId), eq(nativeRunFinalizations.issueId, heartbeatRuns.nativeIssueId)))
.where(and(cancelledWorkspaceSourceLease(), eq(heartbeatRuns.runtimeMode, "native"),
eq(heartbeatRuns.status, "cancelled"), eq(nativeRunFinalizations.phase, "terminal_failure"),
or(isNull(nativeRunFinalizations.resultId), eq(nativeRunFinalizations.failureCode, "native_retry_cancelled")),
isNull(nativeRunFinalizations.leaseOwner),
sql`(${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync'->>'state' = 'prepared' OR ${heartbeatRuns.runnerProfileJson}->'remoteWorkspace'->>'state' = 'prepared')`,
sql`${heartbeatRuns.resultJson}->'nativeCancellation'->>'dispatchState' = 'acknowledged'`,
cursor ? gt(heartbeatRuns.id, cursor) : undefined,
)).orderBy(asc(heartbeatRuns.id)).limit(5);
let candidates = await select(cancelledWorkspaceCursors.get(db));
if (!candidates.length) {
cancelledWorkspaceCursors.delete(db);
candidates = await select();
}
for (const candidate of candidates) {
cancelledWorkspaceCursors.set(db, candidate.runId);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, candidate.runId));
if (run && hasAcknowledgedNativeRunStopRequest(run) && await cancelledWorkspaceSource(db, run)) {
try { await preserveNativeFailedWorkspace(db, run, { allowPreterminal: true, requireCancelledStop: true }); }
catch (error) {
if (error instanceof NativeWorkspaceFinalizationBusyError) continue;
const code = retentionContentionCode(error);
if (!code) throw error;
logger.warn({ runId: run.id, code }, "Cancelled native workspace retention is waiting for its current owner");
}
}
}
}
function pendingCancelledWorkspace() {
return sql`${heartbeatRuns.runtimeMode} = 'native' AND ${heartbeatRuns.status} = 'cancelled'
AND (${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync'->>'state' = 'prepared' OR ${heartbeatRuns.runnerProfileJson}->'remoteWorkspace'->>'state' = 'prepared')
AND ${heartbeatRuns.resultJson}->'nativeCancellation'->>'dispatchState' = 'acknowledged'
AND (${heartbeatRuns.resultJson}->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'
AND ${heartbeatRuns.resultJson}->>'workspaceRestoreFailure' IN (${workspaceRestoreFailureCodesSql})) IS NOT TRUE`;
}
export function registerCancelledWorkspacePreservationWork(db: Db,
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean,
canRecover: () => Promise<boolean>) {
type Entry = { id: string; companyId: string; dueAt: number };
type Candidate = Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId" | "issueId" | "nativeIssueId"
| "runtimeMode" | "driverKind" | "status" | "resultJson" | "runnerProfileJson">;
const eligible = (run: Candidate) => run.nativeIssueId !== null && hasAcknowledgedNativeRunStopRequest(run)
&& workspaceRecoveryReference(run.runnerProfileJson)?.state === "prepared"
&& !hasRequiredWorkspaceRecovery(run.resultJson) && !nativeRunHasNoWorkspaceAccess(run);
const scopeCondition = (scope?: CancelledWorkspacePreservationScope) => scope ? and(
eq(heartbeatRuns.companyId, scope.companyId), scope.runId ? eq(heartbeatRuns.id, scope.runId) : undefined) : undefined;
async function load(scope: CancelledWorkspacePreservationScope | undefined, signal?: AbortSignal) {
if (!await canRecover()) return [];
const entries: Entry[] = [];
let cursor: string | undefined;
while (canRun() && !signal?.aborted) {
const rows = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
issueId: heartbeatRuns.issueId, nativeIssueId: heartbeatRuns.nativeIssueId, runtimeMode: heartbeatRuns.runtimeMode,
driverKind: heartbeatRuns.driverKind, status: heartbeatRuns.status, resultJson: heartbeatRuns.resultJson,
runnerProfileJson: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and(pendingCancelledWorkspace(), scopeCondition(scope),
cursor ? gt(heartbeatRuns.id, cursor) : undefined)).orderBy(asc(heartbeatRuns.id)).limit(100);
for (const row of rows) if (eligible(row)) entries.push({ id: row.id, companyId: row.companyId, dueAt: 0 });
if (rows.length < 100) return entries;
cursor = rows[rows.length - 1].id;
}
throw new Error("Cancelled workspace preservation reconstruction stopped");
}
async function check(entry: Entry, signal?: AbortSignal): Promise<Entry | null> {
if (!await canRecover()) return null;
const [run] = await db.select().from(heartbeatRuns)
.where(and(pendingCancelledWorkspace(), scopeCondition({ companyId: entry.companyId, runId: entry.id })));
if (!run || !eligible(run)) return null;
if (!canRun() || signal?.aborted) throw new Error("Cancelled workspace preservation stopped");
const controllerDeadline = run.controllerLeaseExpiresAt?.getTime();
if (controllerDeadline && controllerDeadline > Date.now()) return { ...entry, dueAt: controllerDeadline };
const [finalization] = await db.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.runId, run.id), eq(nativeRunFinalizations.companyId, run.companyId),
eq(nativeRunFinalizations.issueId, run.nativeIssueId!),
));
if (finalization?.resultId && !stoppedAcceptedCopyback(finalization)) return null;
if (finalization?.phase !== "terminal_failure" || finalization.leaseOwner)
return { ...entry, dueAt: Date.now() + 30_000 };
if (await cancelledWorkspaceSource(db, run)) await preserveNativeFailedWorkspace(db, run, { allowPreterminal: true, requireCancelledStop: true });
const [current] = await db.select().from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId)));
const currentControllerDeadline = current?.controllerLeaseExpiresAt?.getTime();
return current && current.status === "cancelled" && eligible(current) ? { ...entry,
dueAt: currentControllerDeadline && currentControllerDeadline > Date.now() ? currentControllerDeadline : Date.now() + 30_000 } : null;
}
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.cancelledWorkspacePreservation, batchSize: 5,
decodeScope: (hint: string): CancelledWorkspacePreservationScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
matches: (entry: Entry, scope?: CancelledWorkspacePreservationScope) => !scope ||
entry.companyId === scope.companyId && (!scope.runId || entry.id === scope.runId),
load, check,
}, canRun);
}