mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## 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>
240 lines
16 KiB
TypeScript
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 };
|
|
}
|