mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs in remote sandboxes upload the workspace as seed
archives, and a per-company cache keeps recent seeds so a later run with
the same workspace state can reuse them
> - The cache only ever added seeds; at its limits it stopped accepting
new ones and never removed old ones
> - On one instance it had filled to its 8 GiB cap with week-old seeds
and froze, so it held disk without helping new runs
> - This pull request expires seeds unused for 7 days and evicts the
least recently used seeds when a new one needs room
> - The benefit is a cache that stays useful within the same limits and
gives back space held by stale seeds
## Linked Issues or Issue Description
Bug report described in-PR (bug report template).
**What happened?**
On one instance, the company's seed cache
(`native-workspace-sync/generations/<company>/`) sat at its 8 GiB cap:
209 generations across 169 workspaces. 107 generations (8.2 GB) had not
been touched for 7 days or more. Because publishing stops at the limits,
new workspace states were no longer cached, while seeds for old states
and deleted workspaces stayed forever.
**Expected behavior**
Stale seeds are removed, and a new seed can replace the least recently
used ones when the cache is full.
**Steps to reproduce**
1. Run enough native sandbox runs across workspace states to reach a
seed cache limit.
2. New seeds are no longer published, and nothing is ever removed.
**Paperclip version or commit:** `master` at 837add0caa.
**Deployment mode:** any with native remote-sandbox runs.
**Agent adapter(s) involved:** native sandbox runtimes
(`packages/adapter-utils` seed cache).
## What Changed
`packages/adapter-utils/src/workspace-seed-cache.ts`:
- **Use tracking:** on a verified hit, `readWorkspaceSeedGeneration`
takes the company lock, confirms the receipt is still the one it
verified, and touches it, so its mtime records the last use. Eviction
holds the same lock from reading timestamps to removing, so a hit is
either recorded first and protected, or already gone and reported as a
miss. The reader accepts a `companyDirectory` option, which the sandbox
runtime now passes as it already does for publishing.
- **Idle expiry:** `publishWorkspaceSeedGeneration`, under the existing
company lock, first removes generations unused for
`WORKSPACE_SEED_CACHE_EVICTION.idleMs` (7 days).
- **LRU eviction:** if the new seed still does not fit, it evicts the
least recently used generations until it does: within the workspace for
workspace limits, across the company for company limits.
- **What is never evicted:**
- a generation used within `minIdleMs` (1 hour), since a run copies a
cache hit shortly after the lookup;
- the generation being published;
- abandoned `.pending-*` directories, which still count toward the
limits as before.
- **Removal:** renames to `.evicted-<uuid>` before deleting, and removes
leftovers of an interrupted eviction. All empty workspace directories
are removed, so they no longer hold workspace slots.
- **Identity check:** publication rechecks the company directory's
identity under the lock before evicting, so a directory replaced during
verification is never touched.
- **Verification:** optional verification still runs only when the seed
can fit after eviction. A dry eviction plan decides this before
verification, and the real eviction happens under the lock.
- `publishWorkspaceSeedGeneration` accepts `eviction` and `now` options
for tests; the limits are unchanged.
No code comments were added.
## Verification
- `pnpm exec vitest run src/workspace-seed-cache.test.ts
src/workspace-checkpoint.test.ts` (in `packages/adapter-utils`): 58
passed. With `sandbox-managed-runtime.test.ts`: 168 passed. New cases:
- **Idle expiry:** generations unused for 7 days are removed on publish,
emptied workspaces are removed, newer ones stay.
- **Use tracking:** a cache hit protects an otherwise idle generation.
- **Workspace LRU:** the least recently used generation of a full
workspace is evicted.
- **Company LRU:** company pressure evicts across workspaces, but never
a generation used within the last hour.
- **Verification:** it runs only when eviction can make room.
- **Pending directories:** abandoned ones are never evicted, and
verification is skipped when they fill the cache.
- **Leftovers:** remnants of an interrupted eviction are removed.
- **Concurrent read:** an eviction paused just before removing a seed
makes a concurrent read report a miss, never a hit whose files vanish.
- **Replaced directory:** a nonempty company directory swapped in during
verification keeps its seeds.
- **Empty workspace:** an abandoned empty workspace directory no longer
holds the only workspace slot.
- **Mutation checks:** removing any of these fails at least one case:
- the touch on read;
- idle expiry;
- the recent-use guard;
- the plan before verification;
- leftover cleanup;
- the pending-directory guard;
- the read lock (the concurrent-read case then returns a hit whose files
are gone);
- the identity recheck;
- removing only workspaces that had generations.
The existing `workspace-checkpoint` end-to-end test caught an earlier
version that ignored pending directories when deciding whether to
verify.
- `pnpm exec vitest run src` (in `packages/adapter-utils`): all seed
cache and checkpoint tests pass. Two runs on a heavily loaded machine
each had unrelated failures (`github-launcher.test.ts`,
`acpx-engine/execute.test.ts`). One run also failed a seed cache test
once; I could not reproduce that in 21 further runs, including 6 in
parallel.
- `pnpm exec vitest run native-workspace-sync` (in `server/`): 23
passed.
- `pnpm run typecheck` (in `server/`, after building
`packages/adapter-utils`): pass.
- Not run: server build and boot smoke.
## Risks
- **First publish after deploy:** on an instance with a full cache of
old seeds, it removes them, about 8 GB on the instance above. The
removal is a rename plus a recursive delete under the company lock.
- **Eviction during use:** a run that looked up a seed more than an hour
earlier and has not yet copied it could see it evicted. It would fail
that preparation and retry. In practice the copy follows the lookup
within the same preparation.
- **No new settings:** the 7-day and 1-hour thresholds are constants
exported as `WORKSPACE_SEED_CACHE_EVICTION`.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests, read-only measurements on a
live instance).
## 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
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
244 lines
14 KiB
TypeScript
244 lines
14 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { createReadStream } from "node:fs";
|
|
import { directorySnapshotSha256, withDirectoryMergeLock, type DirectorySnapshot } from "./workspace-restore-merge.js";
|
|
import { assertWorkspaceManifestDiskSpace } from "./workspace-manifest.js";
|
|
import type { GitWorkspaceSnapshot } from "./git-workspace-sync.js";
|
|
import type { WorkspaceDurableSeedPaths } from "./sandbox-managed-runtime.js";
|
|
|
|
export function workspaceSeedGeneration(baseline: DirectorySnapshot, git: GitWorkspaceSnapshot | null, repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = []): string {
|
|
const identity = (snapshot: GitWorkspaceSnapshot | null): unknown => snapshot && ({
|
|
head: snapshot.headCommit, branch: snapshot.branchName, origin: snapshot.originUrl,
|
|
repositories: snapshot.repositories?.map((repo) => ({ path: repo.path, git: identity(repo.snapshot) })),
|
|
});
|
|
return createHash("sha256").update("verified-seed-v1\0").update(JSON.stringify([directorySnapshotSha256(baseline), identity(git), repositories.map((repo) => ({ path: repo.path, git: identity(repo.snapshot) }))])).digest("hex");
|
|
}
|
|
async function digest(file: string): Promise<string> {
|
|
const hash = createHash("sha256");
|
|
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
export async function readWorkspaceSeedGeneration(root: string, generation: string,
|
|
options: { companyDirectory?: string } = {}): Promise<WorkspaceDurableSeedPaths | null> {
|
|
const directory = path.join(root, generation);
|
|
try {
|
|
for (const item of [root, directory]) { const stat = await fs.lstat(item); if (!stat.isDirectory() || stat.isSymbolicLink()) return null; }
|
|
const receipt = path.join(directory, "receipt.json");
|
|
const receiptStat = await fs.lstat(receipt);
|
|
if (!receiptStat.isFile() || receiptStat.isSymbolicLink() || receiptStat.size > 4096) return null;
|
|
const metadata = JSON.parse(await fs.readFile(receipt, "utf8"));
|
|
if (!/^[a-f0-9]{64}$/.test(metadata.workspaceSha256) || (metadata.gitSha256 !== null && !/^[a-f0-9]{64}$/.test(metadata.gitSha256))) return null;
|
|
if (metadata.version !== 1 || metadata.generation !== generation) return null;
|
|
const workspaceArchivePath = path.join(directory, "workspace.tar");
|
|
const gitArchivePath = metadata.gitSha256 ? path.join(directory, "git.tar") : null;
|
|
for (const [file, expected] of [[workspaceArchivePath, metadata.workspaceSha256], [gitArchivePath, metadata.gitSha256]]) {
|
|
if (!file) continue;
|
|
const stat = await fs.lstat(file);
|
|
if (!stat.isFile() || stat.isSymbolicLink() || await digest(file) !== expected) return null;
|
|
}
|
|
const used = await withDirectoryMergeLock(path.resolve(options.companyDirectory ?? root), async () => {
|
|
const current = await fs.lstat(receipt).catch(() => null);
|
|
if (!current || current.ino !== receiptStat.ino || current.dev !== receiptStat.dev) return false;
|
|
const usedAt = new Date();
|
|
await fs.utimes(receipt, usedAt, usedAt);
|
|
return true;
|
|
});
|
|
if (!used) return null;
|
|
return { workspaceArchivePath, workspaceArchiveSha256: metadata.workspaceSha256,
|
|
gitArchivePath, gitArchiveSha256: metadata.gitSha256 };
|
|
} catch { return null; }
|
|
}
|
|
export const WORKSPACE_SEED_CACHE_LIMITS = Object.freeze({
|
|
workspaceGenerations: 16,
|
|
workspaceBytes: 1024 ** 3,
|
|
companyGenerations: 256,
|
|
companyWorkspaces: 256,
|
|
companyBytes: 8 * 1024 ** 3,
|
|
});
|
|
type SeedCacheLimits = { [Key in keyof typeof WORKSPACE_SEED_CACHE_LIMITS]: number };
|
|
|
|
export const WORKSPACE_SEED_CACHE_EVICTION = Object.freeze({
|
|
idleMs: 7 * 24 * 60 * 60 * 1000,
|
|
minIdleMs: 60 * 60 * 1000,
|
|
});
|
|
type SeedCacheEviction = { [Key in keyof typeof WORKSPACE_SEED_CACHE_EVICTION]: number };
|
|
|
|
type CachedGeneration = { directory: string; workspace: string; bytes: number; usedAt: number; evictable: boolean };
|
|
|
|
async function removeCacheEntry(directory: string) {
|
|
const retired = path.join(path.dirname(directory), `.evicted-${randomUUID()}`);
|
|
await fs.rename(directory, retired);
|
|
await fs.rm(retired, { recursive: true, force: true });
|
|
}
|
|
|
|
async function cachedGenerations(company: string, root: string, cleanup: boolean): Promise<{ workspaces: string[]; generations: CachedGeneration[] }> {
|
|
const workspaces = root === company ? [company]
|
|
: (await fs.readdir(company)).map(name => path.join(company, name));
|
|
const generations: CachedGeneration[] = [];
|
|
for (const workspace of workspaces) {
|
|
const workspaceStat = await fs.lstat(workspace).catch(() => null);
|
|
if (!workspaceStat?.isDirectory() || workspaceStat.isSymbolicLink()) continue;
|
|
for (const name of await fs.readdir(workspace)) {
|
|
const directory = path.join(workspace, name);
|
|
if (name.startsWith(".evicted-")) { if (cleanup) await fs.rm(directory, { recursive: true, force: true }); continue; }
|
|
const stat = await fs.lstat(directory).catch(() => null);
|
|
if (!stat) continue;
|
|
const evictable = /^[a-f0-9]{64}$/.test(name) && stat.isDirectory() && !stat.isSymbolicLink();
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) { generations.push({ directory, workspace, bytes: stat.size, usedAt: stat.mtimeMs, evictable }); continue; }
|
|
let bytes = 0, usedAt = stat.mtimeMs;
|
|
for (const child of await fs.readdir(directory)) {
|
|
const file = await fs.lstat(path.join(directory, child)).catch(() => null);
|
|
if (!file?.isFile()) continue;
|
|
bytes += file.size;
|
|
if (child === "receipt.json") usedAt = file.mtimeMs;
|
|
}
|
|
generations.push({ directory, workspace, bytes, usedAt, evictable });
|
|
}
|
|
}
|
|
return { workspaces, generations };
|
|
}
|
|
|
|
async function evictSeedGenerations(company: string, root: string, input: {
|
|
generation: string; incomingBytes: number; limits: SeedCacheLimits; eviction: SeedCacheEviction; now: number; apply: boolean;
|
|
}): Promise<boolean> {
|
|
const { workspaces: workspaceDirectories, generations } = await cachedGenerations(company, root, input.apply);
|
|
const remove = async (directory: string) => { if (input.apply) await removeCacheEntry(directory); };
|
|
const target = path.join(root, input.generation);
|
|
const live: CachedGeneration[] = [];
|
|
for (const entry of generations) {
|
|
if (entry.evictable && entry.directory !== target && entry.usedAt < input.now - input.eviction.idleMs) await remove(entry.directory);
|
|
else live.push(entry);
|
|
}
|
|
const over = () => {
|
|
const inWorkspace = live.filter(entry => entry.workspace === root);
|
|
const workspaces = new Set([...live.map(entry => entry.workspace), root]).size;
|
|
return {
|
|
workspace: inWorkspace.length + 1 > input.limits.workspaceGenerations
|
|
|| inWorkspace.reduce((sum, entry) => sum + entry.bytes, 0) + input.incomingBytes > input.limits.workspaceBytes,
|
|
company: live.length + 1 > input.limits.companyGenerations
|
|
|| live.reduce((sum, entry) => sum + entry.bytes, 0) + input.incomingBytes > input.limits.companyBytes
|
|
|| workspaces > input.limits.companyWorkspaces,
|
|
};
|
|
};
|
|
for (let pressure = over(); pressure.workspace || pressure.company; pressure = over()) {
|
|
const victim = live
|
|
.filter(entry => entry.evictable && entry.directory !== target && entry.usedAt < input.now - input.eviction.minIdleMs
|
|
&& (pressure.company || entry.workspace === root))
|
|
.sort((a, b) => a.usedAt - b.usedAt)[0];
|
|
if (!victim) return false;
|
|
await remove(victim.directory);
|
|
live.splice(live.indexOf(victim), 1);
|
|
}
|
|
if (input.apply && root !== company) {
|
|
for (const workspace of workspaceDirectories) {
|
|
if (workspace !== root && !live.some(entry => entry.workspace === workspace)) await fs.rmdir(workspace).catch(() => undefined);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function cacheUsage(root: string, limits: SeedCacheLimits): Promise<{ generations: number; bytes: number } | null> {
|
|
let generations = 0, bytes = 0;
|
|
for await (const entry of await fs.opendir(root)) {
|
|
if (++generations > limits.companyGenerations) return null;
|
|
const directory = path.join(root, entry.name);
|
|
const stat = await fs.lstat(directory);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
let files = 0;
|
|
for await (const child of await fs.opendir(directory)) {
|
|
if (++files > 3) return null;
|
|
const file = await fs.lstat(path.join(directory, child.name));
|
|
if (!file.isFile() || file.isSymbolicLink()) return null;
|
|
bytes += file.size;
|
|
if (bytes > limits.companyBytes) return null;
|
|
}
|
|
}
|
|
return { generations, bytes };
|
|
}
|
|
|
|
export async function publishWorkspaceSeedGeneration(
|
|
root: string, generation: string, seed: WorkspaceDurableSeedPaths,
|
|
options: { companyDirectory?: string; limits?: Partial<SeedCacheLimits>; eviction?: Partial<SeedCacheEviction>;
|
|
now?: () => number; verify?: () => Promise<boolean> } = {},
|
|
): Promise<boolean> {
|
|
const limits = { ...WORKSPACE_SEED_CACHE_LIMITS, ...options.limits };
|
|
const eviction = { ...WORKSPACE_SEED_CACHE_EVICTION, ...options.eviction };
|
|
if (!/^[a-f0-9]{64}$/.test(generation)
|
|
|| Object.values(limits).some(value => !Number.isSafeInteger(value) || value < 0)) return false;
|
|
root = path.resolve(root);
|
|
const company = path.resolve(options.companyDirectory ?? root);
|
|
if (root !== company && path.dirname(root) !== company) return false;
|
|
try {
|
|
await fs.mkdir(company, { recursive: true, mode: 0o700 });
|
|
const companyStat = await fs.lstat(company);
|
|
if (!companyStat.isDirectory() || companyStat.isSymbolicLink()) return false;
|
|
const capacity = async (): Promise<"existing" | "available" | false> => {
|
|
const currentCompany = await fs.lstat(company);
|
|
if (!currentCompany.isDirectory() || currentCompany.isSymbolicLink()
|
|
|| currentCompany.dev !== companyStat.dev || currentCompany.ino !== companyStat.ino) return false;
|
|
let totalGenerations = 0, totalBytes = 0, workspaces = 0;
|
|
let workspace = { generations: 0, bytes: 0 };
|
|
let workspaceExists = false;
|
|
const directories = root === company ? [{ name: "" }] : await fs.opendir(company);
|
|
for await (const { name } of directories) {
|
|
if (++workspaces > limits.companyWorkspaces) return false;
|
|
const directory = root === company ? company : path.join(company, name);
|
|
const stat = await fs.lstat(directory);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
const usage = await cacheUsage(directory, limits);
|
|
if (!usage) return false;
|
|
totalGenerations += usage.generations; totalBytes += usage.bytes;
|
|
if (totalGenerations > limits.companyGenerations || totalBytes > limits.companyBytes) return false;
|
|
if (directory === root) { workspace = usage; workspaceExists = true; }
|
|
}
|
|
if (workspaceExists && await fs.lstat(path.join(root, generation)).then(() => true, () => false)) return "existing";
|
|
let incomingBytes = 4096;
|
|
for (const archive of [seed.workspaceArchivePath, seed.gitArchivePath]) {
|
|
if (!archive) continue;
|
|
const stat = await fs.lstat(archive);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
incomingBytes += stat.size;
|
|
}
|
|
if (workspace.generations + 1 > limits.workspaceGenerations || workspace.bytes + incomingBytes > limits.workspaceBytes
|
|
|| totalGenerations + 1 > limits.companyGenerations || totalBytes + incomingBytes > limits.companyBytes
|
|
|| workspaces + (workspaceExists ? 0 : 1) > limits.companyWorkspaces) return false;
|
|
assertWorkspaceManifestDiskSpace(company, incomingBytes);
|
|
return "available";
|
|
};
|
|
const initialCapacity = await capacity();
|
|
if (initialCapacity === "existing") return true;
|
|
let incomingBytes = 4096;
|
|
for (const archive of [seed.workspaceArchivePath, seed.gitArchivePath]) if (archive) incomingBytes += (await fs.lstat(archive)).size;
|
|
const plan = { generation, incomingBytes, limits, eviction, now: options.now?.() ?? Date.now() };
|
|
if (initialCapacity === false && !await evictSeedGenerations(company, root, { ...plan, apply: false })) return false;
|
|
if (options.verify && !await options.verify()) return false;
|
|
return await withDirectoryMergeLock(company, async () => {
|
|
const lockedCompany = await fs.lstat(company);
|
|
if (!lockedCompany.isDirectory() || lockedCompany.isSymbolicLink()
|
|
|| lockedCompany.dev !== companyStat.dev || lockedCompany.ino !== companyStat.ino) return false;
|
|
await evictSeedGenerations(company, root, { ...plan, apply: true });
|
|
const currentCapacity = await capacity();
|
|
if (currentCapacity !== "available") return currentCapacity === "existing";
|
|
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
const temporary = path.join(root, `.pending-${randomUUID()}`);
|
|
await fs.mkdir(temporary, { mode: 0o700 });
|
|
try {
|
|
const copy = async (source: string, name: string) => {
|
|
const target = path.join(temporary, name);
|
|
await fs.link(source, target).catch(() => fs.copyFile(source, target));
|
|
const file = await fs.open(target, "r");
|
|
try { await file.sync(); } finally { await file.close(); }
|
|
return digest(target);
|
|
};
|
|
const workspaceSha256 = await copy(seed.workspaceArchivePath, "workspace.tar");
|
|
const gitSha256 = seed.gitArchivePath ? await copy(seed.gitArchivePath, "git.tar") : null;
|
|
await fs.writeFile(path.join(temporary, "receipt.json"), JSON.stringify({ version: 1, generation, workspaceSha256, gitSha256 }));
|
|
await fs.rename(temporary, path.join(root, generation));
|
|
return true;
|
|
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
|
});
|
|
} catch { return false; }
|
|
}
|