Files
PaperClipAI/packages/adapter-utils/src/workspace-checkpoint.ts
T
DottaandClaude Opus 5.5 c45c7cb854 fix(workspaces): ignore killed-transfer scratch in sparse checkpoints (#19730)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run in a remote sandbox copies its workspace back to the
host after the turn. A sparse checkpoint copies only changed entries,
and the host validates each entry against the sandbox's own manifest
> - Every Daytona transfer stages a `.paperclip-upload-<uuid>` scratch
file at the sandbox workspace root and removes it only in the same
process
> - A control-plane kill during any transfer leaves that scratch behind.
The sparse checkpoint then recorded it as a workspace file, but
providers never export it. Every later copyback and every saved-work
recovery attempt failed with "Workspace checkpoint payload mismatch"
> - The failed recovery then left an accepted Retry showing "Paperclip
is finishing the saved result" with no run
> - This pull request stops recording transfer scratch, makes the
checkpoint export an exact copy, names the failing entry, and gives a
Retry behind a spent recovery an honest reason and a working action
> - The benefit is that a restart during a transfer no longer makes a
task's saved work permanently unrecoverable

## Linked Issues or Issue Description

Refs #19721 (RC-50: the killed copyback owner; this PR covers the
separate RC-50b cause)

**What happened?**

Restart-chaos waves s12/t01 and s14/t05 (Claude on Daytona). After a
SIGKILL of the control plane, the native result was accepted, and then
workspace copyback failed with "Workspace checkpoint payload mismatch".
In s14 all 6 saved-work recovery attempts failed the same way. A Retry
accepted during recovery kept `retryWait.reason =
finalizing_saved_result` after the recovery budget was spent. No run was
created.

**Steps to reproduce**

1. Start a native Claude run in a Daytona sandbox.
2. SIGKILL the control plane while any provider transfer is in flight.
3. Let the run finish its turn and copy its workspace back.

**Expected behavior**

Copyback succeeds. Provider scratch is not workspace content. A Retry
behind a spent recovery names the failure and can start a new recovery.

**Actual behavior**

The s14 sandbox (6f4a0b6a) still held
`/home/daytona/paperclip-workspace/.paperclip-upload-d99fe36b-5acc-437b-96ad-85d5ff9e79c4.tar`.
Its mtime was 03:29:23, the second of the K4 SIGKILL. The Daytona
provider stages transfer scratch at `remoteDir`, which is the lease
`remoteCwd` (the workspace root). The runtime workspace wipe
deliberately preserves `.paperclip-upload-*`. The checkpoint helper
(`workspace-checkpoint.ts`, `checkpointMain`) recorded the stale tar.
The ordinary export excludes `.paperclip-upload*`, so the host received
nothing for that entry. `validateCheckpointPayload` then threw "payload
mismatch" on every attempt. The file-mode hypothesis was disproved: the
sandbox umask is 022 and the scratch is mode 644.

## What Changed

- `packages/adapter-utils/src/exclude-patterns.ts`: new
`isSandboxTransferScratchPath`, which matches a root-level
`.paperclip-upload-*` entry. This agrees with the wipe's preserve rule
in `buildWorkspaceTarExtractCommand`.
- `packages/adapter-utils/src/workspace-checkpoint.ts`: the sandbox
checkpoint helper skips that scratch. A mismatch error now names the
entry and its recorded, baseline and received forms (kind, octal mode,
short hash; never contents).
- `packages/adapter-utils/src/sandbox-managed-runtime.ts`: the
checkpoint export requests `preserveSourceEntries: true`. The checkpoint
output is an exact copy that the host validates entry by entry. An
ordinary export applies the host umask and drops `._*` and
`.paperclip-upload*` names at any depth. That gives the same permanent
mismatch for a legitimate user file such as `._summary`,
`notes/.paperclip-upload-plan.md`, or a `0775` script.
- `server/src/services/explicit-native-continuation.ts` and
`task-interruption-presentation.ts`: when the run's saved-work recovery
is exhausted, a waiting Retry reports `saved_work_recovery_failed` with
Retry available, instead of `finalizing_saved_result`. The existing task
Retry path starts one new recovery generation.
- `doc/execution-semantics.md`: a short additive note.

## Verification

- `packages/adapter-utils/src/workspace-checkpoint-capture.test.ts` uses
the real checkpoint helper and an export that behaves like Daytona's
ordinary export:
- Before the fix: `Workspace checkpoint payload mismatch at
".paperclip-upload-d99fe36b-….tar": sandbox recorded file mode=644 …;
baseline absent; received absent`. That is the wave error.
  - After the fix: the scratch is not recorded, and validation passes.
