Files
PaperClipAI/server/src/services/skill-installation-maintenance.ts
T
DottaandPaperclip c0550e7010 fix(server): preserve sessions and defer busy overlap recovery (#18552)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A web server replacement must preserve remote execution and later
task continuation.
> - The active-run handoff now survives deletion of the departing server
home.
> - The next admission still compared disposable instruction and skill
paths as configuration.
> - A native receipt could also settle without the old adapter returning
its terminal metadata.
> - A replacement can also collide with a run row locked by the serving
controller.
> - This change preserves durable session identity and terminal
evidence, and defers locked recovery work so overlap can reach
readiness.

## Linked Issues or Issue Description

Refs #18543, #18366, #9977, #18118.

**What happened?**

A real Codex/Daytona journey replaced both control-plane servers and
deleted their homes. Reads, saves, saved files and active execution
passed. A later useful answer used a new provider session. All four run
records lacked the native terminal contract. The whole qualification
therefore failed. A later attempt also exposed startup failure: the
replacement waited for a row lock held by the serving controller and
aborted on its one-second lock timeout.

**Expected behavior**

An unchanged managed instruction entry and immutable skill version
retain the session across server cache relocation. Actual content and
configuration changes still reset it. A committed native receipt retains
its terminal contract even when the originating adapter never returns. A
busy recovery row defers through the existing scheduler while the
replacement reaches readiness.

**Steps to reproduce**

Run the `rolling-web-deployment.runner-codex.daytona.recover-controller`
Product E2E cell with separate controller homes and shared
PostgreSQL/S3. The focused regressions reproduce the failures without a
model call.

## What Changed

- Read the managed instruction entry digest from the company-scoped
durable file head. Do not restore or read the cache for this check.
- Omit disposable instruction paths only when that durable authority
exists. Fingerprint selected immutable skill versions in both runtime
skill inputs.
- Preserve the existing local and external instruction path checks, and
unversioned skill identity.
- Publish the accepted native terminal contract in the same transaction
as the canonical result receipt.
- Skip locked recovery rows and schedule the same run for retry. Avoid
an audit write that would block on the same row. No ownership is
transferred while busy.
- Keep replacement readiness independent of an in-progress skill
selection; migration and cache eviction still require exact pins.
Missing durable skill versions still refuse startup.
- Project only the instruction digest in SQL instead of loading the
entire agent-file manifest per admission.
- Add real PostgreSQL lock contention tests for the issue, run and
coordinator, including the heartbeat startup caller and later claim.
- Limit native test teardown to its owned database on a shared test
host. The old behavior also fails on clean master.
- Add cache-loss, scope, changed-content, changed-version and
finalization regressions. Update the deployment qualification record.

## Verification

- Both focused regressions failed before the source changes.
- All 249 tests pass across `heartbeat-workspace-session`,
`agent-file-durability`, and `paperclip-control-plane-port`.
- Server typecheck and exact-revision build pass.
- Assembled admission ran 59 passing cases but failed during
native-suite teardown. The teardown failure reproduced on clean master.
All 16 native resumption cases and teardown pass after the
database-scope fix.
- All three row-lock regressions fail with the pre-fix lock timeout and
pass with deferred recovery. The full recovery suite passes 74 cases,
including the startup caller; eight Docker-specific cases were not
enabled.
- The real attempt at `d2165dec5d` failed when B aborted on the run
lock. Its credential and bucket were removed; all three provider
sandboxes were independently confirmed absent. The next real attempt at
`aae8a53d01` failed a different readiness gate while a run was preparing
skill pins. That race now has a failing-before regression; all 33
readiness, durable-file and managed-skill tests pass after the fix. Its
owned resources are cleaned. The `a15a12651c` live attempt passed both
web replacements: 765 reads, 78 saves and 78 verified saves with zero
HTTP probe failures. The first three turns preserved the provider
session and native terminal contract. The fourth continuation failed
because a safely suspended remote Runner was treated as live/idle; that
distinct recovery gap is being handled in a follow-up. All three owned
sandboxes were independently confirmed absent. No claim of full Railway
qualification yet.

## Risks

- The new fingerprint shape may reset a session once when upgrading from
older fingerprint metadata. It is stable across subsequent cache
replacements.
- A versioned skill is identified by its immutable selected version;
materialization and admission still enforce availability. Unversioned
skills retain their existing path boundary.
- A native terminal describes the accepted execution result. Workspace
and issue settlement remain separate authorities.
- This does not yet prove the ten-deploy staging campaign, natural
database autosuspension or the production canary.

## Model Used

OpenAI GPT-6 Codex, with reasoning, repository editing, shell execution
and live integration-test tools. The exact runtime model variant and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-01 09:14:34 -06:00

240 lines
16 KiB
TypeScript

import { constants, promises as fs } from "node:fs";
import path from "node:path";
import { and, asc, eq, gt, sql } from "drizzle-orm";
import { companySkills, companySkillVersions, type Db } from "@paperclipai/db";
import { parseFrontmatterMarkdown, type CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
import { conflict } from "../errors.js";
import { classifyInventoryKind, companySkillService } from "./company-skills.js";
import { managedSkillContent } from "./managed-skill-content.js";
import { skillPackageStore, skillVersionMetadataColumns } from "./skill-package-store.js";
import { skillPackageMaintenance } from "./skill-package-maintenance.js";
import { skillPackageManifest } from "./skill-package-files.js";
import { assertSkillSnapshotPath, MAX_SKILL_SNAPSHOT_BYTES, MAX_SKILL_SNAPSHOT_FILES, skillSnapshotHash, snapshotFile } from "./skill-snapshot.js";
import { protectedVersions, withSkillVersionCacheLock } from "./skill-version-cache.js";
import { getStorageService, type StorageService } from "../storage/index.js";
type Page = { companyId?: string; after?: string; id?: string; limit?: number; signal?: AbortSignal };
type Conversion = Page & { apply?: boolean; writersStopped?: boolean };
const limit = (options: Page) => Math.min(50, Math.max(1, options.limit ?? 10));
const legacyManaged = (skill: typeof companySkills.$inferSelect) => skill.sourceType === "local_path" && skill.metadata?.sourceKind === "managed_local";
const linkedLocal = (skill: typeof companySkills.$inferSelect) => skill.sourceType === "local_path"
&& !legacyManaged(skill) && skill.metadata?.sourceKind !== "paperclip_bundled";
function coversInstalledInventory(skill: typeof companySkills.$inferSelect, files: Array<{ path: string }>) {
const paths = new Set(files.map(file => file.path));
return paths.has("SKILL.md") && skill.fileInventory.every(file => typeof file.path === "string" && paths.has(file.path));
}
async function legacyFiles(skill: typeof companySkills.$inferSelect, signal?: AbortSignal) {
if (!skill.sourceLocator || !path.isAbsolute(skill.sourceLocator)) return null;
const source = path.resolve(skill.sourceLocator);
const root = path.basename(source) === "SKILL.md" ? path.dirname(source) : source;
const stat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!stat) return null;
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Legacy skill root is not a safe directory");
const files: CompanySkillVersionFileInventoryEntry[] = [];
let bytes = 0;
async function visit(directory: string) {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
signal?.throwIfAborted();
if (entry.name === ".git" || entry.name === "node_modules") continue;
const absolute = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error("Legacy skill contains a symbolic link");
if (entry.isDirectory()) { await visit(absolute); continue; }
if (!entry.isFile()) throw new Error("Legacy skill contains an unsupported file");
const relative = assertSkillSnapshotPath(path.relative(root, absolute).split(path.sep).join("/"));
const handle = await fs.open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await handle.stat();
if (!stat.isFile()) throw new Error("Legacy skill contains an unsupported file");
bytes += stat.size;
if (files.length >= MAX_SKILL_SNAPSHOT_FILES || bytes > MAX_SKILL_SNAPSHOT_BYTES) throw new Error("Legacy skill exceeds package limits");
const body = Buffer.alloc(stat.size);
let offset = 0;
while (offset < body.length) {
signal?.throwIfAborted();
const { bytesRead } = await handle.read(body, offset, body.length - offset, offset);
if (!bytesRead) throw new Error("Legacy skill changed during conversion");
offset += bytesRead;
}
const after = await handle.stat();
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) throw new Error("Legacy skill changed during conversion");
files.push(snapshotFile(relative, classifyInventoryKind(relative), body, Boolean(stat.mode & 0o111)));
} finally { await handle.close(); }
}
}
await visit(root);
if (!files.some(file => file.path === "SKILL.md" && file.encoding !== "base64")) throw new Error("Legacy skill is missing UTF-8 SKILL.md");
return files;
}
export function skillInstallationMaintenance(db: Db, storage?: StorageService) {
const store = skillPackageStore(db, storage);
const content = managedSkillContent(db, db, storage);
const provider = () => (storage ?? getStorageService()).provider;
function scope(options: Page) {
return and(options.companyId ? eq(companySkills.companyId, options.companyId) : undefined,
options.id ? eq(companySkills.id, options.id) : undefined,
options.after ? gt(companySkills.id, options.after) : undefined);
}
async function installations(options: Conversion = {}) {
if (options.apply && !options.writersStopped) throw conflict("Installation conversion requires stopped admission and retired legacy writers (--writers-stopped).");
const skills = await db.select().from(companySkills).where(scope(options)).orderBy(asc(companySkills.id)).limit(limit(options));
const results: Array<{ skillId: string; companyId: string; status: string; versionId?: string | null; reason?: string }> = [];
for (const skill of skills) {
options.signal?.throwIfAborted();
const catalogOrigin = skill.sourceType === "catalog" && !skill.metadata?.originVersionId;
if (linkedLocal(skill)) {
results.push({ skillId: skill.id, companyId: skill.companyId, status: "linked_local", versionId: skill.currentVersionId });
continue;
}
try {
const version = skill.currentVersionId ? await store.version(skill.companyId, skill.id, skill.currentVersionId) : null;
const complete = version && coversInstalledInventory(skill, await store.versionInventory(version));
if (!legacyManaged(skill) && !catalogOrigin && complete) {
results.push({ skillId: skill.id, companyId: skill.companyId, status: "unchanged", versionId: skill.currentVersionId });
continue;
}
const pins = await protectedVersions(db, skill.companyId);
if (pins.versions === null) throw new Error("Active run lacks an exact durable skill selection; settle or snapshot it on the legacy server first");
const saved = version ? await store.versionFiles(version) : null;
const local = await legacyFiles(skill, options.signal);
const completeSaved = saved && coversInstalledInventory(skill, saved) ? saved : null;
const files = local ?? completeSaved ?? ((skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url")
? await companySkillService(db).captureLegacyInstallation(skill.companyId, skill.id) : null);
const origin = catalogOrigin && typeof skill.metadata?.originSnapshotLocator === "string"
? await legacyFiles({ ...skill, sourceLocator: skill.metadata.originSnapshotLocator }, options.signal) : null;
if (catalogOrigin && !origin && skill.metadata?.userModifiedAt) {
throw new Error("Catalog reset snapshot is unavailable; preserve the original installed package before conversion");
}
if (!files?.length || !files.some(file => file.path === "SKILL.md")) throw new Error("Neither complete legacy files nor a saved version is available");
if (!local && !coversInstalledInventory(skill, files)) {
throw new Error("Saved version does not cover the installed inventory");
}
if (!options.apply) {
results.push({ skillId: skill.id, companyId: skill.companyId, status: "conversion_required", versionId: skill.currentVersionId });
continue;
}
if (version && saved && !version.packageSnapshotId && skillSnapshotHash(saved) === skillSnapshotHash(files)) {
await skillPackageMaintenance(db, storage).migrateVersions({ id: version.id, apply: true, clearLegacy: true, signal: options.signal });
}
const candidate = await content.prepare(skill.companyId, files, options.signal);
if (!candidate.packageSnapshotId || !await store.verifyDurable(skill.companyId, candidate.packageSnapshotId, options.signal)) {
throw new Error("Final snapshot is not independently recoverable from object storage");
}
let originVersionId = typeof skill.metadata?.originVersionId === "string" ? skill.metadata.originVersionId : null;
if (catalogOrigin && origin && skillSnapshotHash(origin) !== skillSnapshotHash(files)) {
originVersionId = (await companySkillService(db).createVersion(skill.companyId, skill.id,
{ label: "Original catalog package" }, { type: "system" }, {
fileInventory: origin, updateCurrentVersion: false, skipInventoryRefresh: true,
})).id;
}
const markdown = files.find(file => file.path === "SKILL.md")!.content;
const parsed = parseFrontmatterMarkdown(markdown);
const { missingSource: _missing, ...metadata } = skill.metadata ?? {};
const result = await withSkillVersionCacheLock(db, skill.companyId, () => content.publish(skill, candidate, {
...(legacyManaged(skill) ? { sourceType: "managed", sourceLocator: null } : {}), markdown,
name: typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : skill.name,
description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : skill.description,
fileInventory: files.map(({ path, kind }) => ({ path, kind })),
trustLevel: files.some(file => file.kind === "script" || file.executable) ? "scripts_executables"
: files.some(file => file.kind === "asset" || file.kind === "other") ? "assets" : "markdown_only",
metadata: { ...metadata, ...(legacyManaged(skill) ? { sourceKind: "managed" } : {}),
...(originVersionId ? { originVersionId } : {}) },
}, { type: "system" }, { label: "Legacy installation conversion",
reuseCurrentVersion: Boolean(saved && skillSnapshotHash(saved) === skillSnapshotHash(files)),
afterPublish: async (tx, row, version) => {
if (catalogOrigin && !originVersionId) await tx.update(companySkills)
.set({ metadata: { ...row.metadata, originVersionId: version.id } }).where(eq(companySkills.id, row.id));
},
}));
results.push({ skillId: skill.id, companyId: skill.companyId, status: "converted", versionId: result.version.id });
} catch (error) {
results.push({ skillId: skill.id, companyId: skill.companyId, status: "blocked", reason: error instanceof Error ? error.message : "Conversion failed" });
}
}
return { inspected: skills.length, after: skills.at(-1)?.id ?? null, results };
}
async function eligibility(options: Page & { purpose?: "migration" | "startup" } = {}) {
const skills = await db.select().from(companySkills).where(scope(options)).orderBy(asc(companySkills.id)).limit(limit(options));
const results = [];
for (const skill of skills) {
// Migration/eviction must wait for exact pins. Replacement readiness only
// inspects installation durability: a serving controller may legitimately
// be between run admission and publishing its immutable skill selection.
const pins = options.purpose === "startup" ? undefined : await protectedVersions(db, skill.companyId);
let status = linkedLocal(skill) ? "linked_local" : legacyManaged(skill) ? "legacy_managed" : "version_backed";
if (pins?.versions === null) status = "unsupported_active_run";
else if (!linkedLocal(skill) && !skill.currentVersionId) status = "missing_current_version";
else if (skill.sourceType === "catalog" && !skill.metadata?.originVersionId) status = "legacy_catalog_origin";
else if (status === "version_backed" && skill.currentVersionId) {
try {
const version = await store.version(skill.companyId, skill.id, skill.currentVersionId);
if (!version) status = "unavailable_current_version";
else if (!coversInstalledInventory(skill, await store.versionInventory(version))) status = "incomplete_current_version";
} catch { status = "unavailable_current_version"; }
}
results.push({ skillId: skill.id, companyId: skill.companyId, sourceType: skill.sourceType, versionId: skill.currentVersionId, status });
}
return { inspected: skills.length, after: skills.at(-1)?.id ?? null, results, contentVerified: false };
}
async function verifyVersions(options: Page = {}) {
const versions = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
options.companyId ? eq(companySkillVersions.companyId, options.companyId) : undefined,
options.id ? eq(companySkillVersions.id, options.id) : undefined,
options.after ? gt(companySkillVersions.id, options.after) : undefined,
)).orderBy(asc(companySkillVersions.id)).limit(limit(options));
const results = [];
for (const version of versions) {
options.signal?.throwIfAborted();
const packageId = version.repositorySnapshotId ?? version.packageSnapshotId;
let status = "missing_package";
try {
if (packageId) {
const receipt = await store.descriptor(version.companyId, packageId);
status = !receipt.storage ? "no_durable_receipt" : receipt.storage.provider !== provider() ? "provider_mismatch"
: receipt.storage.provider === "local_disk" ? "local_storage" : "remote_storage";
if (receipt.storage && receipt.storage.provider === provider()
&& !await store.verifyDurable(version.companyId, packageId, options.signal)) status = "corrupt_package";
if (!receipt.storage && receipt.restorable && version.repositorySnapshotId === packageId) {
const active = await protectedVersions(db, version.companyId);
const [protectedRow] = await db.execute(sql`select 1 from company_skills
where company_id = ${version.companyId} and current_version_id = ${version.id}
union all select 1 from company_skill_test_runs where company_id = ${version.companyId}
and status in ('queued', 'running') and skill_version_id = ${version.id}
union all select 1 from agents where company_id = ${version.companyId}
and adapter_config::text like ${`%${version.id}%`} limit 1`);
if (!protectedRow && active.versions && !active.versions.has(version.id)
&& Date.now() - receipt.createdAt.getTime() >= 30 * 24 * 60 * 60_000) {
await store.recoveryRecipe(version.companyId, packageId);
status = "expired_git_history";
}
}
} else {
const files = await store.versionFiles(version, false, options.signal);
skillPackageManifest(files);
status = files.some(file => file.path === "SKILL.md" && file.encoding !== "base64")
? version.releaseId ? "database_release_snapshot" : "database_snapshot"
: "incomplete_database_snapshot";
}
// A valid archive can still be an incomplete legacy installation. Compare
// only the current head: historical revisions may legitimately omit files
// that were added later. Inventory reads do not load SQL package bodies.
const [installed] = await db.select().from(companySkills).where(and(
eq(companySkills.companyId, version.companyId), eq(companySkills.id, version.companySkillId),
eq(companySkills.currentVersionId, version.id),
));
if (installed && !linkedLocal(installed)
&& !coversInstalledInventory(installed, await store.versionInventory(version))) status = "incomplete_current_version";
} catch { status = "unavailable_or_corrupt_package"; }
results.push({ companyId: version.companyId, skillId: version.companySkillId, versionId: version.id, packageId, status });
}
return { inspected: versions.length, after: versions.at(-1)?.id ?? null, results };
}
return { installations, eligibility, verifyVersions };
}