mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## 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>
204 lines
12 KiB
TypeScript
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;
|
|
}
|