mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip runs agents in isolated workspaces. > - A native sandbox turn must restore its workspace before the provider starts. > - Restored Git metadata can cause expensive work in the provider's first snapshot. > - Cache replay also hashes the same archives twice, and workspace helpers open sessions that do not need output. > - This pull request removes that repeated work and refreshes the restored index before OpenCode starts. > - It also adds cache-publication timings so the next measurement can separate quota checks, lock waiting, and archive IO. ## Linked Issues or Issue Description **What existing behavior does this improve?** Native sandbox workspace preparation and copyback. **Current behavior** Cache replay validates shared archives, copies them, and validates them again. Exit-status-only helpers use persistent sessions. A restored checkout can make OpenCode's first snapshot recheck much of the repository. Cache publication has an unexplained interval after archive verification. **Proposed behavior** Validate the private archive copy that staging will consume. Use the existing one-shot command path for helpers that only need an exit status. Refresh the Git index after workspace transfer for a new native OpenCode sandbox. Keep the refresh inside the awaited inbound barrier and use the existing command timeout. Add nested publication spans. **Reason and benefit** Two matched local component pairs reduced preparation plus provider time from 8.563/8.553 seconds to 7.313/7.511 seconds, including the refresh cost. Two alternating remote copyback pairs reduced the median from 9.110 to 7.943 seconds. These are component results. They do not establish an end-to-end hosted improvement. Related: #18181 and the earlier closed #18195. This implementation uses the existing fully awaited transfer boundary. It does not use an optional preparation deadline. ## What Changed - Materialize cache hits into caller-owned staging and validate those exact bytes once. Remove incomplete copies on failure. - Select the existing one-shot path for exit-status-only run and removal helpers. Output-reading helpers keep their existing behavior. - Refresh restored Git stat metadata before OpenCode starts. Enable this only for newly acquired native OpenCode host-current workspaces. Skip recovery and retained sandboxes. Keep dirty files unstaged, accept Git's dirty-worktree status, and propagate real command errors. - Trace cache capacity checks, eviction, the body after lock acquisition, archive copy, sync, and hashing. Keep the operator endpoint gate and bounded phase names. ## Verification - On the rebased branch: 202 focused tests pass: 152 workspace seed/cache/runtime tests, 27 managed command tests, and 23 native workspace/tracing tests. Adapter TypeScript build and server typecheck pass. - Tests cover corrupted copies, cache changes after private materialization, dirty working trees, quoted paths, recovery, the awaited refresh barrier, real command failure, and company-lock contention. - A real-filesystem probe through the server phase mapper verified parentage and endpoint-off behavior. A controlled lock wait stayed outside the locked-publication span. No external export was used. - A full local Core/Daytona replay on the pre-rebase implementation saved its synthetic reply. It retained the pinned large public workspace, all 209 skill files, positive Git context, and 66 tool schemas. Both model calls stayed on sandbox loopback. Index refresh took 0.644 seconds and the first provider snapshot took 0.499 seconds. The run took 162.004 seconds, including large laptop uploads; it is not a hosted acceptance result or a matched end-to-end comparison. The failed installer attempt remains recorded separately. Normal cleanup removed all owned sandboxes. - The current head passed 53 CI checks, with two optional skips. Greptile is 5/5 on the exact head, with no open review threads. No deployment or hosted latency claim is included. ## Risks - Index refresh adds one awaited command. It uses the existing timeout. A real Git failure stops preparation. - Cache hits need temporary disk space for a private copy. Existing corruption fallback and per-run recovery archives remain. - One-shot command transport must preserve exit status and timeout behavior across providers. The agent's native session and output-reading helpers are unchanged. - Local component gains are small relative to the remaining hosted latency. Batch the fixes with the diagnostic spans; measure the actual deployed path before claiming success. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use, and code execution. The session does not expose the exact model SKU or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
268 lines
16 KiB
TypeScript
268 lines
16 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import { prepareSandboxManagedRuntime, type SandboxManagedRuntimeClient, type SandboxSyncOperation } from "./sandbox-managed-runtime.js";
|
|
import { publishWorkspaceSeedGeneration, readWorkspaceSeedGeneration, workspaceSeedGeneration } from "./workspace-seed-cache.js";
|
|
import { captureDirectorySnapshot, disposeDirectorySnapshot } from "./workspace-restore-merge.js";
|
|
import type { GitWorkspaceSnapshot } from "./git-workspace-sync.js";
|
|
import { prepareAdapterExecutionTargetRuntime } from "./execution-target.js";
|
|
import { runChildProcess } from "./server-utils.js";
|
|
|
|
const exec = promisify(execFile);
|
|
const roots: string[] = [];
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
|
|
});
|
|
const git = async (cwd: string, ...args: string[]) => (await exec("git", args, { cwd })).stdout.trim();
|
|
|
|
function client(): SandboxManagedRuntimeClient {
|
|
const sync = async (operations: SandboxSyncOperation[]) => ({ operations: await Promise.all(operations.map(async operation => {
|
|
let bytesTransferred = 0;
|
|
for (const file of operation.files) {
|
|
await fs.mkdir(path.dirname(file.targetPath), { recursive: true });
|
|
if (file.kind === "directory") await fs.cp(file.sourcePath, file.targetPath, { recursive: true, verbatimSymlinks: true });
|
|
else {
|
|
const bytes = await fs.readFile(file.sourcePath);
|
|
await fs.writeFile(file.targetPath, bytes);
|
|
bytesTransferred += bytes.length;
|
|
}
|
|
}
|
|
for (const command of operation.postUploadCommands ?? []) await exec("sh", ["-c", command.command]);
|
|
return { operationId: operation.operationId, filesTransferred: operation.files.length, bytesTransferred };
|
|
})) });
|
|
return {
|
|
makeDir: directory => fs.mkdir(directory, { recursive: true }).then(() => {}),
|
|
writeFile: (file, bytes) => fs.writeFile(file, Buffer.from(bytes)),
|
|
readFile: file => fs.readFile(file),
|
|
listFiles: async directory => (await fs.readdir(directory, { withFileTypes: true })).filter(entry => entry.isFile()).map(entry => entry.name),
|
|
remove: file => fs.rm(file, { recursive: true, force: true }),
|
|
run: command => exec("sh", ["-c", command]).then(() => {}),
|
|
syncIn: sync,
|
|
syncOut: sync,
|
|
};
|
|
}
|
|
|
|
it.each([
|
|
{ nested: false, secondBranch: "issue/second" },
|
|
{ nested: true, secondBranch: "issue/second" },
|
|
{ nested: false, secondBranch: null },
|
|
{ nested: true, secondBranch: null },
|
|
])("reuses company seed content and restores branch identity ($nested, $secondBranch)", async ({ nested, secondBranch }) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-shared seed-'"));
|
|
roots.push(root);
|
|
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "home"));
|
|
const source = path.join(root, "source");
|
|
const repositories = nested ? ["", ".paperclip-repositories/backend"] : [""];
|
|
for (const relative of repositories) {
|
|
const cwd = path.join(source, relative);
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
await git(cwd, "init", "-q", "-b", "main");
|
|
await fs.writeFile(path.join(cwd, "tracked.txt"), "committed bytes\n");
|
|
await fs.writeFile(path.join(cwd, "deleted.txt"), "delete in host\n");
|
|
await fs.writeFile(path.join(cwd, ".gitignore"), "secret.txt\n.paperclip-repositories/\n");
|
|
await fs.symlink("tracked.txt", path.join(cwd, "link.txt"));
|
|
await git(cwd, "add", ".");
|
|
await git(cwd, "-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-qm", "Seed");
|
|
}
|
|
const company = path.join(root, "company");
|
|
const otherWorkspace = path.join(root, "other-workspace"); await fs.mkdir(otherWorkspace);
|
|
const priorGenerations = new Set<string>();
|
|
for (let index = 0; index < 16; index++) {
|
|
await fs.writeFile(path.join(otherWorkspace, "file.txt"), `Other workspace ${index}\n`);
|
|
const snapshot = await captureDirectorySnapshot(otherWorkspace);
|
|
const generation = workspaceSeedGeneration(snapshot, null, [], "company");
|
|
await disposeDirectorySnapshot(snapshot);
|
|
const archive = path.join(root, `other-${index}.tar`);
|
|
await exec("tar", ["-cf", archive, "-C", otherWorkspace, "."]);
|
|
expect(await publishWorkspaceSeedGeneration(path.join(company, "shared-v1"), generation,
|
|
{ workspaceArchivePath: archive }, { companyDirectory: company, scope: "company" })).toBe(true);
|
|
priorGenerations.add(generation);
|
|
}
|
|
const prepare = async (name: string, branch: string | null, companyDirectory = company) => {
|
|
const host = path.join(root, name, "host"), remote = path.join(root, name, "remote");
|
|
for (const relative of repositories) {
|
|
const cwd = path.join(host, relative);
|
|
await fs.mkdir(path.dirname(cwd), { recursive: true });
|
|
await exec("git", ["clone", "--shared", "-q", path.join(source, relative), cwd]);
|
|
await git(cwd, "checkout", "-q", ...(branch ? ["-b", branch] : ["--detach", "HEAD"]));
|
|
await fs.writeFile(path.join(cwd, "tracked.txt"), "working-tree bytes\n");
|
|
await fs.rm(path.join(cwd, "deleted.txt"));
|
|
await fs.writeFile(path.join(cwd, "new.txt"), "untracked overlay\n");
|
|
await fs.writeFile(path.join(cwd, "secret.txt"), "must stay in host\n");
|
|
}
|
|
const phases: string[] = [];
|
|
const durable = path.join(root, name, "durable");
|
|
const input: Parameters<typeof prepareSandboxManagedRuntime>[0] = {
|
|
spec: { transport: "sandbox", provider: "filesystem", sandboxId: name, remoteCwd: remote, timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test", client: client(), workspaceLocalDir: host, workspaceCheckpoint: true,
|
|
workspaceGitIndexRefresh: true,
|
|
workspaceSeedCacheScope: "company",
|
|
workspaceSeedCacheDirectory: path.join(companyDirectory, "shared-v1"), workspaceSeedCacheCompanyDirectory: companyDirectory,
|
|
workspaceDurableSeed: { workspaceArchivePath: path.join(durable, "workspace.tar"), gitArchivePath: path.join(durable, "git.tar") },
|
|
runtimeSpan: async (name, work) => { phases.push(name); return work(); },
|
|
};
|
|
const transport = client();
|
|
const prepared = await prepareAdapterExecutionTargetRuntime({
|
|
...input, runId: name,
|
|
target: {
|
|
kind: "remote", transport: "sandbox", providerKey: "filesystem", remoteCwd: remote,
|
|
runner: {
|
|
syncIn: operations => transport.syncIn!(operations), syncOut: operations => transport.syncOut!(operations),
|
|
execute: command => runChildProcess(name, command.command, command.args ?? [], {
|
|
cwd: command.cwd ?? root, env: command.env ?? {}, stdin: command.stdin,
|
|
timeoutSec: 30, graceSec: 1, onLog: command.onLog ?? (async () => {}),
|
|
}),
|
|
},
|
|
},
|
|
...(nested ? { installCommand: "exit 1", detectCommand: "git" } : {}),
|
|
});
|
|
return { prepared, host, remote, phases, input };
|
|
};
|
|
const first = await prepare("first", "issue/first");
|
|
const second = await prepare("second", secondBranch);
|
|
for (const relative of repositories) {
|
|
const cwd = path.join(second.remote, relative);
|
|
expect(await git(cwd, "rev-parse", "--abbrev-ref", "HEAD")).toBe(secondBranch ?? "HEAD");
|
|
expect(await git(cwd, "branch", "--list", "issue/first")).toBe("");
|
|
expect(await fs.readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("working-tree bytes\n");
|
|
expect(await git(cwd, "diff", "--cached", "--exit-code")).toBe("");
|
|
expect(await fs.readFile(path.join(cwd, "new.txt"), "utf8")).toBe("untracked overlay\n");
|
|
expect(await fs.readlink(path.join(cwd, "link.txt"))).toBe("tracked.txt");
|
|
await expect(fs.stat(path.join(cwd, "deleted.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(fs.stat(path.join(cwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
}
|
|
expect(first.phases).toContain("workspace.git_clone");
|
|
expect(first.phases).toContain("workspace.git_index_refresh");
|
|
expect(second.phases).toContain("workspace.git_index_refresh");
|
|
expect(second.phases).not.toContain("workspace.git_clone");
|
|
expect(second.phases).not.toContain("workspace.seed_publish");
|
|
const generations = await fs.readdir(path.join(company, "shared-v1"));
|
|
expect(generations).toHaveLength(17);
|
|
const current = generations.filter(generation => !priorGenerations.has(generation));
|
|
expect(current).toHaveLength(1);
|
|
expect(await readWorkspaceSeedGeneration(path.join(company, "shared-v1"), current[0]!, { companyDirectory: company })).not.toBeNull();
|
|
const recovery = path.join(root, "replacement");
|
|
const refreshCount = second.phases.filter(phase => phase === "workspace.git_index_refresh").length;
|
|
const recovered = await prepareSandboxManagedRuntime({
|
|
...second.input,
|
|
spec: { ...second.input.spec, remoteCwd: recovery },
|
|
workspaceInboundMode: "durable_seed",
|
|
workspaceSeedCacheScope: undefined, workspaceSeedCacheDirectory: undefined, workspaceSeedCacheCompanyDirectory: undefined,
|
|
workspaceBaseline: second.prepared.workspaceSyncSnapshot!.baseline,
|
|
workspaceGitSnapshot: second.prepared.workspaceSyncSnapshot!.gitSnapshot,
|
|
});
|
|
expect(second.phases.filter(phase => phase === "workspace.git_index_refresh")).toHaveLength(refreshCount);
|
|
second.remote = recovery;
|
|
for (const relative of repositories) {
|
|
const cwd = path.join(second.remote, relative);
|
|
expect(await git(cwd, "rev-parse", "--abbrev-ref", "HEAD")).toBe(secondBranch ?? "HEAD");
|
|
expect(await fs.readFile(path.join(cwd, "new.txt"), "utf8")).toBe("untracked overlay\n");
|
|
await fs.writeFile(path.join(cwd, "tracked.txt"), "agent edit\n");
|
|
await git(cwd, "add", "tracked.txt");
|
|
await git(cwd, "-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-qm", "Agent change");
|
|
}
|
|
await recovered.restoreWorkspace();
|
|
for (const relative of repositories) {
|
|
expect(await git(path.join(second.host, relative), "rev-parse", "--abbrev-ref", "HEAD")).toBe(secondBranch ?? "HEAD");
|
|
expect(await fs.readFile(path.join(second.host, relative, "tracked.txt"), "utf8")).toBe("agent edit\n");
|
|
expect(await fs.readFile(path.join(second.host, relative, "secret.txt"), "utf8")).toBe("must stay in host\n");
|
|
expect(await fs.readFile(path.join(first.host, relative, "tracked.txt"), "utf8")).toBe("working-tree bytes\n");
|
|
}
|
|
await first.prepared.cleanupWorkspaceSnapshot?.();
|
|
const isolated = await prepare("other-company", secondBranch, path.join(root, "other-company-cache"));
|
|
expect(isolated.phases).toContain("workspace.git_clone");
|
|
await isolated.prepared.cleanupWorkspaceSnapshot?.();
|
|
await fs.writeFile(path.join(company, "shared-v1", current[0]!, "git.tar"), "corrupted cache");
|
|
const fallback = await prepare("corrupted-cache", secondBranch);
|
|
expect(fallback.phases).toContain("workspace.git_clone");
|
|
for (const relative of repositories) {
|
|
const cwd = path.join(fallback.remote, relative);
|
|
expect(await git(cwd, "rev-parse", "--abbrev-ref", "HEAD")).toBe(secondBranch ?? "HEAD");
|
|
expect(await fs.readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("working-tree bytes\n");
|
|
expect(await fs.readFile(path.join(cwd, "new.txt"), "utf8")).toBe("untracked overlay\n");
|
|
await expect(fs.stat(path.join(cwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
}
|
|
await fallback.prepared.cleanupWorkspaceSnapshot?.();
|
|
}, 30_000);
|
|
|
|
it("separates shared seeds when file contents, Git head, origin or repository layout differ", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-seed-identity-"));
|
|
roots.push(root);
|
|
await fs.writeFile(path.join(root, "file.txt"), "first bytes");
|
|
const first = await captureDirectorySnapshot(root);
|
|
const git: GitWorkspaceSnapshot = { headCommit: "a".repeat(40), branchName: "first", originUrl: "https://example.test/one.git", overlayPaths: [], deletedPaths: [], ignoredPaths: [] };
|
|
const generation = workspaceSeedGeneration(first, git, [], "company");
|
|
expect(workspaceSeedGeneration(first, { ...git, branchName: "second" }, [], "company")).toBe(generation);
|
|
expect(workspaceSeedGeneration(first, git)).not.toBe(workspaceSeedGeneration(first, { ...git, branchName: "second" }));
|
|
expect(workspaceSeedGeneration(first, { ...git, headCommit: "b".repeat(40) }, [], "company")).not.toBe(generation);
|
|
expect(workspaceSeedGeneration(first, { ...git, originUrl: "https://example.test/two.git" }, [], "company")).not.toBe(generation);
|
|
expect(workspaceSeedGeneration(first, git, [{ path: "nested", snapshot: git }], "company")).not.toBe(generation);
|
|
expect(workspaceSeedGeneration(first, null, [{ path: "first", snapshot: git }], "company"))
|
|
.not.toBe(workspaceSeedGeneration(first, null, [{ path: "second", snapshot: git }], "company"));
|
|
await fs.writeFile(path.join(root, "file.txt"), "second bytes");
|
|
const second = await captureDirectorySnapshot(root);
|
|
expect(workspaceSeedGeneration(second, git, [], "company")).not.toBe(generation);
|
|
await disposeDirectorySnapshot(first); await disposeDirectorySnapshot(second);
|
|
});
|
|
|
|
it.each([false, true])("awaits restored-index work before inbound preparation completes (failure=%s)", async failure => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-index-barrier-"));
|
|
roots.push(root);
|
|
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "home"));
|
|
const host = path.join(root, "host"), remote = path.join(root, "remote");
|
|
await fs.mkdir(host);
|
|
await git(host, "init", "-q", "-b", "main");
|
|
await fs.writeFile(path.join(host, "tracked.txt"), "committed\n");
|
|
await git(host, "add", ".");
|
|
await git(host, "-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-qm", "Seed");
|
|
await fs.writeFile(path.join(host, "tracked.txt"), "dirty working tree\n");
|
|
let release!: () => void, enter!: () => void;
|
|
const blocked = new Promise<void>(resolve => { release = resolve; });
|
|
const entered = new Promise<void>(resolve => { enter = resolve; });
|
|
let refreshing = false, completed = false;
|
|
const refreshTimeouts: Array<number | undefined> = [];
|
|
const transport = client();
|
|
const preparing = prepareAdapterExecutionTargetRuntime({
|
|
runId: "index-barrier", adapterKey: "test", workspaceLocalDir: host, workspaceGitIndexRefresh: true,
|
|
target: { kind: "remote", transport: "sandbox", providerKey: "filesystem", remoteCwd: remote, timeoutMs: 45_000,
|
|
runner: { syncIn: operations => transport.syncIn!(operations), syncOut: operations => transport.syncOut!(operations),
|
|
execute: command => {
|
|
if (refreshing) refreshTimeouts.push(command.timeoutMs);
|
|
return runChildProcess("index-barrier", refreshing && failure ? "sh" : command.command,
|
|
refreshing && failure ? ["-c", "exit 7"] : command.args ?? [], {
|
|
cwd: command.cwd ?? root, env: command.env ?? {}, timeoutSec: (command.timeoutMs ?? 45_000) / 1000,
|
|
graceSec: 1, onLog: async () => {},
|
|
});
|
|
},
|
|
},
|
|
},
|
|
runtimeSpan: async (name, work) => {
|
|
if (name !== "workspace.git_index_refresh") return work();
|
|
enter(); await blocked; refreshing = true;
|
|
try { return await work(); } finally { refreshing = false; }
|
|
},
|
|
});
|
|
void preparing.then(() => { completed = true; }, () => {});
|
|
try {
|
|
await Promise.race([entered, preparing.then(() => { throw new Error("Preparation bypassed the index barrier"); })]);
|
|
expect(await fs.readFile(path.join(remote, "tracked.txt"), "utf8")).toBe("dirty working tree\n");
|
|
expect(completed).toBe(false);
|
|
release();
|
|
if (failure) await expect(preparing).rejects.toThrow();
|
|
else {
|
|
const prepared = await preparing;
|
|
expect(await git(remote, "diff", "--cached", "--exit-code")).toBe("");
|
|
expect(await git(remote, "diff", "--name-only")).toBe("tracked.txt");
|
|
await prepared.cleanupWorkspaceSnapshot?.();
|
|
}
|
|
expect(refreshTimeouts).toEqual([45_000]);
|
|
} finally {
|
|
release();
|
|
await preparing.catch(() => {});
|
|
}
|
|
});
|