mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## 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>
336 lines
24 KiB
TypeScript
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);
|
|
}
|