Files
PaperClipAI/server/src/services/skill-version-cache.ts
T
DottaandPaperclip 06c5bc471d fix(skills): unblock retention for legacy run selections (#16852)
## 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>
2026-10-19 10:10:49 -05:00

195 lines
10 KiB
TypeScript

import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { constants, promises as fs } from "node:fs";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { companySkillVersions, companySkillRepositorySnapshots, heartbeatRuns,
nativeRunFinalizations, environmentLeases, withDedicatedDbConnection, type Db } from "@paperclipai/db";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { assertSkillSnapshotPath } from "./skill-snapshot.js";
import { repositorySkillFrontmatter } from "./skill-repository-package.js";
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js";
export const SKILL_VERSION_CACHE_IDLE_MS = 60 * 60 * 1000;
const locks = new AsyncLocalStorage<Array<{ db: Db; companyId: string }>>();
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
export class SkillCacheBusy extends Error {}
// Repository snapshots are immutable and referenced with restrictive foreign
// keys. Cache only validated entrypoint names, never large file contents, so an
// hourly sweep does not transfer the same repository for each skill/version.
const verifiedSnapshots = new WeakMap<Db, Map<string, ReadonlySet<string>>>();
/** Hold through publication AND durable run pinning, not just file creation. */
export async function withSkillVersionCacheLock<T>(db: Db, companyId: string, action: () => Promise<T>, tryOnly = false): Promise<T> {
const held = locks.getStore() ?? [];
if (held.some(lock => lock.db === db && lock.companyId === companyId)) return action();
// Preparation uses the ordinary pool for version/pin writes. Borrowing a pool
// connection for this lock would deadlock installations with a one-slot pool.
return withDedicatedDbConnection(db, connection => connection.transaction(async tx => {
const key = `skill_version_cache:${companyId}`;
if (tryOnly) {
const rows = await tx.execute<{ acquired: boolean }>(sql`select pg_try_advisory_xact_lock(hashtextextended(${key}, 0)) as acquired`);
if (!rows[0]?.acquired) throw new SkillCacheBusy();
} else await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
return locks.run([...held, { db, companyId }], action);
}));
}
export function skillVersionCachePaths(companyId: string, skillId: string, versionId: string) {
if (![companyId, skillId, versionId].every(value => uuid.test(value))) throw new Error("Invalid skill cache identity");
const root = path.join(resolvePaperclipInstanceRoot(), "skills", companyId);
return {
root,
directory: path.join(root, "__versions__", skillId, versionId),
access: path.join(root, "__version_access__", skillId, `${versionId}.json`),
};
}
/** Never traverse a symlink below the configured instance root. */
export async function ensureSkillCacheDirectory(directory: string, create = false) {
const root = path.resolve(resolvePaperclipInstanceRoot());
const relative = path.relative(root, directory);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Skill cache escaped instance root");
if (create) await fs.mkdir(root, { recursive: true });
let cursor = root;
for (const part of relative.split(path.sep)) {
cursor = path.join(cursor, part);
if (create) await fs.mkdir(cursor).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST") throw error;
});
const stat = await fs.lstat(cursor);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe skill cache directory");
}
}
export async function touchSkillVersionCache(companyId: string, skillId: string, versionId: string, now = Date.now()) {
const { access } = skillVersionCachePaths(companyId, skillId, versionId);
await ensureSkillCacheDirectory(path.dirname(access), true);
const temporary = `${access}.${randomUUID()}.tmp`;
try {
await fs.writeFile(temporary, JSON.stringify({ version: 1, lastUsedAt: now }), { flag: "wx", mode: 0o600 });
await fs.rename(temporary, access);
} finally { await fs.rm(temporary, { force: true }); }
}
export async function lastUsedAt(filename: string): Promise<number | null> {
try {
await ensureSkillCacheDirectory(path.dirname(filename));
const file = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > 256) return null;
const value = JSON.parse(await file.readFile("utf8"));
return value.version === 1 && Number.isSafeInteger(value.lastUsedAt) && value.lastUsedAt > 0 ? value.lastUsedAt : null;
} finally { await file.close(); }
} catch (error) {
if (error instanceof SyntaxError || ["ENOENT", "ELOOP"].includes((error as NodeJS.ErrnoException).code ?? "")) return null;
throw error;
}
}
// A terminal run can still own provider execution or recovery. Only fully settled
// runs release their pins; resume preparation reacquires the same cache lock.
export async function protectedVersions(db: Db, companyId: string): Promise<Set<string> | null> {
const rows = await db.select({
id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, nativeIssueId: heartbeatRuns.nativeIssueId,
pins: sql<unknown>`${heartbeatRuns.contextSnapshot}->'skillVersions'`,
// Avoid loading large execution inputs for runs with the modern pin map.
legacyInput: sql<unknown>`case when ${heartbeatRuns.runtimeMode} = 'native'
and not coalesce(${heartbeatRuns.contextSnapshot} ? 'skillVersions', false)
then ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput' end`,
})
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), sql`(
${heartbeatRuns.status} not in ('succeeded', 'failed', 'timed_out', 'cancelled', 'interrupted')
or ${heartbeatRuns.finishedAt} is null
or ${heartbeatRuns.controllerLeaseExpiresAt} > now()
or exists (select 1 from ${nativeRunFinalizations} f
where f.company_id = ${heartbeatRuns.companyId} and f.run_id = ${heartbeatRuns.id}
and (f.phase <> 'committed' or f.lease_owner is not null or f.recovery_state is not null))
or exists (select 1 from ${environmentLeases} l
where l.company_id = ${heartbeatRuns.companyId} and l.heartbeat_run_id = ${heartbeatRuns.id}
and (l.status = 'pending_cleanup' or l.cleanup_status = 'failed'
or l.metadata ? 'nativeWorkspaceExportResume'
or (l.released_at is null and not coalesce(l.status = 'retained'
and l.lease_policy = 'reuse_by_environment' and l.cleanup_status = 'success', false))))
)`));
const versions = new Set<string>();
for (const { pins, legacyInput, id, agentId, nativeIssueId } of rows) {
if (legacyInput != null) {
// Older native runs captured the complete selection in their immutable
// execution input before contextSnapshot.skillVersions was introduced.
// Use the recovery parser (including digest validation) and the exact run
// binding. Never repair a malformed modern pin map with a different source.
try {
const input = parseNativeExecutionInput(legacyInput);
if (!("runtimeContext" in input) || input.binding.companyId !== companyId
|| input.binding.runId !== id || input.binding.agentId !== agentId
|| input.binding.issueId !== nativeIssueId) return null;
for (const skill of input.runtimeContext.skills) {
// Unversioned skills have their own captured asset bundle; they do
// not reference a directory under __versions__.
if (skill.versionId === null) continue;
if (!uuid.test(skill.versionId)) return null;
versions.add(skill.versionId);
}
continue;
} catch { return null; }
}
// Legacy/incomplete preparation is not proof that no skill is in use.
if (!pins || typeof pins !== "object" || Array.isArray(pins)) return null;
for (const version of Object.values(pins)) {
if (typeof version !== "string" || !uuid.test(version)) return null;
versions.add(version);
}
}
return versions;
}
function validateInventory(inventory: CompanySkillVersionFileInventoryEntry[]) {
const seen = new Set<string>();
for (const file of inventory) {
assertSkillSnapshotPath(file.path);
if (seen.has(file.path) || typeof file.content !== "string") return false;
seen.add(file.path);
}
return true;
}
export async function hasRestorableSnapshot(db: Db, companyId: string, versionId: string) {
const [version] = await db.select().from(companySkillVersions).where(and(
eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.id, versionId)));
if (!version) return false;
if (version.repositorySnapshotId) {
if (!version.repositorySkillPath) return false;
let cache = verifiedSnapshots.get(db);
if (!cache) { cache = new Map(); verifiedSnapshots.set(db, cache); }
const key = `${companyId}:${version.repositorySnapshotId}`;
const verified = cache.get(key);
if (verified) return verified.has(version.repositorySkillPath);
const [snapshot] = await db.select({ inventory: companySkillRepositorySnapshots.fileInventory })
.from(companySkillRepositorySnapshots).where(and(eq(companySkillRepositorySnapshots.companyId, companyId),
eq(companySkillRepositorySnapshots.id, version.repositorySnapshotId)));
if (!snapshot || !validateInventory(snapshot.inventory)) return false;
const entrypoints = new Set(snapshot.inventory.filter(file => file.encoding !== "base64"
&& repositorySkillFrontmatter(file.content)).map(file => file.path));
if (cache.size >= 128) cache.delete(cache.keys().next().value!);
cache.set(key, entrypoints);
return entrypoints.has(version.repositorySkillPath);
}
return validateInventory(version.fileInventory) && version.fileInventory.some(file => file.path === "SKILL.md");
}
// Counts allocated bytes without following links, matching what df can reclaim.
export async function allocatedBytes(directory: string, shouldContinue: () => boolean = () => true): Promise<number> {
if (!shouldContinue()) throw new Error("Skill cache scan stopped");
const stat = await fs.lstat(directory);
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error("Unsafe skill cache entry");
let bytes = stat.blocks * 512;
if (stat.isDirectory()) {
for (const entry of await fs.readdir(directory)) bytes += await allocatedBytes(path.join(directory, entry), shouldContinue);
}
return bytes;
}