From 188bb14ba0fd8f8496897d4cea7ced1c30bba196 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 23 Oct 2026 14:05:34 -0700 Subject: [PATCH] fix: evict stale workspace seed generations instead of freezing (#17357) ## 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//`) 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 837add0caa0. **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-` 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 --- .../src/sandbox-managed-runtime.ts | 3 +- .../src/workspace-seed-cache.test.ts | 132 +++++++++++++++++- .../adapter-utils/src/workspace-seed-cache.ts | 105 +++++++++++++- 3 files changed, 235 insertions(+), 5 deletions(-) diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 0997fb87fb8..0a034cf931f 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -1419,7 +1419,8 @@ export async function prepareSandboxManagedRuntime(input: { const seedGeneration = input.workspaceSeedCacheDirectory && baselineSnapshot && workspaceInboundMode !== "durable_seed" ? await runStepSpan("workspace.seed_generation", async () => workspaceSeedGeneration(baselineSnapshot!, gitSnapshot, repositories)) : null; const cachedSeed = seedGeneration - ? await runStepSpan("workspace.seed_lookup", () => readWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration)) : null; + ? await runStepSpan("workspace.seed_lookup", () => readWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration, + { companyDirectory: input.workspaceSeedCacheCompanyDirectory })) : null; const replaySeed = workspaceInboundMode === "durable_seed" ? input.workspaceDurableSeed : cachedSeed; // Every inbound staging step delegates to the provider through `client.syncIn`: diff --git a/packages/adapter-utils/src/workspace-seed-cache.test.ts b/packages/adapter-utils/src/workspace-seed-cache.test.ts index 4dc7f99e345..e816b420e46 100644 --- a/packages/adapter-utils/src/workspace-seed-cache.test.ts +++ b/packages/adapter-utils/src/workspace-seed-cache.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import nodeFs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { publishWorkspaceSeedGeneration, readWorkspaceSeedGeneration } from "./workspace-seed-cache.js"; function deferred() { @@ -163,3 +163,133 @@ it("rejects a company directory replaced during verification", async () => { expect(await fs.readdir(replacement)).toEqual([]); expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes"); }); + +describe("eviction", () => { + const DAY = 24 * 60 * 60 * 1000; + async function lastUsed(directory: string, ms: number) { + await fs.utimes(path.join(directory, "receipt.json"), new Date(ms), new Date(ms)); + } + + it("expires generations unused for the idle period when publishing, and removes emptied workspaces", async () => { + const f = await fixture(); const now = Date.now(); const options = { companyDirectory: f.company, now: () => now }; + const other = path.join(f.company, "other"); + expect(await publishWorkspaceSeedGeneration(other, generation(1), f.seed, options)).toBe(true); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true); + await lastUsed(path.join(other, generation(1)), now - 8 * DAY); + await lastUsed(path.join(f.workspace, generation(2)), now - 6 * DAY); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, options)).toBe(true); + expect((await fs.readdir(f.company)).sort()).toEqual(["workspace"]); + expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(2), generation(3)]); + expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes"); + }); + + it("counts a cache hit as use, so a recently read generation is not expired", async () => { + const f = await fixture(); const options = { companyDirectory: f.company }; + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true); + await lastUsed(path.join(f.workspace, generation(1)), Date.now() - 8 * DAY); + expect(await readWorkspaceSeedGeneration(f.workspace, generation(1))).not.toBeNull(); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true); + expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(1), generation(2)]); + }); + + it("evicts the least recently used generation of the workspace when it is full", async () => { + const f = await fixture(); const now = Date.now(); + const options = { companyDirectory: f.company, limits: { workspaceGenerations: 2 }, now: () => now }; + for (const index of [1, 2]) expect(await publishWorkspaceSeedGeneration(f.workspace, generation(index), f.seed, options)).toBe(true); + await lastUsed(path.join(f.workspace, generation(1)), now - 3 * 60 * 60 * 1000); + await lastUsed(path.join(f.workspace, generation(2)), now - 2 * 60 * 60 * 1000); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, options)).toBe(true); + expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(2), generation(3)]); + }); + + it("evicts across workspaces under company pressure but never a generation used within the last hour", async () => { + const f = await fixture(); const now = Date.now(); + const options = { companyDirectory: f.company, limits: { companyGenerations: 2 }, now: () => now }; + const other = path.join(f.company, "other"); + expect(await publishWorkspaceSeedGeneration(other, generation(1), f.seed, options)).toBe(true); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, options)).toBe(false); + await lastUsed(path.join(other, generation(1)), now - 2 * 60 * 60 * 1000); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, options)).toBe(true); + expect((await fs.readdir(f.company)).sort()).toEqual(["workspace"]); + expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(2), generation(3)]); + }); + + it("runs optional verification only when eviction can make room", async () => { + const f = await fixture(); const now = Date.now(); const verify = vi.fn(async () => true); + const options = { companyDirectory: f.company, limits: { workspaceGenerations: 1 }, now: () => now, verify }; + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true); + verify.mockClear(); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(false); + expect(verify).not.toHaveBeenCalled(); + await lastUsed(path.join(f.workspace, generation(1)), now - 2 * 60 * 60 * 1000); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true); + expect(verify).toHaveBeenCalledTimes(1); + expect(await fs.readdir(f.workspace)).toEqual([generation(2)]); + }); + + it("never evicts abandoned pending directories and skips verification when only they fill the cache", async () => { + const f = await fixture(); const now = Date.now(); const verify = vi.fn(async () => true); + await fs.mkdir(path.join(f.workspace, ".pending-old"), { recursive: true }); + await fs.utimes(path.join(f.workspace, ".pending-old"), new Date(now - 30 * DAY), new Date(now - 30 * DAY)); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, { + companyDirectory: f.company, limits: { workspaceGenerations: 1 }, now: () => now, verify, + })).toBe(false); + expect(verify).not.toHaveBeenCalled(); + expect(await fs.readdir(f.workspace)).toEqual([".pending-old"]); + }); + + it("never returns a cache hit that a concurrent eviction removes", async () => { + const f = await fixture(); const now = Date.now(); + const options = { companyDirectory: f.company, limits: { workspaceGenerations: 1 }, now: () => now }; + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true); + await lastUsed(path.join(f.workspace, generation(1)), now - 2 * 60 * 60 * 1000); + const paused = deferred(), release = deferred(); + const rename = fs.rename.bind(fs); + vi.spyOn(fs, "rename").mockImplementation(async (from, to) => { + if (String(to).includes(".evicted-")) { paused.resolve(); await release.promise; } + return rename(from, to); + }); + const publishing = publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options); + await paused.promise; + const reading = readWorkspaceSeedGeneration(f.workspace, generation(1), { companyDirectory: f.company }); + await new Promise(resolve => setTimeout(resolve, 200)); + release.resolve(); + const [hit, published] = await Promise.all([reading, publishing]); + expect(published).toBe(true); + if (hit) expect(await fs.readFile(hit.workspaceArchivePath, "utf8")).toBe("run recovery bytes"); + else await expect(fs.access(path.join(f.workspace, generation(1)))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("does not evict from a company directory replaced during verification", async () => { + const f = await fixture(); const now = Date.now(); + const options = { companyDirectory: f.company, limits: { workspaceGenerations: 1 }, now: () => now }; + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true); + await lastUsed(path.join(f.workspace, generation(1)), now - 8 * DAY); + const replacement = path.join(f.root, "other-company"); + await fs.cp(f.company, replacement, { recursive: true, preserveTimestamps: true }); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, { ...options, verify: async () => { + await fs.rename(f.company, `${f.company}-retained`); + await fs.symlink(replacement, f.company); + return true; + } })).toBe(false); + expect(await fs.readdir(path.join(replacement, "workspace"))).toEqual([generation(1)]); + }); + + it("removes empty workspace directories so they do not hold workspace slots", async () => { + const f = await fixture(); await fs.mkdir(path.join(f.company, "abandoned"), { recursive: true }); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, { + companyDirectory: f.company, limits: { companyWorkspaces: 1 }, + })).toBe(true); + expect(await fs.readdir(f.company)).toEqual(["workspace"]); + }); + + it("removes leftovers of an interrupted eviction", async () => { + const f = await fixture(); const options = { companyDirectory: f.company }; + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true); + await fs.mkdir(path.join(f.workspace, ".evicted-leftover")); + await fs.writeFile(path.join(f.workspace, ".evicted-leftover", "workspace.tar"), "stale"); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true); + expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(1), generation(2)]); + }); +}); diff --git a/packages/adapter-utils/src/workspace-seed-cache.ts b/packages/adapter-utils/src/workspace-seed-cache.ts index c26c23f7626..003f1640c6c 100644 --- a/packages/adapter-utils/src/workspace-seed-cache.ts +++ b/packages/adapter-utils/src/workspace-seed-cache.ts @@ -20,7 +20,8 @@ async function digest(file: string): Promise { return hash.digest("hex"); } -export async function readWorkspaceSeedGeneration(root: string, generation: string): Promise { +export async function readWorkspaceSeedGeneration(root: string, generation: string, + options: { companyDirectory?: string } = {}): Promise { 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; } @@ -37,6 +38,14 @@ export async function readWorkspaceSeedGeneration(root: string, generation: stri 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; } @@ -50,6 +59,86 @@ export const WORKSPACE_SEED_CACHE_LIMITS = Object.freeze({ }); 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 { + 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)) { @@ -71,9 +160,11 @@ async function cacheUsage(root: string, limits: SeedCacheLimits): Promise<{ gene export async function publishWorkspaceSeedGeneration( root: string, generation: string, seed: WorkspaceDurableSeedPaths, - options: { companyDirectory?: string; limits?: Partial; verify?: () => Promise } = {}, + options: { companyDirectory?: string; limits?: Partial; eviction?: Partial; + now?: () => number; verify?: () => Promise } = {}, ): Promise { 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); @@ -117,9 +208,17 @@ export async function publishWorkspaceSeedGeneration( return "available"; }; const initialCapacity = await capacity(); - if (initialCapacity !== "available") return initialCapacity === "existing"; + 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 });