mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - A remote (sandbox) task workspace has one writer run at a time. The
writer holds the workspace until its files are saved as a snapshot
> - A run that a user comment interrupts right after its claim did no
work. No provider turn started and no command ran in the sandbox
> - Paperclip still kept its sandbox, then took a full snapshot of the
unchanged workspace. On a 2 GB repository this took 3 minutes
> - The next runs failed setup with `workspace_busy` ("ended, but its
workspace save has not settled"). The retry then waited for the
one-minute settling re-check after the save. The user's message waited
more than 4 minutes
> - This pull request releases such a writer at once, at the saved
version it started from, when durable evidence proves that it changed
nothing. It also wakes parked `workspace_busy` waiters when a hold
settles
> - The benefit is that an interrupted run no longer delays the next
message by a full workspace save
## Linked Issues or Issue Description
**What happened?**
A remote-workspace run was claimed (`native.coordinator.claim`). One
second later a board comment interrupted it (`operator_interrupted`). It
made no provider turn and no tool calls. Cancelled-workspace
preservation still retained its sandbox, and workspace recovery took a
full `remote_workspace_snapshot` of the unchanged workspace (3 minutes
for a 2 GB repository). Two follow-up runs were cancelled with
`workspace_busy`. Their retry was due during the save, and it parked for
the one-minute settling re-check after the save finished.
**Expected behavior**
A writer that provably changed nothing releases the workspace at once
and keeps its existing `snapshotId`. The next run is admitted without
`workspace_busy`. When a save does run, waiters start as soon as it
commits.
**Steps to reproduce**
1. Run a task on a remote sandbox workspace that already has a saved
version.
2. Interrupt the run with a board comment right after the claim, before
the provider turn.
3. See the full snapshot operation on the interrupted run and
`workspace_busy` on the follow-up runs.
**Paperclip version or commit**
Staging served revision b43cccbb0. Reproduced on `master` by the new
regression tests.
## What Changed
- `prepareRemoteWorkspace` records `remoteWorkspaceUnchangedBaseline`
(epoch, writer run, saved version) when preparation started from a clean
saved version and added nothing to it: no recovered attachments, no
repository outside the saved version and no workspace migration.
- New `proveRemoteWorkspaceUnchanged`
(`remote-workspace-unchanged-proof.ts`). It uses only durable evidence.
The writer run must have ended without a result, and its owner fence
must be clear. It must have no process fields, no execution, provider or
tool events (the shared `executionStartedEventPredicate`), no native
result, no runner checkpoint rows (the controller writes one before
every runner command), and no provider turn in a checkpoint that names
this run. It must have no workspace operation other than a restore or a
`workspace_finalize`/`workspace_recovery` receipt, so provisioning and
saves count as writes. Every task repository must be in the saved
version. Any missing or ambiguous evidence refuses.
- New `releaseUnchangedRemoteWorkspaceWriter`. It sets the authority to
clean with no writer, keeps `snapshotId`, marks the run reference
`saved`, logs `workspace.snapshot_skipped_unchanged` and sends the same
notifications as a final save. It never waits on a writer run that
another transaction holds (`skip locked`).
- The release applies in three places: the next run's
`reserveRemoteWorkspace`, cancelled/failed-workspace preservation
(`preserveNativeFailedWorkspace`), and `saveRemoteWorkspace` with the
final boundary. The final save returns the same version on a repeated
call, so a recovery that raced the release finishes without a capture.
- The final-save commit and the unchanged release call
`wakeWorkspaceBusyWaitersInTx`. A parked waiter whose holder was that
writer is due at once, not at the one-minute settling re-check.
- `doc/execution-semantics.md` describes the release and the wake.
## Verification
- `pnpm --filter @paperclipai/server exec vitest run
src/services/remote-workspace-unchanged-release.test.ts` (13 tests,
embedded Postgres, real archive capture):
- Staging shape (claim, then interrupt with no provider work): the next
reservation is admitted. No snapshot operation is recorded. The run
reference is `saved` at the old `snapshotId`. Red on `master`: "The
workspace still has an unfinished writer or save operation."
- A later final save, behind the `workspace_finalize` receipt that
production finalizers publish first, returns the same version with no
capture, a repeat returns it again, and a parked waiter becomes due now.
Red on `master`: a new snapshot is captured.
- Negatives that still do a full save: `turn.started`, `provider.event`,
`tool.execution.started`, `native.process_start_requested`, a
provisioning command that wrote into the workspace, a recorded process,
a live owner fence, and a writer that is still running.
- Upgrade safety: no baseline (a writer prepared by the previous
release), a baseline without its saved version, and a baseline with
another schema each keep the full save.
- A full final save wakes the parked waiter. Red on `master`.
- `native-cancelled-workspace-preservation.test.ts`: a new case shows
that a cancelled writer with a baseline and no work is released, with no
retained lease and no recovery action. Red on `master` (the
restore-recovery hold was recorded). The other 44 cases pass unchanged.
- I also ran the related remote-workspace, workspace-recovery, native
preservation and run-dispatch suites locally.
## Risks
- A false "unchanged" verdict would drop unsaved files. The proof
requires a positive baseline from preparation and refuses on any write
record. Only `.paperclip-runtime` (excluded from snapshots) is written
by runner staging before a process starts. A deploy that lands mid-run
keeps the full save, because the proof reads only durable rows.
- Previous-release state: a writer prepared without the baseline (or
with an unknown or partial one) keeps today's full save. An older server
ignores the new metadata key.
- Waiters now start sooner after a final save. Arrival order among
waiters is unchanged (the park check still admits the earliest first).
- Other ways a hold can settle (for example a stop receipt that turns an
unused reservation free) still rely on the one-minute re-check.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code
with tool use (shell, file edits and a read-only browser for staging
evidence).
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
343 lines
24 KiB
TypeScript
343 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";
|
|
import { releaseUnchangedRemoteWorkspaceWriterById } from "../remote-workspaces.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 run that ended without changing its files has nothing to keep: its
|
|
// workspace is handed on at the saved version it started from.
|
|
if (!exhaustedAcceptedCopyback && !coordinator.resultId && coordinator.phase === "terminal_failure"
|
|
&& run.runnerProfileJson?.nativeWorkspaceSync == null
|
|
&& await releaseUnchangedRemoteWorkspaceWriterById(tx, { companyId: run.companyId,
|
|
workspaceId: currentReference.workspaceId, runId: run.id })) 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);
|
|
}
|