Files
PaperClipAI/server/src/services/repository-object-cache.ts
T
DottaandPaperclip 023f4a5d0c fix: preserve repository cache negotiation refs (#16687)
## Thinking Path

> - Paperclip manages agent work and its execution environments.
> - A fresh repository task needs an independent checkout before its
runner can start.
> - The object cache from #16487 should reduce repeated network
transfer.
> - The cache kept Git objects but dropped remote-tracking refs during
its bare clone.
> - Git needs those tips to negotiate which objects it already has.
> - This pull request preserves the tips in a versioned mirror cache and
records explicit cache outcomes.
> - The benefit is less repeated transfer and clearer evidence when a
cache does not help.

## Linked Issues or Issue Description

Refs #16487 and #16454. No duplicate cache-negotiation fix was found in
the public PR search.

**What happened?**

A valid object cache could make a fresh clone slower. A controlled
staging probe took 45.87 seconds and received 184.39 MB even though the
reference already held the objects. An earlier probe took 52.33 seconds.
An uncached clone took 20.94 seconds.

`git clone --bare` copied the pristine checkout's objects, local
branches, and tags, but omitted its remote-tracking refs. The source had
about 1,200 remote branches. Git could not use the missing tips to
negotiate those objects.

**Expected behavior**

Keep the pristine reference's negotiation tips. Each task must still
authenticate online, use the current remote refs, and end with
independent Git objects.

**Steps to reproduce**

1. Clone a repository with many remote branches into a fresh checkout.
2. Publish a reference with `git clone --bare --no-hardlinks` from that
checkout.
3. Clone the remote with `--reference-if-able` and `--dissociate`.
4. Compare with a reference that retains the remote-tracking tips. Use
Git Trace2 and count received pack bytes.

**Paperclip version or commit**

The staging runtime was `27ab902d3db2770ada79c9f42a9c06e1d346f34b`, with
Git 2.47.3. The cache implementation was unchanged from #16487.

**Deployment mode**

Docker on a persistent application volume. The probe used the
application file owner and the public Paperclip repository. It did not
start agent tasks or alter the application cache.

## What Changed

- Publish pristine object caches with `--mirror` to retain
remote-tracking tips.
- Put cache format version 2 in the key and receipt. The first checkout
builds a new generation. Existing generations stay under the current
storage limits.
- Keep the existing online authentication, current remote refs,
credential and company isolation, independent objects, quotas, and
fallback behavior.
- Add closed Honeycomb cache outcomes and a boolean that states whether
a reference was supplied to clone.
- Document that a lookup hit or supplied reference does not prove Git
avoided transfer.

## Verification

- Controlled staging component experiment: a disposable reference copy
held exactly the same 210,057 objects as the original. It added 1,187
advertised branch tips whose commits were already in those objects.
- Before: 45.87 seconds and 184.39 MB received, one sample in this
comparison.
- After: 12.20 and 14.58 seconds; median 13.39 seconds. Received pack
sizes were 1.99 and 1.95 MB.
- All comparison clones used the same current remote HEAD and ended
without an alternates file.
- The remaining repack took 9.72–9.81 seconds. Checkout file writing
took 0.86–0.96 seconds.
- The staging experiment restored tips on a disposable cache copy. It
did not deploy this patch or measure complete agent latency.
- Confirmed both staging scratch locations were empty afterward. The
original cache timestamps stayed unchanged. The temporary operator SSH
key was revoked and its local files removed.
- TypeScript 5.9.3 transpilation reported zero syntax diagnostics across
the four changed TypeScript files. `git diff --check` passed.
- The new negotiation regression fails with the old `--bare` publisher
and passes with the mirror publisher.
- Focused Vitest suites: 26 tests passed across repository-object-cache
and execution-preparation-timing. These cover independent objects,
current refs, unavailable/revoked remotes, scope separation, invalid
storage, tracing isolation, and endpoint-off behavior.
- The first full focused run passed 24 tests but failed its existing
localhost HTTP fixture because the sandbox denied `listen` on
`127.0.0.1`. The rerun with local loopback permission passed all 26
tests.
- Server semantic typechecking passed with `tsc -p server/tsconfig.json
--noEmit` after building the required workspace declarations. Initial
attempts lacked generated dependency outputs; the completed dependency
build and rerun passed.
- A full application build was not run locally. CI passed: 55 checks
passed and two optional checks were skipped. Greptile is 5/5 on the
current head, with both findings resolved.

## Risks

- Version 2 causes one cold cache rebuild. Old cache generations retain
their disk usage, and the existing quota can prevent publication.
- A cache still needs a full repack to produce an independent task
checkout. This PR does not remove that cost.
- The component experiment establishes the negotiation effect on
staging. It does not establish an end-to-end agent speedup or the
under-30-second goal.
- The new trace fields remain behind the existing OTLP gate. Their
values are closed enums or a boolean. They export no refs, paths, URLs,
cache keys, credentials, or error text.

## Model Used

OpenAI Codex, GPT-6 family, with code and tool use. The exact serving
model ID and context window are not exposed in this session.

## 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-18 21:28:20 -05:00

129 lines
7.3 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { execFile as execute } from "node:child_process";
import { promisify } from "node:util";
import { tryWithDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { assertWorkspaceManifestDiskSpace } from "@paperclipai/adapter-utils/workspace-manifest";
import type { GitAuthInvocation } from "./git-credentials.js";
import { sanitizeRuntimeServiceBaseEnv } from "./runtime-service-env.js";
import { recordRepositoryObjectCacheDetail, type RepositoryObjectCacheOutcome } from "./execution-preparation-timing.js";
const execFile = promisify(execute);
const REPOSITORY_BYTES = 2 * 1024 ** 3;
const COMPANY_BYTES = 4 * 1024 ** 3;
const COMPANY_REPOSITORIES = 64;
const CACHE_VERSION = 2;
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
class RepositoryObjectCacheSkip extends Error {
constructor(readonly outcome: "capacity" | "budget" | "invalid") {
super("Repository object cache skipped");
}
}
export interface RepositoryObjectCache {
companyDirectory: string;
directory: string;
key: string;
repoUrl: string;
}
export async function resolveRepositoryObjectCache(companyDirectory: string, repoUrl: string, auth: GitAuthInvocation | null): Promise<RepositoryObjectCache | null> {
try {
const remote = new URL(repoUrl);
if (remote.username || remote.password || remote.search || remote.hash) return null;
const parent = path.dirname(companyDirectory);
await fs.mkdir(parent, { recursive: true, mode: 0o700 });
for (const directory of [parent, companyDirectory]) {
if (directory === companyDirectory) await fs.mkdir(directory, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST") throw error;
});
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
}
companyDirectory = await fs.realpath(companyDirectory);
const scope = auth ? [auth.source, auth.secretName, auth.configArgs, Object.entries(auth.env).sort(([a], [b]) => a.localeCompare(b))] : null;
const key = hash(JSON.stringify([CACHE_VERSION, repoUrl, scope]));
return { companyDirectory, directory: path.join(companyDirectory, key), key, repoUrl };
} catch { return null; }
}
export async function readRepositoryObjectCache(cache: RepositoryObjectCache): Promise<string | null> {
try {
for (const directory of [cache.companyDirectory, cache.directory]) {
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
}
if (await fs.realpath(cache.companyDirectory) !== cache.companyDirectory) return null;
const objects = await fs.lstat(path.join(cache.directory, "objects"));
if (!objects.isDirectory() || objects.isSymbolicLink()) return null;
if (await fs.lstat(path.join(cache.directory, "objects/info/alternates")).then(() => true, () => false)) return null;
const receiptPath = path.join(cache.directory, "paperclip-object-cache.json");
const stat = await fs.lstat(receiptPath);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return null;
const receipt = JSON.parse(await fs.readFile(receiptPath, "utf8"));
if (receipt.version !== CACHE_VERSION || receipt.key !== cache.key) return null;
const options = { timeout: 10000, maxBuffer: 16384, env: sanitizeRuntimeServiceBaseEnv(process.env) };
const bare = await execFile("git", ["-C", cache.directory, "rev-parse", "--is-bare-repository"], options);
const origin = await execFile("git", ["-C", cache.directory, "config", "--local", "--null", "--get-all", "remote.origin.url"], options);
if (bare.stdout.trim() !== "true" || origin.stdout !== `${cache.repoUrl}\0`) return null;
return cache.directory;
} catch { return null; }
}
async function storageBytes(directory: string, ceiling: number, budget: { entries: number; deadline: number }): Promise<number> {
let bytes = 0;
for await (const entry of await fs.opendir(directory)) {
if (--budget.entries < 0 || performance.now() > budget.deadline) throw new RepositoryObjectCacheSkip("budget");
const target = path.join(directory, entry.name);
const stat = await fs.lstat(target);
if (stat.isSymbolicLink()) throw new RepositoryObjectCacheSkip("invalid");
bytes += stat.isDirectory() ? await storageBytes(target, ceiling - bytes, budget) : stat.size;
if (bytes > ceiling) throw new RepositoryObjectCacheSkip("capacity");
}
return bytes;
}
export async function publishRepositoryObjectCache(cache: RepositoryObjectCache, checkout: string): Promise<void> {
try {
if (await readRepositoryObjectCache(cache)) {
recordRepositoryObjectCacheDetail({ outcome: "already_present" });
return;
}
const outcome = await tryWithDirectoryMergeLock(cache.companyDirectory, async (): Promise<RepositoryObjectCacheOutcome> => {
const company = await fs.lstat(cache.companyDirectory);
if (!company.isDirectory() || company.isSymbolicLink() || await fs.realpath(cache.companyDirectory) !== cache.companyDirectory) return "invalid";
const budget = { entries: 4096, deadline: performance.now() + 5000 };
let repositories = 0, bytes = 0;
for await (const entry of await fs.opendir(cache.companyDirectory)) {
if (++repositories >= COMPANY_REPOSITORIES) return "capacity";
const directory = path.join(cache.companyDirectory, entry.name);
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) return "invalid";
bytes += await storageBytes(directory, COMPANY_BYTES - bytes, budget);
}
if (await fs.lstat(cache.directory).then(() => true, () => false)) return "invalid";
const incoming = await storageBytes(path.join(checkout, ".git"), REPOSITORY_BYTES, budget) + 65536;
if (bytes + incoming > COMPANY_BYTES) return "capacity";
assertWorkspaceManifestDiskSpace(cache.companyDirectory, incoming);
const temporary = path.join(cache.companyDirectory, `.pending-${randomUUID()}`);
try {
const options = () => ({ timeout: Math.max(1, Math.ceil(budget.deadline - performance.now())), maxBuffer: 16384, env: sanitizeRuntimeServiceBaseEnv(process.env) });
await execFile("git", ["clone", "--mirror", "--no-hardlinks", "--", checkout, temporary], options());
await execFile("git", ["-C", temporary, "remote", "set-url", "origin", cache.repoUrl], options());
await fs.writeFile(path.join(temporary, "paperclip-object-cache.json"), JSON.stringify({ version: CACHE_VERSION, key: cache.key }));
const actual = await storageBytes(temporary, REPOSITORY_BYTES, budget);
if (bytes + actual > COMPANY_BYTES) return "capacity";
await fs.rename(temporary, cache.directory);
return "published";
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
});
recordRepositoryObjectCacheDetail({ outcome: outcome ?? "contended" });
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
recordRepositoryObjectCacheDetail({ outcome: error instanceof RepositoryObjectCacheSkip
? error.outcome : code === "ENOSPC" || code === "workspace_git_scan_failed" ? "capacity" : "error" });
}
}