- A second case shows that reserved-looking user names and `0775` modes
need the exact export.
- `packages/adapter-utils/src/workspace-checkpoint.test.ts` is an
end-to-end `prepareSandboxManagedRuntime` restore through a provider
model. In that model, ordinary exports drop reserved names and apply the
umask, and preserve exports copy exactly. Two red checks:
- Without `preserveSourceEntries`, it fails with the mismatch on
`._summary`.
- Without the scratch skip, the stale tar leaks into the host workspace.
- Green: the agent file, the `._summary` file and the `0775` mode
arrive, and no scratch arrives.
- `server/src/services/workspace-recovery-preparation-gates.test.ts`,
"names a spent saved-work recovery behind an accepted Retry, and the
next Retry finishes it" (s14 shape):
  - Red: `finalizing_saved_result` after 6 failed attempts.
- Green: `saved_work_recovery_failed` with `canRetry: true`. The next
Retry starts a new generation, recovery completes, and the Retry
continues in exactly one new run.
- Suites: adapter-utils checkpoint, capture, sandbox-managed-runtime,
seed-reuse and managed-seed (168 passed). Server preparation-gates (47),
explicit-native-continuation and task-interruption-presentation (455),
workspace-recovery admission and retry-copyback, and
native-workspace-finalizer. `tsc --noEmit` is clean for adapter-utils
and server.
- Live evidence: I started the stopped s14 sandbox read-only, listed its
workspace, and stopped it again. No Daytona replay was run. The runner
content ID of this checkout matches no built image.

## Risks

- A user file named `.paperclip-upload-*` directly at the workspace root
is no longer captured by a sparse checkpoint. Providers already drop
that name from every ordinary export, and the runtime already reserves
it at the root.
- The checkpoint export now uses the provider's exact-copy mode. A
workspace file with setuid, setgid or sticky bits is refused by that
mode, as it is for the existing exact-copy transfers. The ordinary
export would have stripped those bits and failed validation anyway.
- The stale scratch tar stays in the sandbox. It is ignored, not
deleted, because a concurrent transfer can own a file with the same
prefix.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edits, test runs, embedded Postgres, a
read-only Daytona sandbox listing).

## 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
- [ ] 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-06 22:46:16 -06:00

