mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A hosted Paperclip server can run volume-free: its durable state is in the database and object storage, and its local disk is replaced with every deployment > - At startup, a volume-free server runs a readiness gate that refuses to start when it finds data that still needs migration off a volume > - One of those findings is a harness backup that never reached durable storage. A volume-free server that stops in the middle of a run always leaves some > - The gate then refuses every later start, the process exits, and the host restarts it into the same refusal. The workspace stays down until a person repairs it > - This pull request lets a server start over such backups when its filesystem carries the volume-free owner marker > - The benefit is that a volume-free server recovers from an interrupted run by itself ## Linked Issues or Issue Description Refs #20952 (the same crash loop for Runner controller files; this is the next check in the same gate). **What happened?** A hosted volume-free workspace stopped while runs were in progress. Every later start logged `Paperclip server failed to start` with `Volume-free server data is not eligible: native_harness_backups_require_migration`, and exited. The host restarted the container and the server exited again. The workspace did not serve any request for more than two hours. **Expected behavior** A volume-free server starts after an interrupted run. Restart recovery handles the runs whose local harness backup was lost with the old container. **Steps to reproduce** 1. Run a volume-free server (`PAPERCLIP_EPHEMERAL_SERVER=true`) with native Runner tasks in progress. 2. Stop the server process before the harness backup of an active session reaches durable storage. 3. Start the server again. It refuses to start with `native_harness_backups_require_migration`. **Paperclip version or commit** `87fe4d979355232570428deba480b47e1812a26c` (contains #20952). ## What Changed - `assertEphemeralServerData`: when the only reason is `native_harness_backups_require_migration` and the filesystem carries the volume-free owner marker (`volumeFreeControllerStateOwned`), the gate removes that blocker, logs a warning, and lets the server start. - A filesystem without the marker is refused as before. All other blockers are unchanged. - `readEphemeralServerDataEligibility` is unchanged, so the migration and enrollment callers still see the blocker. - Added `ephemeral-server-startup-run-state.test.ts`. ## Verification - Red: with the change to `ephemeral-server-contract.ts` reverted, `starts a volume-free server over harness backups a volume-free server left unsaved` fails (1 failed, 2 passed). - Green: `npx vitest run src/__tests__/ephemeral-server-startup-run-state.test.ts src/__tests__/ephemeral-server-data.test.ts` in `server/`: 2 files, 8 tests passed. - Not run locally: the full suite. CI supplies that coverage. - Not verified: a live volume-free start. The hosted workspace that showed the failure will receive this change through the normal release. ## Risks - A server on a marked volume-free filesystem now starts with unsaved harness backups. Those backups were on a disk that no longer exists, so the refusal protected nothing. The affected runs depend on restart recovery. - The marker is the only evidence that there is no volume. A filesystem that still is a volume and does not carry the marker keeps the refusal. - The gate does not repair or delete the stale lease stamps. The warning repeats at each start until restart recovery settles those runs. ## Model Used Claude Fable 5.2 (`claude-fable-5-2`), through Claude Code with tool use and code execution. ## 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 - [ ] 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 - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
118 lines
8.0 KiB
TypeScript
118 lines
8.0 KiB
TypeScript
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm";
|
|
import { agentFileRoots, agents, executionWorkspaces, heartbeatRuns, providerTraceRecords, type Db } from "@paperclipai/db";
|
|
import type { Config } from "../config.js";
|
|
import { isValidLocalEncryptedMasterKey } from "../secrets/local-encrypted-provider.js";
|
|
import { agentInstructionsBundleMode } from "./agent-instructions.js";
|
|
import { skillInstallationMaintenance } from "./skill-installation-maintenance.js";
|
|
import { hasLocalNativeHarnessObligations } from "./native-runtime/native-harness-backup-migration.js";
|
|
import { findLocalNativeControllerObligation } from "./native-runtime/native-controller-migration.js";
|
|
import { hasUnsettledLocalInstructionCopies } from "./agent-instruction-copy-preservation.js";
|
|
import { hasLocalRuntimeSkillObligations } from "./native-runtime/runtime-skill-preservation.js";
|
|
import { localWorkspaceDependencyWhere } from "./local-workspace-dependencies.js";
|
|
|
|
import { ephemeralServerEnabled } from "./ephemeral-server-mode.js";
|
|
import { volumeFreeControllerStateOwned } from "./native-runtime/volume-free-controller-state.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
export { ephemeralServerEnabled };
|
|
|
|
/** Validate before consumers can generate a replacement local identity. Error
|
|
* messages name settings only, never credential values or connection URLs. */
|
|
export function assertEphemeralServerConfig(config: Pick<Config, "databaseUrl" | "storageProvider">, env = process.env): string | null {
|
|
if (env.PAPERCLIP_EPHEMERAL_SERVER !== "true") return null;
|
|
const refuse = (reason: string): never => { throw new Error(`Volume-free server configuration: ${reason}`); };
|
|
if (config.storageProvider !== "s3" || env.PAPERCLIP_DURABLE_AGENT_FILES !== "true" || env.PAPERCLIP_SHARED_UI_ASSETS !== "true") {
|
|
refuse("shared S3 storage, durable agent files and shared UI assets are required");
|
|
}
|
|
if (!env.PAPERCLIP_SECRETS_MASTER_KEY || !isValidLocalEncryptedMasterKey(env.PAPERCLIP_SECRETS_MASTER_KEY)) {
|
|
refuse("a stable PAPERCLIP_SECRETS_MASTER_KEY is required");
|
|
}
|
|
if ((env.PAPERCLIP_DECISION_SIGNING_SECRET?.trim().length ?? 0) < 32) {
|
|
refuse("a stable PAPERCLIP_DECISION_SIGNING_SECRET of at least 32 characters is required; preserve the existing decision key during migration");
|
|
}
|
|
let runtime: URL, listener: URL;
|
|
try { runtime = new URL(config.databaseUrl ?? ""); listener = new URL(env.PAPERCLIP_DATABASE_LISTEN_URL ?? ""); }
|
|
catch { return refuse("external DATABASE_URL and PAPERCLIP_DATABASE_LISTEN_URL are required"); }
|
|
if (![runtime, listener].every(url => ["postgres:", "postgresql:"].includes(url.protocol))
|
|
|| runtime.username !== listener.username || runtime.password !== listener.password || runtime.pathname !== listener.pathname
|
|
|| runtime.hostname.replace(/-pooler(?=\.)/, "") !== listener.hostname
|
|
|| /pooler|pgbouncer/i.test(listener.hostname)) {
|
|
refuse("PAPERCLIP_DATABASE_LISTEN_URL must be a direct connection to the same database using the runtime role");
|
|
}
|
|
return listener.href;
|
|
}
|
|
|
|
/** Findings that describe work in progress when a server stopped, not data
|
|
* waiting on a volume to be migrated. Every volume-free server that stops
|
|
* during a run leaves some of them behind. Runtime skill assets are not in
|
|
* this set: that finding also covers retained sessions whose skill bundles
|
|
* were never preserved, which the owner marker does not prove durable. */
|
|
const interruptedRunBlockers: ReadonlySet<string> = new Set([
|
|
"native_harness_backups_require_migration",
|
|
"local_instruction_copies_require_settlement",
|
|
]);
|
|
|
|
/** A readiness gate, not a periodic scan or an implicit migration. Existing
|
|
* volume data is adopted by the owning maintenance commands before removal. */
|
|
export async function assertEphemeralServerData(db: Db) {
|
|
if (!ephemeralServerEnabled()) return;
|
|
const eligibility = await readEphemeralServerDataEligibility(db, { purpose: "startup" });
|
|
const { controller } = eligibility;
|
|
let blockers = eligibility.blockers;
|
|
// Run state that never reached durable storage needs migration only while
|
|
// it can still be on a volume. Once this filesystem carries the volume-free
|
|
// owner marker there is no volume: the state was written by a volume-free
|
|
// server, this one or a predecessor that stopped mid-run, and refusing to
|
|
// start cannot bring it back. It would only keep every restart from
|
|
// starting again. Restart recovery owns those runs; start and say so.
|
|
// Configuration that would write new data to the local disk stays refused.
|
|
const interrupted = blockers.filter(blocker => interruptedRunBlockers.has(blocker));
|
|
if (interrupted.length && await volumeFreeControllerStateOwned()) {
|
|
blockers = blockers.filter(blocker => !interruptedRunBlockers.has(blocker));
|
|
logger.warn({ blockers: interrupted },
|
|
"Volume-free server is starting over run state that never reached durable storage; restart recovery owns those runs");
|
|
}
|
|
if (!blockers.length) return;
|
|
const detail = (blocker: string) => blocker === "native_controllers_require_migration" && controller
|
|
? `${blocker} (run ${controller.runId} on task ${controller.issueId}: controller state at ${controller.source} is not proven by the database, and this filesystem has no volume-free owner marker, so it may be the only copy of that run's progress; adopt it with the native controller migration before starting a volume-free server)`
|
|
: blocker;
|
|
throw new Error(`Volume-free server data is not eligible: ${blockers.map(detail).join(", ")}`);
|
|
}
|
|
|
|
export async function readEphemeralServerDataEligibility(db: Db, options: { purpose?: "migration" | "startup" } = {}) {
|
|
const blockers: string[] = [];
|
|
const [missingFiles] = await db.select({ id: agents.id }).from(agents).leftJoin(agentFileRoots,
|
|
and(eq(agentFileRoots.companyId, agents.companyId), eq(agentFileRoots.agentId, agents.id)))
|
|
.where(isNull(agentFileRoots.agentId)).limit(1);
|
|
if (missingFiles) blockers.push("agent_files_require_migration");
|
|
if (await hasUnsettledLocalInstructionCopies(db)) blockers.push("local_instruction_copies_require_settlement");
|
|
// Only inspect configuration here; filesystem reads must never adopt an
|
|
// empty directory on the replacement machine as the old server's content.
|
|
const configured = await db.select({ id: agents.id, companyId: agents.companyId, name: agents.name, adapterConfig: agents.adapterConfig }).from(agents);
|
|
if (configured.some(agent => agentInstructionsBundleMode(agent) === "external")) blockers.push("external_agent_instructions");
|
|
const [localWorkspace] = await db.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
|
.where(localWorkspaceDependencyWhere(db)).limit(1);
|
|
if (localWorkspace) blockers.push("workspaces_require_migration");
|
|
const [localLog] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.logStore, "local_file"), isNotNull(heartbeatRuns.logRef),
|
|
)).limit(1);
|
|
if (localLog) blockers.push("run_logs_require_migration");
|
|
const [localTrace] = await db.select({ id: providerTraceRecords.id }).from(providerTraceRecords).where(and(
|
|
isNull(providerTraceRecords.deletedAt), sql`${providerTraceRecords.traceRef} not like 's3-segments-v1:%'`,
|
|
)).limit(1);
|
|
if (localTrace) blockers.push("provider_traces_require_migration");
|
|
if (await hasLocalNativeHarnessObligations(db)) blockers.push("native_harness_backups_require_migration");
|
|
const controller = await findLocalNativeControllerObligation(db);
|
|
if (controller) blockers.push("native_controllers_require_migration");
|
|
if (await hasLocalRuntimeSkillObligations(db)) blockers.push("runtime_skill_assets_require_migration");
|
|
const skills = skillInstallationMaintenance(db);
|
|
let after: string | undefined;
|
|
for (;;) {
|
|
const page = await skills.eligibility({ after, limit: 50, purpose: options.purpose });
|
|
if (page.results.some(skill => skill.status !== "version_backed")) { blockers.push("skills_require_migration"); break; }
|
|
if (page.inspected < 50 || !page.after) break;
|
|
after = page.after;
|
|
}
|
|
return { eligible: blockers.length === 0, blockers, controller };
|
|
}
|