mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs can select saved skill versions. > - The server keeps those files in a cache that must protect active and unresolved runs. > - Older runs can lack the newer pin map even when their saved execution input records the full selection. > - Finished interrupted runs can also keep a company blocked after all execution and cleanup have settled. > - This pull request uses existing selection evidence and recognizes settled interruptions so safe cache expiration can resume. ## Linked Issues or Issue Description Related: #16433. **What happened?** Skill cache retention reports `unknown_run_pins` for a whole company when it finds a finished interrupted run without pins, or an older native run whose complete selection is stored only in its execution input. Old cache directories cannot expire. **Expected behavior** Release pins from fully settled interruptions. Preserve exact versions from valid saved native selections. Continue to block cleanup when selection evidence is missing, invalid, or belongs to another run. **Steps to reproduce** 1. Materialize an old, restorable skill version and let its idle grace period expire. 2. Add a finished interrupted run with settled ownership and cleanup, or an unresolved native run with a valid saved execution input but no modern pin map. 3. Run the retention service. Before this change, it skips the company with `unknown_run_pins`. ## What Changed - Treat `interrupted` as terminal only when the existing finish, controller, finalization, and environment cleanup checks allow it. - For native runs with no modern pin-map field, parse the saved execution input with the existing recovery parser. Validate its digests and company, run, agent, and issue binding. - Keep captured version IDs protected. Unversioned skills use separate captured asset bundles. Do not replace a malformed modern map or write any recovery records. - Add database regressions and document the fallback. Remove an unused status list that no longer represented the actual eligibility query. ## Verification - Passed 41 focused tests: `skill-version-cache.test.ts`, `skill-version-retention.test.ts`, and `skill-version-retention-config.test.ts`. - The tests perform real PostgreSQL reads, directory eviction, and restoration from saved bytes with upstream unavailable. They cover leases, unfinished cleanup, invalid digests, invalid versions, and mismatched identities. - Passed `node scripts/check-module-boundaries.mjs` and `git diff --check`. - A read-only evaluation of the candidate selector against existing legacy data cleared the unknown-pin blocker while retaining exact version pins. - Passed `pnpm -r --workspace-concurrency=2 typecheck` and `pnpm build`. The full remote CI suite passed, including all server, workspace, Runner, and end-to-end shards. The full local test command remains in progress; its focused cache suites passed as recorded above. - Greptile reviewed this head at 5/5 with no actionable comments or unresolved threads. - Initial local database attempts did not run the cases: sandbox access prevented a listener, then an install with scripts disabled lacked PostgreSQL library symlinks. After running the package's symlink setup, the binary started and all focused tests passed. ## Risks The main risk is releasing a cache still in use. The existing ownership and cleanup checks remain in force. The native fallback applies only when the modern field is absent and the saved input passes the same parser used for recovery. Invalid or missing evidence still blocks the company. Current versions and explicit agent pins stay protected. Saved version rows and runtime asset bundles are not deleted. There is no migration. The selector reads execution inputs only for legacy native runs that need the fallback. Normal runs still load only their pin maps and binding columns. A representative read-only selector check completed in 98 ms; this is a sample, not a latency guarantee. ## Model Used OpenAI Codex, GPT-6 family, with tool-assisted coding and verification. ## 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 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>
456 lines
23 KiB
TypeScript
456 lines
23 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { and, asc, eq, gt, gte, inArray, lte, sql } from "drizzle-orm";
|
|
import { agents, companies, companySkills, companySkillVersions, type Db } from "@paperclipai/db";
|
|
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { allocatedBytes, ensureSkillCacheDirectory, hasRestorableSnapshot, lastUsedAt,
|
|
protectedVersions, SkillCacheBusy, skillVersionCachePaths, touchSkillVersionCache,
|
|
withSkillVersionCacheLock } from "./skill-version-cache.js";
|
|
|
|
/**
|
|
* Retention for materialized skill version directories.
|
|
*
|
|
* `materializeVersionSnapshot` writes each selected skill version to
|
|
* `<instance>/skills/<companyId>/__versions__/<skillId>/<versionId>/` from the
|
|
* version row's stored file inventory. A directory is a cache: when it is
|
|
* removed while its row survives, the next resolve writes it again. Nothing
|
|
* removed these directories before, so they grew with every new version.
|
|
*
|
|
* The prune keeps a directory when it is the skill's current version, an agent
|
|
* pins it, a non-terminal heartbeat run captured it, it is among the newest
|
|
* versions of its skill (when configured), or it was materialized or reused within the grace
|
|
* window. It removes every other version directory, directories whose version
|
|
* or skill row is gone, and abandoned staging directories. Version rows are
|
|
* never deleted here.
|
|
*/
|
|
|
|
export const SKILL_VERSION_RETENTION_DEFAULT_KEEP_NEWEST = 0;
|
|
export const SKILL_VERSION_RETENTION_DEFAULT_GRACE_MS = 60 * 60 * 1000;
|
|
export const SKILL_VERSION_RETENTION_DEFAULT_MAX_REMOVALS = 50;
|
|
export const SKILL_VERSION_RETENTION_INTERVAL_MS = 60 * 60 * 1000;
|
|
export const SKILL_VERSION_RETENTION_STARTUP_DELAY_MS = 60 * 1000;
|
|
|
|
const VERSIONS_DIR = "__versions__";
|
|
const TRASH_PREFIX = ".trash-";
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
const QUERY_BATCH = 500;
|
|
|
|
// In-process coordination between materialization and pruning, keyed by the
|
|
// absolute version directory. A materialization waits for a pending removal of
|
|
// its directory; a prune skips a directory that a materialization is writing.
|
|
const versionSnapshotFlights = new Map<string, Promise<string>>();
|
|
const versionSnapshotRemovals = new Map<string, Promise<unknown>>();
|
|
// Trash directories this process is deleting right now; a concurrent prune skips them.
|
|
const activeTrash = new Set<string>();
|
|
|
|
export function resolveCompanySkillVersionsRoot(companyId: string) {
|
|
return path.resolve(resolvePaperclipInstanceRoot(), "skills", companyId, VERSIONS_DIR);
|
|
}
|
|
|
|
export function resolveSkillVersionSnapshotDir(companyId: string, skillId: string, versionId: string) {
|
|
return path.join(resolveCompanySkillVersionsRoot(companyId), skillId, versionId);
|
|
}
|
|
|
|
/**
|
|
* Runs one materialization per version directory at a time. Concurrent callers
|
|
* share the in-flight promise. A pending prune of the same directory finishes
|
|
* first, so a caller never receives a path that a prune is about to remove.
|
|
*/
|
|
export async function runVersionSnapshotFlight(skillDir: string, work: () => Promise<string>): Promise<string> {
|
|
for (;;) {
|
|
const removal = versionSnapshotRemovals.get(skillDir);
|
|
if (!removal) break;
|
|
await removal.catch(() => undefined);
|
|
}
|
|
const existing = versionSnapshotFlights.get(skillDir);
|
|
if (existing) return existing;
|
|
const flight = work();
|
|
versionSnapshotFlights.set(skillDir, flight);
|
|
try { return await flight; } finally {
|
|
if (versionSnapshotFlights.get(skillDir) === flight) versionSnapshotFlights.delete(skillDir);
|
|
}
|
|
}
|
|
|
|
function errorCode(error: unknown) {
|
|
return (error as NodeJS.ErrnoException | null)?.code;
|
|
}
|
|
|
|
async function makeTreeWritable(target: string): Promise<void> {
|
|
const stat = await fs.lstat(target).catch(() => null);
|
|
if (!stat || stat.isSymbolicLink()) return;
|
|
if (stat.isDirectory()) {
|
|
await fs.chmod(target, 0o700).catch(() => undefined);
|
|
const entries = await fs.readdir(target).catch(() => [] as string[]);
|
|
for (const entry of entries) await makeTreeWritable(path.join(target, entry));
|
|
} else {
|
|
await fs.chmod(target, 0o600).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a snapshot tree. Snapshot files are read-only, and some platforms
|
|
* refuse to unlink read-only entries, so a permission failure makes the tree
|
|
* writable and retries. Symbolic links are removed, never followed.
|
|
*/
|
|
async function removeSnapshotTree(target: string) {
|
|
if (activeTrash.has(target)) return;
|
|
activeTrash.add(target);
|
|
try {
|
|
await fs.rm(target, { recursive: true, force: true });
|
|
} catch (error) {
|
|
const code = errorCode(error);
|
|
if (code !== "EACCES" && code !== "EPERM" && code !== "ENOTEMPTY") throw error;
|
|
await makeTreeWritable(target);
|
|
await fs.rm(target, { recursive: true, force: true });
|
|
} finally {
|
|
activeTrash.delete(target);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves a directory out of its readable name in one atomic rename under the
|
|
* shared cache lock. Its unique trash name can be deleted after releasing it.
|
|
*/
|
|
async function removeVersionDirectory(parent: string, name: string): Promise<string | null> {
|
|
const target = path.join(parent, name);
|
|
if (path.dirname(target) !== parent) return null;
|
|
if (versionSnapshotFlights.has(target) || versionSnapshotRemovals.has(target)) return null;
|
|
const trash = path.join(parent, `${TRASH_PREFIX}${randomUUID()}`);
|
|
const rename = fs.rename(target, trash);
|
|
versionSnapshotRemovals.set(target, rename);
|
|
try {
|
|
await rename;
|
|
} catch (error) {
|
|
if (errorCode(error) === "ENOENT") return null;
|
|
throw error;
|
|
} finally {
|
|
versionSnapshotRemovals.delete(target);
|
|
}
|
|
return trash;
|
|
}
|
|
|
|
type CompanyCursor = { skillId: string; name: string };
|
|
export type SkillVersionRetentionCursor = CompanyCursor & { companyId: string };
|
|
export type SkillVersionPruneOptions = {
|
|
keepNewest?: number;
|
|
graceMs?: number;
|
|
maxRemovals?: number;
|
|
/** Bound inspection too, including protected directories. */
|
|
maxEntries?: number;
|
|
now?: Date;
|
|
dryRun?: boolean;
|
|
shouldContinue?: () => boolean;
|
|
};
|
|
export type SkillVersionPruneResult = {
|
|
checked: number; candidates: number; candidateBytes: number; reclaimedBytes: number;
|
|
removed: number; removedOrphans: number; removedStaging: number; kept: number;
|
|
skippedRecent: number; failed: number; truncated: boolean;
|
|
skipped: Record<string, number>;
|
|
cursor: CompanyCursor | null;
|
|
};
|
|
function emptyResult(): SkillVersionPruneResult {
|
|
return { checked: 0, candidates: 0, candidateBytes: 0, reclaimedBytes: 0,
|
|
removed: 0, removedOrphans: 0, removedStaging: 0, kept: 0,
|
|
skippedRecent: 0, failed: 0, truncated: false, skipped: {}, cursor: null };
|
|
}
|
|
|
|
type Candidate = { parent: string; skillId: string; name: string; unsafe?: boolean; discoveryOnly?: boolean };
|
|
|
|
// Retain directory handles between pages instead of re-reading/sorting N names
|
|
// for every 50-entry continuation. Every directory entry consumes a scan slot.
|
|
async function* discoverDirectories(root: string): AsyncGenerator<Candidate> {
|
|
await ensureSkillCacheDirectory(root);
|
|
const directory = await fs.opendir(root, { bufferSize: 1 });
|
|
try {
|
|
for (;;) {
|
|
const entry = await directory.read();
|
|
if (!entry) break;
|
|
if (!UUID_PATTERN.test(entry.name)) {
|
|
yield { parent: root, skillId: "", name: entry.name };
|
|
continue;
|
|
}
|
|
const parent = path.join(root, entry.name);
|
|
yield { parent, skillId: entry.name, name: "", discoveryOnly: true };
|
|
let childDirectory;
|
|
try {
|
|
await ensureSkillCacheDirectory(parent);
|
|
childDirectory = await fs.opendir(parent, { bufferSize: 1 });
|
|
} catch {
|
|
yield { parent, skillId: entry.name, name: "", unsafe: true };
|
|
continue;
|
|
}
|
|
try {
|
|
for (;;) {
|
|
const child = await childDirectory.read();
|
|
if (!child) break;
|
|
yield { parent, skillId: entry.name, name: child.name };
|
|
}
|
|
} finally { await childDirectory.close(); }
|
|
}
|
|
} finally { await directory.close(); }
|
|
}
|
|
|
|
function chunks<T>(values: T[], size: number): T[][] {
|
|
const out: T[][] = [];
|
|
for (let index = 0; index < values.length; index += size) out.push(values.slice(index, index + size));
|
|
return out;
|
|
}
|
|
|
|
export function skillVersionRetentionService(db: Db, defaults: SkillVersionPruneOptions = {}) {
|
|
const scans = new Map<string, { iterator: AsyncGenerator<Candidate>; cursor: CompanyCursor | null;
|
|
pending: Candidate[]; expiry: ReturnType<typeof setTimeout> }>();
|
|
const scanning = new Set<string>();
|
|
async function closeScan(companyId: string) {
|
|
const scan = scans.get(companyId);
|
|
if (!scan) return;
|
|
scans.delete(companyId);
|
|
clearTimeout(scan.expiry);
|
|
await scan.iterator.return(undefined);
|
|
}
|
|
async function dispose() { for (const id of [...scans.keys()]) await closeScan(id); }
|
|
|
|
async function protectedVersionIds(companyId: string, skillIds: string[], keepNewest: number) {
|
|
const keep = new Set<string>();
|
|
if (skillIds.length === 0) return keep;
|
|
for (const batch of chunks(skillIds, QUERY_BATCH)) {
|
|
const rows = await db.select({ currentVersionId: companySkills.currentVersionId }).from(companySkills)
|
|
.where(and(eq(companySkills.companyId, companyId), inArray(companySkills.id, batch)));
|
|
for (const row of rows) if (row.currentVersionId) keep.add(row.currentVersionId);
|
|
if (keepNewest > 0) {
|
|
const ranked = db.select({
|
|
id: companySkillVersions.id,
|
|
rank: sql<number>`row_number() over (partition by ${companySkillVersions.companySkillId} order by ${companySkillVersions.revisionNumber} desc)`.as("rank"),
|
|
}).from(companySkillVersions)
|
|
.where(and(eq(companySkillVersions.companyId, companyId), inArray(companySkillVersions.companySkillId, batch)))
|
|
.as("ranked");
|
|
for (const row of await db.select({ id: ranked.id }).from(ranked).where(lte(ranked.rank, keepNewest))) keep.add(row.id);
|
|
}
|
|
}
|
|
for (const agent of await db.select({ adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.companyId, companyId))) {
|
|
for (const entry of readPaperclipSkillSyncPreference(agent.adapterConfig ?? {}).desiredSkillEntries) {
|
|
if (entry.versionId) keep.add(entry.versionId);
|
|
}
|
|
}
|
|
const runVersions = await protectedVersions(db, companyId);
|
|
if (!runVersions) return null;
|
|
for (const version of runVersions) keep.add(version);
|
|
return keep;
|
|
}
|
|
|
|
async function pruneCompanyPage(companyId: string, options: SkillVersionPruneOptions & { cursor?: CompanyCursor | null } = {}): Promise<SkillVersionPruneResult> {
|
|
if (!UUID_PATTERN.test(companyId)) throw new Error("Invalid company ID for skill retention");
|
|
const keepNewest = Math.max(0, Math.floor(options.keepNewest ?? defaults.keepNewest ?? SKILL_VERSION_RETENTION_DEFAULT_KEEP_NEWEST));
|
|
const graceMs = Math.max(0, options.graceMs ?? defaults.graceMs ?? SKILL_VERSION_RETENTION_DEFAULT_GRACE_MS);
|
|
const maxRemovals = Math.min(50, Math.max(0, options.maxRemovals ?? defaults.maxRemovals ?? SKILL_VERSION_RETENTION_DEFAULT_MAX_REMOVALS));
|
|
const maxEntries = Math.min(maxRemovals, Math.max(1, options.maxEntries ?? defaults.maxEntries ?? 50));
|
|
const shouldContinue = options.shouldContinue ?? defaults.shouldContinue ?? (() => true);
|
|
const dryRun = options.dryRun ?? defaults.dryRun ?? false;
|
|
const now = (options.now ?? new Date()).getTime();
|
|
const result = emptyResult();
|
|
const skip = (reason: string) => { result.skipped[reason] = (result.skipped[reason] ?? 0) + 1; };
|
|
const trash: string[] = [];
|
|
const root = resolveCompanySkillVersionsRoot(companyId);
|
|
const trashName = (name: string) => name.startsWith(TRASH_PREFIX) && UUID_PATTERN.test(name.slice(TRASH_PREFIX.length));
|
|
let scan = scans.get(companyId);
|
|
if (!options.cursor || !scan || scan.cursor?.skillId !== options.cursor.skillId || scan.cursor?.name !== options.cursor.name) {
|
|
await closeScan(companyId);
|
|
// A service normally holds two descriptors for one company's continuation.
|
|
// Bound direct/internal callers too; abandoned cursors expire automatically.
|
|
if (scans.size >= 16) await closeScan(scans.keys().next().value!);
|
|
const expiry = setTimeout(() => { if (!scanning.has(companyId)) void closeScan(companyId).catch(() => {}); }, 5 * 60 * 1000);
|
|
expiry.unref();
|
|
scan = { iterator: discoverDirectories(root), cursor: null, pending: [], expiry };
|
|
scans.set(companyId, scan);
|
|
}
|
|
scan.expiry.refresh();
|
|
const entries: Candidate[] = [];
|
|
let discoveryDone = false;
|
|
try {
|
|
while (entries.length < maxEntries && shouldContinue()) {
|
|
const next = scan.pending.length ? { done: false, value: scan.pending.shift()! } : await scan.iterator.next();
|
|
if (next.done) { discoveryDone = true; break; }
|
|
entries.push(next.value);
|
|
}
|
|
} catch (error) {
|
|
await closeScan(companyId);
|
|
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return result;
|
|
result.checked++;
|
|
result.failed++;
|
|
skip("unsafe_or_unavailable");
|
|
logger.warn({ err: error, companyId }, "skill version retention skipped unsafe company root");
|
|
return result;
|
|
}
|
|
result.truncated = !discoveryDone;
|
|
if (entries.length === 0) { await closeScan(companyId); return result; }
|
|
const skillIds = [...new Set(entries.filter(entry => !entry.unsafe && entry.skillId).map(entry => entry.skillId))];
|
|
let initialKeep: Set<string> | null;
|
|
try { initialKeep = await withSkillVersionCacheLock(db, companyId, () => protectedVersionIds(companyId, skillIds, keepNewest), true); }
|
|
catch (error) {
|
|
result.checked = 1;
|
|
if (error instanceof SkillCacheBusy) skip("cache_busy");
|
|
else { result.failed++; skip("unavailable_pins"); }
|
|
await closeScan(companyId);
|
|
result.truncated = false;
|
|
return result;
|
|
}
|
|
if (!initialKeep) { await closeScan(companyId); result.truncated = false; result.checked = 1; skip("unknown_run_pins"); return result; }
|
|
const inspected: Array<{
|
|
entry: Candidate; isStaging: boolean; version: boolean;
|
|
before: { dev: number; ino: number }; bytes: number;
|
|
eligible: (keep: Set<string> | null) => Promise<{ dev: number; ino: number } | null>;
|
|
}> = [];
|
|
for (const entry of entries) {
|
|
if (!shouldContinue()) { scan.pending.push(...entries.slice(result.checked)); result.truncated = true; break; }
|
|
result.checked++;
|
|
result.cursor = { skillId: entry.skillId, name: entry.name };
|
|
if (entry.unsafe) { skip("unsafe_or_unavailable"); continue; }
|
|
if (entry.discoveryOnly) continue;
|
|
const target = path.join(entry.parent, entry.name);
|
|
const isTrash = trashName(entry.name);
|
|
const isVersion = UUID_PATTERN.test(entry.name);
|
|
const isStaging = entry.name.startsWith(".") && UUID_PATTERN.test(entry.name.slice(1, 37))
|
|
&& /^-[a-zA-Z0-9]{6}$/.test(entry.name.slice(37));
|
|
if (!isTrash && !isVersion && !isStaging) { skip("unknown_directory"); continue; }
|
|
if (isTrash) { if (!dryRun) trash.push(target); continue; }
|
|
// Called under the shared lock both before inspection and immediately
|
|
// before rename. A preparation between these calls changes the pins/use.
|
|
const eligible = async (keep: Set<string> | null) => {
|
|
if (!shouldContinue()) return null;
|
|
await ensureSkillCacheDirectory(target);
|
|
if (versionSnapshotFlights.has(target) || versionSnapshotRemovals.has(target)) { skip("cache_busy"); return null; }
|
|
if (!keep) { skip("unknown_run_pins"); return null; }
|
|
if (isVersion && keep.has(entry.name)) { result.kept++; skip("protected_version"); return null; }
|
|
const stat = await fs.lstat(target);
|
|
if (isVersion) {
|
|
const used = await lastUsedAt(skillVersionCachePaths(companyId, entry.skillId, entry.name).access);
|
|
if (used === null) {
|
|
if (!dryRun) await touchSkillVersionCache(companyId, entry.skillId, entry.name, now);
|
|
skip("observation_grace"); return null;
|
|
}
|
|
if (now - used < graceMs) { result.skippedRecent++; skip("recent_use"); return null; }
|
|
} else if (now - stat.mtimeMs < graceMs) { result.skippedRecent++; return null; }
|
|
return stat;
|
|
};
|
|
try {
|
|
const before = await withSkillVersionCacheLock(db, companyId, () => eligible(initialKeep), true);
|
|
if (!before) continue;
|
|
// Inventory transfer/validation and recursive file walks must not hold
|
|
// the lock needed by agent preparation. Immutable snapshots can be
|
|
// checked here; mutable current/run pins are checked again below.
|
|
const [version] = isVersion ? await db.select({ id: companySkillVersions.id }).from(companySkillVersions).where(and(
|
|
eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, entry.skillId),
|
|
eq(companySkillVersions.id, entry.name))) : [];
|
|
if (version && !await hasRestorableSnapshot(db, companyId, version.id)) { skip("snapshot_unavailable"); continue; }
|
|
const bytes = await allocatedBytes(target, shouldContinue);
|
|
inspected.push({ entry, isStaging, version: Boolean(version), before, bytes, eligible });
|
|
} catch (error) {
|
|
if (!shouldContinue()) { result.truncated = true; break; }
|
|
if (error instanceof SkillCacheBusy) skip("cache_busy");
|
|
else if (errorCode(error) === "ENOENT") skip("not_materialized");
|
|
else {
|
|
result.failed++;
|
|
skip("unsafe_or_unavailable");
|
|
logger.warn({ err: error, companyId }, "skill version cache candidate skipped");
|
|
}
|
|
}
|
|
}
|
|
if (inspected.length && shouldContinue()) {
|
|
try {
|
|
// Read company-wide agent/run pins once per phase, not once per cache.
|
|
// The lock keeps this final set authoritative through every rename.
|
|
await withSkillVersionCacheLock(db, companyId, async () => {
|
|
const keep = await protectedVersionIds(companyId, skillIds, keepNewest);
|
|
for (const candidate of inspected) {
|
|
if (!shouldContinue()) { result.truncated = true; break; }
|
|
try {
|
|
const current = await candidate.eligible(keep);
|
|
if (!current) continue;
|
|
if (current.dev !== candidate.before.dev || current.ino !== candidate.before.ino) { skip("cache_changed"); continue; }
|
|
result.candidates++;
|
|
result.candidateBytes += candidate.bytes;
|
|
if (dryRun) continue;
|
|
const renamed = await removeVersionDirectory(candidate.entry.parent, candidate.entry.name);
|
|
if (!renamed) continue;
|
|
trash.push(renamed);
|
|
result.removed++;
|
|
if (candidate.isStaging) result.removedStaging++;
|
|
else if (!candidate.version) result.removedOrphans++;
|
|
} catch (error) {
|
|
if (errorCode(error) === "ENOENT") { skip("not_materialized"); continue; }
|
|
result.failed++; skip("unsafe_or_unavailable");
|
|
logger.warn({ err: error, companyId }, "skill version cache rename skipped");
|
|
}
|
|
}
|
|
}, true);
|
|
} catch (error) {
|
|
if (error instanceof SkillCacheBusy) skip("cache_busy");
|
|
else { result.failed++; skip("unavailable_pins"); }
|
|
}
|
|
}
|
|
if (!result.truncated) { result.cursor = null; await closeScan(companyId); }
|
|
else scan.cursor = result.cursor;
|
|
// These unique names are never reused by materialization. A crash leaves
|
|
// them for the next scan; a returning run can rehydrate immediately.
|
|
for (const target of trash) {
|
|
if (!shouldContinue()) break;
|
|
try {
|
|
await withSkillVersionCacheLock(db, `trash:${companyId}:${target}`, async () => {
|
|
await ensureSkillCacheDirectory(target);
|
|
const bytes = await allocatedBytes(target, shouldContinue);
|
|
if (!shouldContinue()) return;
|
|
await removeSnapshotTree(target);
|
|
result.reclaimedBytes += bytes;
|
|
}, true);
|
|
} catch (error) {
|
|
if (!shouldContinue()) break;
|
|
if (error instanceof SkillCacheBusy || errorCode(error) === "ENOENT") continue;
|
|
result.failed++;
|
|
logger.warn({ err: error, companyId }, "skill version snapshot trash removal failed");
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function pruneCompany(companyId: string, options: SkillVersionPruneOptions & { cursor?: CompanyCursor | null } = {}) {
|
|
if (scanning.has(companyId)) return { ...emptyResult(), checked: 1, skipped: { scan_busy: 1 } };
|
|
scanning.add(companyId);
|
|
try { return await pruneCompanyPage(companyId, options); }
|
|
finally { scanning.delete(companyId); }
|
|
}
|
|
|
|
// A continuation visits later companies and protected directories too; a busy
|
|
// first company or a dry run cannot starve the remainder of the instance.
|
|
let cursor: SkillVersionRetentionCursor | null = null;
|
|
async function tick(options: SkillVersionPruneOptions = {}) {
|
|
const maxRemovals = Math.min(50, options.maxRemovals ?? defaults.maxRemovals ?? SKILL_VERSION_RETENTION_DEFAULT_MAX_REMOVALS);
|
|
const maxEntries = Math.min(maxRemovals, options.maxEntries ?? defaults.maxEntries ?? 50);
|
|
const shouldContinue = options.shouldContinue ?? defaults.shouldContinue ?? (() => true);
|
|
const totals = { ...emptyResult(), companies: 0, cursor: null as SkillVersionRetentionCursor | null };
|
|
const companyRows = await db.select({ id: companies.id }).from(companies)
|
|
.where(cursor ? cursor.skillId === "\uffff" ? gt(companies.id, cursor.companyId) : gte(companies.id, cursor.companyId) : undefined)
|
|
.orderBy(asc(companies.id)).limit(51);
|
|
for (const company of companyRows) {
|
|
if (cursor && (company.id < cursor.companyId || (company.id === cursor.companyId && cursor.skillId === "\uffff"))) continue;
|
|
if (totals.companies >= 50 || totals.checked >= maxEntries || totals.candidates >= maxRemovals || !shouldContinue()) { totals.truncated = true; break; }
|
|
const result = await pruneCompany(company.id, { ...options, cursor: cursor?.companyId === company.id ? cursor : null,
|
|
maxEntries: maxEntries - totals.checked, maxRemovals: maxRemovals - totals.candidates, shouldContinue });
|
|
totals.companies++;
|
|
for (const key of ["checked", "candidates", "candidateBytes", "reclaimedBytes", "removed", "removedOrphans", "removedStaging", "kept", "skippedRecent", "failed"] as const) totals[key] += result[key];
|
|
for (const [reason, count] of Object.entries(result.skipped)) totals.skipped[reason] = (totals.skipped[reason] ?? 0) + count;
|
|
cursor = { companyId: company.id, skillId: "\uffff", name: "" };
|
|
if (result.truncated) {
|
|
cursor = { companyId: company.id, ...(result.cursor ?? { skillId: "", name: "" }) };
|
|
totals.truncated = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!totals.truncated) cursor = null;
|
|
totals.cursor = cursor;
|
|
return totals;
|
|
}
|
|
return { pruneCompany, tick, dispose };
|
|
}
|