mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 20:44:16 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Each run needs a private workspace and safe persistence. > - Fresh isolated repository sources download upstream Git history again. > - Sandbox staging also verifies an optional seed cache before it uploads the workspace. > - That verification holds a company lock during a full extraction and hash scan. > - This pull request reuses pristine Git objects through an authenticated remote clone. > - It also overlaps seed verification with upload and moves verification outside the publication lock. > - The benefit is less repeated transfer and less serial preparation work, with the same task isolation and recovery contract. ## Linked Issues or Issue Description **What existing behavior does this improve?** Fresh task-private repository preparation and inbound sandbox workspace staging. **Current behavior** Each isolated remote source clones the full upstream history. Optional seed verification then extracts and hashes another full tree before upload. One workspace's verification can hold the company cache lock while other workspaces wait. **Proposed behavior** Use a company and credential scoped cache of pristine upstream objects for GitHub HTTPS sources. Each task still clones from the current remote. It disconnects from the cache before publication. Run seed verification alongside upload. Hold the company lock only for final capacity checks and publication. **Reason and benefit** These operations lie on the workspace preparation path. Removing repeated Git pack transfer and serial verification can reduce startup time. Local measurements below cover these boundaries. They do not establish hosted agent latency. Related: #16425, #16454, #16398, #16461, #16468, #16473. The cache inspection and durable folder proposal in #13376 covers separate product work. ## What Changed - Add a pristine upstream Git object cache for fresh isolated GitHub HTTPS sources. Key it by company, repository and current credential fingerprint. Do not store credentials. - Keep online clone authorization and current remote refs. Use `--reference-if-able --dissociate` so task objects remain independent. A reference failure retries once online without the cache and resolves credentials again. - Cap cache storage at 64 entries and 4 GiB per company, with 2 GiB per entry. Skip contended publication. Bound priming by a cooperative five-second budget and a 4,096-entry scan allowance. Keep the existing free-space reserve. - Overlap optional seed verification and required workspace upload. Settle both before returning or deleting temporary files. Recheck quotas and company directory identity under the publication lock. - Keep the independent fresh upstream reproducibility proof, task commits, dirty source copies, ignored files, nested repositories and durable recovery behavior. - Suppress optional Git server-info generation during dissociation so generated storage passes the existing safe-expiration checks. This uses [the supported Git setting](https://git-scm.com/docs/git-config/2.43.0#Documentation/git-config.txt-repackupdateServerInfo). - Add bounded timing phases, regression coverage and two repeatable local benchmarks. No first-party Telemetry or dependency changes. ## Verification - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - Affected adapter suites: 240 tests passed. Native workspace persistence/history and tracing: 33 tests passed. Server checkout/isolation/tracing batch: 53 tests passed. Final object-cache authority, path and capacity suite: 12 tests passed. These batches overlap. - Cache and expiration/restoration lifecycle batch: 34 tests passed. A direct generated-storage check covers cached clones. - Real HTTP Git fixture: a matching cache does not bypass revoked remote access. Other fixtures cover current refs/tags, hidden cached commits, independent objects after cache removal, corruption fallback, credential/company scope, quota limits and publication contention. - Initial restricted runs hit SQLite write and Unix socket permission failures. The adapter batch passed with disposable writable instance state and socket access. The first typecheck rejected the test use of `Promise.withResolvers`; typed deferred gates now use the supported library contract. - Repository benchmark on final head `9047368bf3b7fa2061e00d698b8b51cf4ae1da32`: five alternating samples, real local `file://` Git transport, synthetic 32 MiB history, no injected latency. Median 1143.8 ms to 240.5 ms; ranges 1085.7–1486.6 ms and 188.0–254.8 ms. Received Git pack bytes fell from 33,569,788 to zero. Initial cache priming took 1208.4 ms and is reported separately. - Sandbox benchmark, five alternating samples per mode on 10,000 files with real packing, extraction and verification: with a controlled 3-second transfer delay, median 9.450 s to 7.620 s (ranges 9.280–12.115 s and 7.296–7.983 s). With no injected delay, median 6.808 s to 6.450 s (ranges 6.255–7.029 s and 5.709–6.902 s). The no-delay ranges overlap; local copying competes with verification for disk. Both modes verify final tree contents, ignored-file exclusion and Git HEAD. These are synthetic local fixtures, not hosted agent runs. - Reproduce with `pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-repository-object-cache.ts` and `pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-staging.ts --baseline-ref=307eb16c0c --transfer-delay-ms=3000`. - Prior head `af766bc820` received Greptile 5/5 with no findings. Its E2E shard 7 failed in `plan-task-fixture` cleanup: company deletion raced an `activity_log` foreign key. That fixture has no provider run or Git workspace. The failure is retained here; final-head CI must pass. The corrected corruption fixture now forces a pack and verifies a real damaged archive. Current head `9047368bf3b7fa2061e00d698b8b51cf4ae1da32` has Greptile 5/5 with no review threads or findings. Final head has 55 passing checks and two optional Storybook skips. [The first workspace-shard attempt](https://github.com/paperclipai/paperclip/actions/runs/38802441857/job/116655485350) passed all 1,778 cases, then reported a late `EPIPE` from unchanged `server-utils.ts` while the ACP reuse test was active. That case mocks workspace preparation and passed locally in isolation. [Its single retry on the same commit](https://github.com/paperclipai/paperclip/actions/runs/38802441857/job/116659728034) passed, as did the dependent verification gate. The earlier E2E cleanup fixture passed on this final head. Both initial failures remain recorded here. A full local suite was not duplicated; the affected boundaries were run locally and the complete suite runs in CI. ## Risks - First cache population adds local copying. Cold clones, credential rotation, capacity exhaustion and a corrupt cache retain the online clone path. No eviction is added. Cached data is bounded, but many companies can each consume their allowance. - Individual filesystem calls and cleanup can exceed the cooperative priming budget. Failed reference clones make one additional online attempt. - Parallel verification and upload compete for disk and CPU. The local no-delay benchmark shows a smaller gain than a slow-transfer case. Verification still checks contents before cache publication, and required upload failures remain failures. - Local fixtures do not prove hosted throughput or the 15–30-second end-to-end target. The fresh checkout still writes its working tree, and model startup, copyback and other preparation costs remain. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context window are not exposed in this session. Used reasoning, source editing, shell tools and test execution. ## 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 and contains no internal task identifiers - [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>
63 lines
3.7 KiB
TypeScript
63 lines
3.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { execFile as execute } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { materializeManagedProjectWorkspace } from "../server/src/services/managed-repository-checkout.js";
|
|
|
|
const execFile = promisify(execute);
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "repository-cache-benchmark-"));
|
|
const previousHome = process.env.PAPERCLIP_HOME;
|
|
const previousTrace = process.env.GIT_TRACE_PACKFILE;
|
|
process.env.PAPERCLIP_HOME = path.join(root, "home");
|
|
const source = path.join(root, "source");
|
|
const cache = path.join(root, "cache/company");
|
|
const samples = Number(process.argv.find(arg => arg.startsWith("--samples="))?.split("=")[1] ?? 5);
|
|
assert.ok(Number.isSafeInteger(samples) && samples > 0 && samples <= 10);
|
|
try {
|
|
await fs.mkdir(source);
|
|
const git = async (...args: string[]) => (await execFile("git", ["-C", source, ...args])).stdout;
|
|
await git("init", "-b", "main");
|
|
await git("config", "user.name", "Fixture"); await git("config", "user.email", "fixture@example.test");
|
|
for (let batch = 0; batch < 4; batch++) {
|
|
await Promise.all(Array.from({ length: 32 }, (_, index) => fs.writeFile(path.join(source, `file-${batch * 32 + index}.bin`), randomBytes(256 * 1024))));
|
|
await git("add", "."); await git("commit", "-m", `History ${batch}`);
|
|
}
|
|
const repoUrl = pathToFileURL(source).href;
|
|
const prepare = (target: string, useCache: boolean) => materializeManagedProjectWorkspace(target,
|
|
{ repoUrl, ...(useCache ? { objectCacheCompanyDirectory: cache } : {}) });
|
|
const warmStart = performance.now(); await prepare(path.join(root, "prime"), true);
|
|
const cachePrimeMs = performance.now() - warmStart;
|
|
const expected = await git("rev-parse", "HEAD");
|
|
const results = [];
|
|
for (let sample = 0; sample < samples; sample++) {
|
|
for (const mode of sample % 2 ? ["after", "before"] : ["before", "after"]) {
|
|
const target = path.join(root, `${mode}-${sample}`);
|
|
const trace = path.join(root, `${mode}-${sample}.pack`);
|
|
process.env.GIT_TRACE_PACKFILE = trace;
|
|
const start = performance.now(); await prepare(target, mode === "after");
|
|
const wallMs = performance.now() - start;
|
|
const receivedPackBytes = await fs.stat(trace).then(stat => stat.size, () => 0);
|
|
delete process.env.GIT_TRACE_PACKFILE;
|
|
assert.equal((await execFile("git", ["-C", target, "rev-parse", "HEAD"])).stdout, expected);
|
|
await assert.rejects(fs.stat(path.join(target, ".git/objects/info/alternates")), { code: "ENOENT" });
|
|
await execFile("git", ["-C", target, "fsck", "--full"]);
|
|
results.push({ mode, sample, wallMs, receivedPackBytes });
|
|
await fs.rm(target, { recursive: true, force: true });
|
|
}
|
|
}
|
|
const summary = Object.fromEntries(["before", "after"].map(mode => {
|
|
const timings = results.filter(result => result.mode === mode).map(result => result.wallMs).sort((a, b) => a - b);
|
|
return [mode, { medianMs: timings[Math.floor(timings.length / 2)], minMs: timings[0], maxMs: timings.at(-1) }];
|
|
}));
|
|
console.log(JSON.stringify({ node: process.version, platform: process.platform, transport: "local file:// Git upload-pack; no injected latency",
|
|
files: 128, contentBytes: 32 * 1024 * 1024, commits: 4, samples, cachePrimeMs, summary, results }, null, 2));
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
|
if (previousTrace === undefined) delete process.env.GIT_TRACE_PACKFILE; else process.env.GIT_TRACE_PACKFILE = previousTrace;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|