204 lines
12 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { WorkspaceManifestWriter, WorkspaceManifestMap, workspacePaths, workspacePathMatcher } from "./workspace-manifest.js";
import { shouldExcludePath, excludePatternMatches, isRelativePathOrDescendant, isSandboxTransferScratchPath } from "./exclude-patterns.js";
import { openDirectorySnapshot, type DirectorySnapshot } from "./workspace-restore-merge.js";
export async function writeCheckpointBaseline(snapshot: DirectorySnapshot, filePath: string): Promise<void> {
const writer = new WorkspaceManifestWriter(filePath);
try {
for (const [relative, entry] of snapshot.entries) writer.add("baseline", relative, JSON.stringify(entry));
if (snapshot.ignoredPaths) for (const relative of workspacePaths(snapshot.ignoredPaths)) writer.add("ignored", relative);
writer.close();
} catch (error) { writer.close(false); throw error; }
}
async function checkpointMain(
fs: typeof import("node:fs/promises"), path: typeof import("node:path"),
DatabaseSync: typeof import("node:sqlite").DatabaseSync,
createHash: typeof import("node:crypto").createHash,
createReadStream: typeof import("node:fs").createReadStream,
shouldExcludePath: (relative: string, exclude: readonly string[]) => boolean,
isSandboxTransferScratchPath: (relative: string) => boolean,
) {
const [rootArg, baselinePath, outputDir, exclusions] = process.argv.slice(2);
const root = await fs.realpath(rootArg!);
const exclude: string[] = JSON.parse(exclusions!);
await fs.mkdir(outputDir!, { recursive: true });
const payload = path.join(outputDir!, "payload");
await fs.mkdir(payload);
const baseline = new DatabaseSync(baselinePath!, { readOnly: true, allowExtension: false });
baseline.exec("PRAGMA trusted_schema=OFF; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; PRAGMA mmap_size=0;");
const lookup = baseline.prepare("SELECT value FROM records WHERE category='baseline' AND path=?");
const ignored = baseline.prepare("SELECT 1 FROM records WHERE category='ignored' AND path=?");
const result = new DatabaseSync(path.join(outputDir!, "manifest.sqlite"));
const space = await fs.statfs(outputDir!, { bigint: true });
const available = space.bavail * space.bsize - 256n * 1024n * 1024n;
if (available <= 0n) throw new Error("Workspace checkpoint storage is below its free-space reserve");
result.exec(`PRAGMA max_page_count=${Math.min(Number(available / 4n / 4096n), 4_294_967_294)}`);
result.exec("PRAGMA journal_mode=DELETE; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; CREATE TABLE records(category TEXT,path TEXT,value TEXT NOT NULL, PRIMARY KEY(category,path)) WITHOUT ROWID; BEGIN;");
const insert = result.prepare("INSERT INTO records VALUES('baseline',?,?)");
let files = 0, bytes = 0, scanned = 0;
const fingerprint = (s: Awaited<ReturnType<typeof fs.lstat>>) => [s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs].join(":");
const hash = async (file: string) => {
const digest = createHash("sha256");
for await (const chunk of createReadStream(file)) digest.update(chunk);
return digest.digest("hex");
};
const walk = async (relative = "") => {
const directory = path.join(root, relative);
if (await fs.realpath(directory) !== directory) throw new Error("Workspace directory changed during checkpoint capture");
const pending: Promise<{ ok: true } | { ok: false; error: unknown }>[] = [];
const flush = async () => {
const results = await Promise.all(pending.splice(0));
for (const result of results) if (!result.ok) throw result.error;
};
const capture = async (name: string, before: import("node:fs").Stats) => {
const source = path.join(root, name);
let entry: { kind: string; hash?: string; mode?: number; target?: string };
if (before.isDirectory()) entry = { kind: "dir" };
else if (before.isFile()) entry = { kind: "file", mode: before.mode, hash: await hash(source) };
else if (before.isSymbolicLink()) {
const target = await fs.readlink(source);
const resolved = path.resolve(path.dirname(source), target);
if (path.isAbsolute(target) || (resolved !== root && !resolved.startsWith(`${root}/`))) return;
entry = { kind: "symlink", target };
} else return;
if (fingerprint(before) !== fingerprint(await fs.lstat(source))) throw new Error("Workspace changed during checkpoint capture");
const encoded = JSON.stringify(entry);
if (!result.isTransaction) throw new Error("Workspace checkpoint transaction ended during capture");
insert.run(name, encoded);
if (lookup.get(name)?.value !== encoded) {
const destination = path.join(payload, name);
await fs.mkdir(path.dirname(destination), { recursive: true });
if (entry.kind === "dir") await fs.mkdir(destination, { recursive: true });
else if (entry.kind === "symlink") await fs.symlink(entry.target!, destination);
else {
await fs.copyFile(source, destination);
await fs.chmod(destination, before.mode);
if (await hash(destination) !== entry.hash || fingerprint(before) !== fingerprint(await fs.lstat(source))) throw new Error("Workspace changed during checkpoint copy");
files++; bytes += before.size;
}
}
};
try {
for await (const child of await fs.opendir(directory)) {
const name = relative ? `${relative}/${child.name}` : child.name;
if (Buffer.byteLength(name) > 65536) throw new Error("Invalid workspace manifest path");
let ignoredPath = false;
for (let p = name; p; p = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "") {
if (ignored.get(p)) { ignoredPath = true; break; }
}
// Provider transfer scratch is never exported, so capturing it makes
// every later validation fail (RC-50b).
if (ignoredPath || shouldExcludePath(name, exclude) || isSandboxTransferScratchPath(name)) continue;
const before = await fs.lstat(path.join(root, name));
scanned++;
if (before.isFile()) {
pending.push(capture(name, before).then(() => ({ ok: true as const }), error => ({ ok: false as const, error })));
if (pending.length === 8) await flush();
} else {
await flush();
await capture(name, before);
if (before.isDirectory()) await walk(name);
}
}
await flush();
} finally {
await Promise.all(pending);
}
};
try {
await walk();
result.exec("COMMIT");
await fs.writeFile(path.join(outputDir!, "receipt.json"), JSON.stringify({ version: 1, files, bytes, scanned }));
} finally { baseline.close(); result.close(); }
}
export function workspaceCheckpointScript(): string {
return `import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\nimport { createHash } from 'node:crypto';\nimport { createReadStream } from 'node:fs';\nconst __name = (fn) => fn;\n${isRelativePathOrDescendant.toString()}\nfunction pathContainsSegmentOrDescendant(relative, segment) { return relative === segment || relative.startsWith(segment + '/') || relative.endsWith('/' + segment) || relative.includes('/' + segment + '/'); }\n${excludePatternMatches.toString()}\n${shouldExcludePath.toString()}\n${isSandboxTransferScratchPath.toString()}\nawait (${checkpointMain.toString()})(fs, path, DatabaseSync, createHash, createReadStream, shouldExcludePath, isSandboxTransferScratchPath);\n`;
}
export function readCheckpointSnapshot(filePath: string, baseline: DirectorySnapshot): DirectorySnapshot {
const db = new DatabaseSync(filePath, { readOnly: true, allowExtension: false });
let count: number;
try {
db.exec("PRAGMA trusted_schema=OFF; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; PRAGMA mmap_size=0;");
count = Number(db.prepare("SELECT count(*) AS count FROM records WHERE category='baseline'").get()!.count);
} finally { db.close(); }
const snapshot = openDirectorySnapshot({ version: 2, exclude: baseline.exclude, ignoredPaths: baseline.ignoredPaths,
entries: { kind: "path_manifest", version: 1, filePath, category: "baseline", count } });
try { for (const _entry of snapshot.entries) { } }
catch (error) { (snapshot.entries as WorkspaceManifestMap<unknown>).close(); throw error; }
return snapshot;
}
type CheckpointEntryLike = { kind: string; mode?: number; hash?: string; target?: string } | undefined;
/** Names which entry disagreed and how, so a deterministic mismatch is diagnosable.
* Modes are octal permission bits; hashes are shortened. File contents never appear. */
function checkpointPayloadMismatch(relative: string, recorded: CheckpointEntryLike, baseline: CheckpointEntryLike, received: CheckpointEntryLike) {
const describe = (entry: CheckpointEntryLike) => !entry ? "absent"
: entry.kind === "file" ? `file mode=${((entry.mode ?? 0) & 0o7777).toString(8)} sha256=${String(entry.hash).slice(0, 12)}`
: entry.kind === "symlink" ? "symlink" : entry.kind;
const shownPath = relative.length > 240 ? `${relative.slice(0, 240)}…` : relative;
return Object.assign(new Error(`Workspace checkpoint payload mismatch at ${JSON.stringify(shownPath)}: `
+ `sandbox recorded ${describe(recorded)}; baseline ${describe(baseline)}; received ${describe(received)}`), {
checkpointMismatch: { path: relative, recorded, baseline, received },
});
}
export async function validateCheckpointPayload(input: { baseline: DirectorySnapshot; snapshot: DirectorySnapshot; payload: string }): Promise<WorkspaceCheckpointMetrics> {
const { captureDirectorySnapshot, disposeDirectorySnapshot } = await import("./workspace-restore-merge.js");
const ignored = workspacePathMatcher(input.baseline.ignoredPaths);
let actual: DirectorySnapshot | null = null;
const equal = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
const assertIncluded = (relative: string) => {
if (shouldExcludePath(relative, input.baseline.exclude) || ignored.matches(relative)) {
throw new Error("Excluded workspace checkpoint entry");
}
};
let changedFiles = 0, payloadBytes = 0;
try {
actual = await captureDirectorySnapshot(input.payload, { diskBacked: true });
for (const [relative, entry] of input.snapshot.entries) {
assertIncluded(relative);
const separator = relative.lastIndexOf("/");
if (separator >= 0 && input.snapshot.entries.get(relative.slice(0, separator))?.kind !== "dir") {
throw new Error("Invalid workspace checkpoint parent");
}
if (entry.kind === "symlink") {
const target = path.resolve(input.payload, path.dirname(relative), entry.target);
if (path.isAbsolute(entry.target) || (target !== path.resolve(input.payload) && !target.startsWith(`${path.resolve(input.payload)}/`))) {
throw Object.assign(new Error("Unsafe workspace checkpoint symlink"), { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" });
}
}
if (!equal(entry, input.baseline.entries.get(relative)) && !equal(entry, actual.entries.get(relative))) {
throw checkpointPayloadMismatch(relative, entry, input.baseline.entries.get(relative), actual.entries.get(relative));
}
}
for (const [relative, entry] of actual.entries) {
assertIncluded(relative);
if (!equal(entry, input.snapshot.entries.get(relative))) throw new Error("Unexpected workspace checkpoint payload");
if (entry.kind === "file") { changedFiles++; payloadBytes += (await fs.stat(path.join(input.payload, relative))).size; }
}
if (input.baseline.ignoredPaths) for (const relative of workspacePaths(input.baseline.ignoredPaths)) {
for (let slash = relative.lastIndexOf("/"); slash >= 0; slash = relative.lastIndexOf("/", slash - 1)) {
const ancestor = relative.slice(0, slash);
for (const entry of [input.snapshot.entries.get(ancestor), actual.entries.get(ancestor)]) {
if (entry && entry.kind !== "dir") throw new Error("Workspace checkpoint replaces an ignored path ancestor");
}
}
}
return { mode: "sparse", scannedEntries: input.snapshot.entries.size, changedFiles, payloadBytes };
} finally { ignored.close(); await disposeDirectorySnapshot(actual); }
}
export interface WorkspaceCheckpointMetrics {
mode: "sparse" | "full";
scannedEntries: number | null;
changedFiles: number | null;
payloadBytes: number | null;
}