Files
PaperClipAI/packages/adapter-utils/src/workspace-seed-reuse.test.ts
T
DottaandPaperclip 2aa3ea47e3 Reduce restored workspace overhead and trace cache publication (#18240)
## 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>
2026-10-29 19:32:28 -05:00

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(() => {});
}
});