Files
PaperClipAI/server/src/services/native-runtime/native-workspace-finalizer.ts
T
DottaandClaude Opus 5.5 a994a648f1 fix(workspaces): name copy-back failures on the run and end failed save operations (#19187)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native run in a Daytona sandbox saves its workspace after the
provider turn. This save is "copy-back".
> - On staging, about 20 runs kept a saved result, but copy-back failed
three times. The issues became blocked with
`native_workspace_sync_out_retry_exhausted`.
> - #19152 added a redacted cause for a sandbox command that exits with
an error. Three gaps stayed open: a provider that refuses the command
still gives a bare code, the run does not show the cause, and the failed
save operation stays "running".
> - The first failure also had the wrong name. The runner kept the
result but did not prove a durable suspension. The executor reports this
as `native_session_lease_lost`, and the workspace operation shows only
that text.
> - This pull request names each of these failures with closed, redacted
labels. It also ends a failed save operation as "failed".
> - The benefit is that operators can see why copy-back failed and which
save is still active. Retries and saved results do not change.

## Linked Issues or Issue Description

Refs #19152, #19145. These are the earlier copy-back diagnostics and
retry-scheduling repairs. This PR does not duplicate them.

**What happened?**
A native task ran in a Daytona sandbox and its result was accepted. The
run then failed with `native_workspace_sync_out_retry_exhausted`. The
run had no error text. The automatic copy-back retries each failed in
under one second, with only `workspace_sync_out_failed`. The final
`remote_workspace_snapshot` operation stayed "running" after the retries
stopped. The first `workspace_finalize` operation showed only
`native_session_lease_lost`. The run log showed that the runner shutdown
timed out before durable suspension. The lease was not lost.

**Steps to reproduce**
1. Run a native task in a Daytona sandbox until its result is accepted.
2. Make the sandbox provider refuse the copy-back command (for example,
a lease that is no longer active), so that no exit status comes back.
3. Let the automatic retries stop. Then read the run and its workspace
operations.

**Expected behavior**
The run, the coordinator, and the failed operations name a redacted
cause. The failed save operation is "failed" with a finish time.

**Actual behavior**
The run has no cause. The save operation stays "running". A provider
refusal keeps only the bare `workspace_sync_out_failed` code.

## What Changed

- `remote-workspace-transport.ts`: the provider can throw instead of
returning an exit status for the copy-back helper, the capture, or the
archive download. `remoteWorkspaceFailureCause` then returns
`remote_workspace_operation_failed: operation=<op> transport=<label>[
code=<ERRNO>]`. The label is from a closed list (for example
`sandbox_lease_inactive`, `permission_denied`, `sandbox_not_running`,
`transport_timeout`, `transport_closed`, `daytona_sandbox_not_found`,
`provider_error`). Provider text is never copied into the cause. The
thrown error does not change, so preparation errors,
`classifyNativeWorkspaceFailure`, and other callers keep their
contracts. Archive-safety and Git-metadata refusals get no label,
because they have their own diagnostics.
- `remote-workspaces.ts`: when the last active invocation of a save
fails, the save operation becomes "failed" with `exitCode`,
`finishedAt`, a redacted `stderrExcerpt`, and `metadata.lastFailure`. A
retry of the same boundary sets it to "running" again. A success sets it
to "succeeded". After the final save commits, a failed save is retired
for export cleanup in the same way as a running save.
- `remote-workspace-snapshot-retention.ts`: a failed save that is not
retired keeps its snapshot pinned, as it did while it was "running". Its
retry can still use an uploaded snapshot.
- `native-run-finalizer.ts` and `native-workspace-finalizer.ts`: the
redacted cause of each copy-back attempt goes to the coordinator
`failureDetail.failureCause` and the run `resultJson.failureCause`. When
retries stop, the run error adds `Last cause: <cause>`. The server
checks the cause against a fixed grammar again before it writes it. A
cause with a path or other text is dropped.
- `native-post-result-failure.ts` (new), `native-session-executor.ts`,
`runtime-execution.ts`: a failure after an accepted result gets the
label `runner_settlement_incomplete` when the runner did not prove a
durable suspension. The failed `workspace_finalize` operation records
this closed diagnostic as `failureDiagnostic`. The coordinator records
it as `native_post_result_failure: category=<label>`. The control code
`native_session_lease_lost` and its fencing do not change.

## Verification

- Red, before the fix (the new tests run against `master` source):
- 4 provider-refusal cases and the archive download failure: no redacted
cause was available, so the copy-back recorded only the bare code.
- Failed save, with only the transport fix applied: `- "status":
"failed"` / `+ "status": "running"`.
- Run cause after retries stop: `expected null to be
'remote_workspace_operation_failed: …'`.
- Green, after the fix:
`cd server && npx vitest run
src/services/remote-workspace-transport.test.ts
src/services/remote-workspace-save-concurrency.test.ts
src/__tests__/native-workspace-finalization-work.test.ts
src/services/native-runtime/native-post-result-failure.test.ts
src/services/remote-workspaces.integration.test.ts
src/services/remote-workspace-snapshot-retention.test.ts` passes.
- Safety cases:
- The failed save is retried on the same boundary operation, records
"succeeded", and keeps the saved file bytes.
- `daytona_sandbox_not_found` is still classified as
`workspace_sync_out_unrecoverable`.
  - An archive-safety refusal keeps its exact text.
  - A forged cause with a path never reaches the run or the coordinator.
- A run-level timeout cause (`remote_workspace_operation_timed_out:
capture (configured timeout 300000ms)`) reaches the run and the
coordinator.
- `heartbeat-native-remote-failure-retention.test.ts` passes: a
preparation failure keeps its exact run error.
- The existing integration tests for retired saves, poisoned
reservations, duplicate invocations, and snapshot retention pass. They
prove export cleanup and identity protection.
- `tsc --noEmit` for `server` reports no errors.
- Not done: a live Daytona copy-back. This PR adds diagnostics and
corrects the operation state. It does not change when a retry runs.

## Risks

- Thrown copy-back errors do not change. Only the recorded cause is new.
The `daytona_sandbox_not_found` and archive-safety classifications are
tested.
- A failed save operation is now "failed" before the final save commits.
Retirement, export cleanup, and snapshot pinning now also select these
failed rows. They select only rows with `lastFailure` and no retirement
receipt.
- Privacy: each new cause is a closed label or a server-built code. The
server checks it again with an anchored grammar before it writes the
run. No sandbox output, path, or message text is stored.
- The retry count, the retry delay, and the classification of each
failure do not change. No provider turn is added.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Provider: Anthropic. Model: Claude Opus 5.5 (`claude-opus-5-5`), in
the Claude Code agent harness.
- Capabilities used: tool use, shell and code execution, repository
search, read-only browser reads of staging, and extended reasoning.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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>
2026-11-04 23:07:42 -06:00

373 lines
16 KiB
TypeScript

import { classifyNativeWorkspaceFailure } from "./native-workspace-failure.js";
import { readRemoteWorkspaceAuthority, saveRemoteWorkspace } from "../remote-workspaces.js";
import { remoteWorkspaceFailureCause } from "../remote-workspace-transport.js";
import { recordNativeFinalizationFailure } from "./native-run-finalizer.js";
import { inspectPluginWorkspaceForFinalization } from "./native-plugin-workspace-finalization.js";
import type { WorkspaceWorkers } from "../plugin-workspaces.js";
import { restoreNativeWorkspaceBestEffort } from "./native-workspace-best-effort.js";
import { withNativeWorkspaceFinalizationOwnership } from "./native-workspace-finalization-ownership.js";
import fs from "node:fs/promises";
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
executionWorkspaces,
heartbeatRuns,
issues,
nativeRunFinalizations,
workspaceOperations,
} from "@paperclipai/db";
import { workspaceOperationService } from "../workspace-operations.js";
import { inspectManagedGitWorktreeBranch } from "../workspace-runtime.js";
import { environmentService } from "../environments.js";
import type { EnvironmentRuntimeService } from "../environment-runtime.js";
import { resolveEnvironmentExecutionTarget } from "../environment-execution-target.js";
import { measureWorkspaceSyncPhase } from "../execution-preparation-timing.js";
import {
readNativeWorkspaceSyncReference,
resumeNativeWorkspaceSync,
} from "./native-workspace-sync.js";
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function workspaceSyncFailure(
code: "workspace_sync_out_failed" | "workspace_sync_out_unrecoverable",
cause?: string,
) {
return {
status: "failed" as const,
exitCode: 1,
stderr: cause ? `${code}\n${cause}\n` : `${code}\n`,
metadata: { workspaceSync: cause ? { code, cause } : { code } },
};
}
/**
* Resume the real workspace-finalization action for a result-bearing native
* run. Durable sandbox-backed runs export and merge the remote workspace;
* older runs retain the local workspace validation path. The caller owns the
* durable retry and failure policy.
*/
export async function attemptNativeWorkspaceFinalization(input: {
db: Db;
runId: string;
environmentRuntime?: EnvironmentRuntimeService;
pluginWorkerManager?: WorkspaceWorkers;
}) {
const bound = await input.db.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
runtimeMode: heartbeatRuns.runtimeMode,
runnerProfileJson: heartbeatRuns.runnerProfileJson,
issueId: nativeRunFinalizations.issueId,
resultId: nativeRunFinalizations.resultId,
issueWorkspaceId: issues.executionWorkspaceId,
})
.from(heartbeatRuns)
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
.innerJoin(issues, and(
eq(issues.id, nativeRunFinalizations.issueId),
eq(issues.companyId, heartbeatRuns.companyId),
))
.where(eq(heartbeatRuns.id, input.runId))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!bound || bound.runtimeMode !== "native" || !bound.resultId) {
throw new Error("native_workspace_finalization_binding_missing");
}
const owned = await withNativeWorkspaceFinalizationOwnership({
db: input.db, companyId: bound.companyId, runId: input.runId,
}, async (ownership) => {
// A sweep can be admitted before the live owner publishes a permanent
// failure or retry delay, then acquire ownership after that owner releases.
// Recheck admission inside the lock before any copyback or operation receipt.
const admission = await input.db.select({
phase: nativeRunFinalizations.phase,
nextAttemptAt: nativeRunFinalizations.nextAttemptAt,
resultId: nativeRunFinalizations.resultId,
}).from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.runId, input.runId),
eq(nativeRunFinalizations.companyId, bound.companyId),
eq(nativeRunFinalizations.issueId, bound.issueId),
)).limit(1).then((rows) => rows[0] ?? null);
if (!admission || admission.resultId !== bound.resultId) {
throw new Error("native_workspace_finalization_binding_missing");
}
if (admission.phase === "terminal_failure") return { kind: "inactive" } as const;
if (admission.nextAttemptAt && admission.nextAttemptAt > new Date()) {
return { kind: "retry", retryAt: admission.nextAttemptAt } as const;
}
const successful = await input.db.select().from(workspaceOperations).where(and(
eq(workspaceOperations.companyId, bound.companyId),
eq(workspaceOperations.heartbeatRunId, input.runId),
eq(workspaceOperations.issueId, bound.issueId),
eq(workspaceOperations.phase, "workspace_finalize"),
eq(workspaceOperations.status, "succeeded"),
)).orderBy(desc(workspaceOperations.createdAt)).limit(1).then((rows) => rows[0] ?? null);
// A stale failure from a pre-fencing controller cannot invalidate exported work.
if (successful) return { kind: "completed", operation: successful } as const;
const previous = await input.db.select().from(workspaceOperations).where(and(
eq(workspaceOperations.companyId, bound.companyId),
eq(workspaceOperations.heartbeatRunId, input.runId),
eq(workspaceOperations.issueId, bound.issueId),
eq(workspaceOperations.phase, "workspace_finalize"),
)).orderBy(desc(workspaceOperations.createdAt)).limit(1).then((rows) => rows[0] ?? null);
const persistedInput = record(
record(bound.runnerProfileJson).nativeExecutionInput,
);
const nativeWorkspaceSync = readNativeWorkspaceSyncReference(
record(bound.runnerProfileJson).nativeWorkspaceSync,
);
const binding = record(persistedInput.binding);
const workspaceId = previous?.executionWorkspaceId
?? bound.issueWorkspaceId
?? readString(binding.executionWorkspaceId);
const workspace = workspaceId
? await input.db.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.id, workspaceId),
eq(executionWorkspaces.companyId, bound.companyId),
)).limit(1).then((rows) => rows[0] ?? null)
: null;
const cwd = workspace?.providerRef ?? workspace?.cwd ?? previous?.cwd ?? null;
const recorder = workspaceOperationService(input.db).createRecorder({
companyId: bound.companyId,
heartbeatRunId: input.runId,
// The native binding also uses this field as a directory-only containment token.
// Only attach it to the operation when it resolves to a company-owned row, because
// workspace_operations.execution_workspace_id is a real execution-workspace FK.
executionWorkspaceId: workspace?.id ?? null,
issueId: bound.issueId,
});
const operation = await recorder.recordOperation({
phase: "workspace_finalize",
cwd,
metadata: {
resumedFromOperationId: previous?.id ?? null,
owningService: "native_workspace_finalizer",
observation: workspace?.strategyType === "git_worktree"
? "managed_git_worktree_branch"
: "workspace_directory",
},
run: async () => {
await ownership.assertHeld();
const remoteAuthority = readRemoteWorkspaceAuthority(workspace?.metadata);
if (remoteAuthority && workspace) {
if (!input.environmentRuntime || !remoteAuthority.location) {
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
}
const location = remoteAuthority.location;
const environmentsSvc = environmentService(input.db);
const lease = await environmentsSvc.getLeaseById(location.leaseId);
const environment = lease?.environmentId ? await environmentsSvc.getById(lease.environmentId) : null;
if (!lease || !environment || lease.companyId !== bound.companyId
|| lease.heartbeatRunId !== input.runId || lease.executionWorkspaceId !== workspace.id
|| lease.providerLeaseId !== location.providerLeaseId || environment.id !== location.environmentId
|| ["expired", "failed", "pending_cleanup"].includes(lease.status)) {
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
}
const target = await resolveEnvironmentExecutionTarget({
db: input.db, companyId: bound.companyId, adapterType: "paperclip_runner",
environment, leaseId: lease.id, leaseMetadata: lease.metadata, lease,
environmentRuntime: input.environmentRuntime,
});
if (!target) return workspaceSyncFailure("workspace_sync_out_unrecoverable");
try {
const saved = await saveRemoteWorkspace({
db: input.db, workspace, runId: input.runId, lease, target,
assertOwned: ownership.assertHeld,
});
await ownership.assertHeld();
return { status: "succeeded", exitCode: 0,
system: "Native workspace files were saved to durable storage.\n",
metadata: { remoteWorkspace: saved } };
} catch (error) {
await ownership.assertHeld();
// The code keeps the retry policy. The redacted remote cause (for
// example a full sandbox disk or the failed capture step) is named on
// the operation; other error text stays off the record.
return workspaceSyncFailure(classifyNativeWorkspaceFailure(error).code === "workspace_sync_out_unrecoverable"
? "workspace_sync_out_unrecoverable" : "workspace_sync_out_failed", remoteWorkspaceFailureCause(error) ?? undefined);
}
}
if (nativeWorkspaceSync) {
if (!input.environmentRuntime) {
return {
status: "failed",
exitCode: 1,
stderr:
"Native workspace finalization cannot access the environment runtime.\n",
};
}
const environmentsSvc = environmentService(input.db);
const lease = await environmentsSvc.getLeaseById(
nativeWorkspaceSync.leaseId,
);
const environment = lease?.environmentId
? await environmentsSvc.getById(lease.environmentId)
: null;
if (
!lease ||
!environment ||
lease.companyId !== bound.companyId ||
environment.id !== lease.environmentId
) {
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
}
if (
lease.status === "expired" ||
lease.status === "failed" ||
lease.status === "pending_cleanup" ||
!lease.providerLeaseId ||
lease.providerLeaseId !== nativeWorkspaceSync.providerLeaseId
) {
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
}
const target = await resolveEnvironmentExecutionTarget({
db: input.db,
companyId: bound.companyId,
adapterType: "paperclip_runner",
environment,
leaseId: lease.id,
leaseMetadata: lease.metadata,
lease,
environmentRuntime: input.environmentRuntime,
});
if (!target) {
return {
status: "failed",
exitCode: 1,
stderr: "Native workspace finalization target is unavailable.\n",
};
}
try {
const restored = await restoreNativeWorkspaceBestEffort({
db: input.db,
runId: input.runId,
assertOwnership: ownership.assertHeld,
restore: () => resumeNativeWorkspaceSync({
db: input.db, runId: input.runId, target, assertOwnership: ownership.assertHeld,
onPhase: measureWorkspaceSyncPhase,
}),
});
await ownership.assertHeld();
if (restored === false) {
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
}
return {
status: "succeeded",
exitCode: 0,
system:
restored
? "Native workspace finalization restored the remote workspace.\n"
: "Unsafe workspace export omitted; finalization completed.\n",
metadata: {
workspaceSync: {
schema: nativeWorkspaceSync.schema,
workspaceId: nativeWorkspaceSync.workspaceId,
leaseId: nativeWorkspaceSync.leaseId,
},
},
};
} catch (error) {
await ownership.assertHeld();
const code = classifyNativeWorkspaceFailure(error).code === "workspace_sync_out_unrecoverable"
? "workspace_sync_out_unrecoverable" : "workspace_sync_out_failed";
return workspaceSyncFailure(code);
}
}
let pluginWorkspace: Record<string, unknown> | null;
try {
pluginWorkspace = workspace
? await inspectPluginWorkspaceForFinalization(input.db, input.pluginWorkerManager, workspace, bound.agentId, input.runId)
: null;
} catch (error) {
await ownership.assertHeld();
const message = error instanceof Error ? error.message : null;
const code = message === "plugin_workspace_branch_mismatch" || message === "plugin_workspace_owner_mismatch"
? message : "plugin_workspace_inspection_failed";
return { status: "failed", exitCode: 1, stderr: `${code}\n` };
}
await ownership.assertHeld();
if (pluginWorkspace) return { status: "succeeded", exitCode: 0,
system: "Native workspace finalization used the plugin workspace owner.\n",
metadata: { pluginWorkspace } };
if (!cwd) {
return {
status: "failed",
exitCode: 1,
stderr: "Native workspace finalization cannot resolve the persisted workspace path.\n",
};
}
const isDirectory = await fs.stat(cwd).then((stat) => stat.isDirectory()).catch(() => false);
await ownership.assertHeld();
if (!isDirectory) {
return {
status: "failed",
exitCode: 1,
stderr: `Native workspace finalization could not access workspace directory ${cwd}.\n`,
};
}
if (workspace?.strategyType === "git_worktree") {
const inspection = await inspectManagedGitWorktreeBranch({
worktreePath: cwd,
expectedBranchName: workspace.branchName,
});
if (!inspection.valid) {
return {
status: "failed",
exitCode: 1,
stderr: `Managed git worktree validation failed: ${inspection.reason ?? "unknown failure"}.\n`,
metadata: { managedGitWorktreeBranch: inspection },
};
}
return {
status: "succeeded",
exitCode: 0,
system: "Native workspace finalization validated the managed git worktree.\n",
metadata: { managedGitWorktreeBranch: inspection },
};
}
return {
status: "succeeded",
exitCode: 0,
system: "Native workspace finalization validated the persisted workspace directory.\n",
metadata: { observedWorkspacePath: cwd },
};
},
});
return { kind: "completed", operation } as const;
});
return owned.acquired ? owned.value : { kind: "waiting", reason: owned.reason } as const;
}
export async function resumeNativeWorkspaceFinalization(input: Parameters<typeof attemptNativeWorkspaceFinalization>[0]) {
const result = await attemptNativeWorkspaceFinalization(input);
return result.kind === "completed" ? result.operation : null;
}
export function recordNativeWorkspaceFinalizationFailure(db: Db, runId: string,
operation: { stderrExcerpt: string | null; metadata?: Record<string, unknown> | null }) {
const unrecoverable = operation.stderrExcerpt?.includes("workspace_sync_out_unrecoverable");
const pluginFailure = operation.stderrExcerpt?.trim();
const pluginRepair = pluginFailure === "plugin_workspace_branch_mismatch" || pluginFailure === "plugin_workspace_owner_mismatch";
return recordNativeFinalizationFailure({
db, runId,
error: new Error(pluginRepair ? `native_${pluginFailure}` : unrecoverable
? "native_workspace_sync_out_unrecoverable" : "native_workspace_sync_out_failed"),
projectRunStatus: true, failureScope: "workspace", permanent: unrecoverable,
failureCause: readString(record(record(operation.metadata).workspaceSync).cause),
});
}