mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - After a restart, the control plane settles in-progress records (leases, claims, intents) only once it proves their owner process is gone > - The proof compares host and PID space, so a stamp from a replaced container or a rebooted machine stays unprovable forever, and its records never settle > - Self-hosted users on Docker, Kubernetes and Fly hit this on every redeploy; a Railway deployment with an exclusive volume is covered by `deployment_replaced` (#20026) > - This pull request adds a fenced controller epoch: each boot holds a database lease, and an expired boot is fenced so its records can be proven gone > - The benefit is that work stranded by a replaced container is recovered, without keeping an idle database awake ## Linked Issues or Issue Description Follow-up to #20026 and the Phase 2 durable-ownership data. Related: #19965 (owner stamps), #19883 (process space). No duplicate PR was found. **What happened?** A record stamped by a server process in another container reports `different_process_space`. A dead PID elsewhere is never proof, so the record stays `unprovable` and is never settled. Every redeploy of a self-hosted container leaves such records behind. **Expected behavior** A record owned by a server boot that was replaced (stopped cleanly, or lost its lease and was fenced) is proven gone and settled exactly once. A live boot's records stay with it. An idle server issues no recurring queries. **Steps to reproduce** 1. Run Paperclip in a container and start work, so an environment lease is stamped by that boot. 2. Replace the container. The new container has a new PID namespace. 3. The ownership reconciler reports `different_process_space` (unprovable) for the old lease, forever. **Paperclip version or commit** `master` before this PR. ## What Changed - New `controller_instances` table (migration 0449): boot id, epoch (bigserial), process space, pid, a lease on the database clock, `fenced_at` / `fenced_by_epoch`, `stopped_at`. - `controller-instance-lease.ts`, registered with the shared delivery-work coordinator (`controller-instance-lease` queue, runs during drain): - **Holds work → renews; idle → silent.** One upsert creates the row on first use and extends the lease only if the boot holds work: a stamp within the last 90 s, or a stamp whose ownership write may still be pending (held until the server's tracked work settles, capped at 10 minutes; a renewal that sees some other held record does not clear it, so a slow ownership write cannot be outlived), or an in-progress record held by its boot id. Those records are active environment leases, in-flight cleanup claims, sandbox creation intents, workspace finalize attempts, agent lifecycle steps, native finalizer and cleanup leases (`lease_holder` bound to the token), open native coordinator claims, and running legacy runs. Renewal runs every 30 s while the boot holds work. When it finds none, the lease lapses and the worker sleeps until the next stamp (`onControllerStamp`). - **No idle trailing statements.** When the server's last tracked work settles, the worker re-checks holdings at once, at most every 5 s (the rate limit also absorbs the worker's own run). So the last check happens with the work, not 30 s later. - **Quiesce, don't crash.** Each renewal has a 10 s bound. A failed or timed-out renewal applies an internal task drain, so no new work is dispatched. A recovered renewal resumes. The boot exits only after 60 s of failure (counted from the first failed attempt's start) while it still holds work, or at once when it finds itself fenced. - **Fence on demand, by a newer epoch.** For a stamp from another host or PID space, the prover reads that boot's row: - `stopped` or `fenced`: gone. - live lease: alive. - otherwise, the boot's last sign of life is the later of its lease expiry and the record's last write plus the TTL. Within 30 s of that, the owner stays `alive` (`controller_lease_grace`), so the settle watch keeps rechecking. After that, the prover fences the boot, but only with a lease row of its own (created on demand) and only an older epoch. - A graceful shutdown records `stopped_at`. - A reconciliation pass that settles nothing (`record: false` or `settle: false`) never fences and never creates the boot's row. - `owner-proof.ts`: these proofs apply only to server-held records. A stamp with a recorded process group stays unprovable, because agent processes can outlive their server. The reconciler passes each record's `updatedAt` to the proof. - `task-admission.ts`: - the quiesce drain never replaces an operator's drain, and has no idle owner, so HTTP stays open; - a `subscribeIdleWorkSettled` hook fires when the last tracked work finishes. - Off on a Railway deployment with an exclusive volume (it has its own replacement proof). `PAPERCLIP_CONTROLLER_INSTANCE_LEASE=on|off` overrides this. A stateless Railway service keeps the lease. A server without its own lease row never fences. - `maintenance-server-routes.test.ts`: in the post-run quiet window, the lease may check once more that the run's records were released. The test now excludes those statements, then asserts that the lease issues nothing in a further window. - Docs: `doc/execution-semantics.md`; the Phase 2 data doc records the two known gaps. ## Verification - `controller-instance-lease.test.ts` (15 cases): - a replaced container with all Railway variables empty is fenced and settled exactly once over two passes; - a live lease is alive; - an owner in grace, and work written after an idle lapse, stay alive and watched; - an older epoch never fences a newer one; - no fencing without its own lease row (off or unregistered), and an idle prover creates its row on demand; - a stopped boot is gone; - process-group stamps stay unprovable; - idle issues zero statements, and one upsert renews only while records are held; - **a 45 s outage:** quiesced, no exit, resumes; - **a renewal that never answers:** bounded, and the boot exits on time; - **a 70 s outage:** exits at 60–65 s, and the successor recovers the work exactly once; - fenced: stops; - the settle re-check; - graceful stop; off on a volume-exclusive Railway deployment, on for a stateless one, and the on/off override; - a running legacy run keeps the lease live. - Red, with the proof's instance check removed: the replaced-container, live, stopped and successor cases fail. - Wider: task-drain, idle-sleep, idle-local-work, owner-proof and ownership suites, plus the maintenance quiet-window tests. Server low-memory typecheck is clean. ## Risks - **Clock skew.** The proof compares a record's `updatedAt` (often the writer's clock) with the lease (database clock). The 30 s grace absorbs ordinary skew. - **A new exit path exists.** A server whose database is unreachable for more than 60 s while it holds work now exits, after first quiescing. This is the intended safety backstop. - **Bounded trailing activity.** A lease that holds work issues one statement every 30 s, plus at most one check when its work settles. An idle server issues none. - **Boots from before this release** have no row, and their records stay unprovable. The board "mark owner gone" action for those follows in a separate PR. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use. ## 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 - [ ] 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 Opus 5.5 <noreply@anthropic.com>
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { readFileSync, readlinkSync } from "node:fs";
|
|
import { hostname, uptime } from "node:os";
|
|
import { readProcessStartedAt } from "./hot-restart.js";
|
|
import { currentWorkspaceWorker, type WorkspaceWorker } from "./native-runtime/native-workspace-platform-proof.js";
|
|
|
|
/**
|
|
* Identity of the server process that started a provider side effect, saved
|
|
* with its durable record. After a restart, recovery uses it to prove that the
|
|
* starter has exited, so the side effect cannot still be in flight there.
|
|
*/
|
|
export type ControllerDeployment = { worker: WorkspaceWorker; exclusiveVolume: boolean; volumeId: string | null };
|
|
export type ControllerProcess = {
|
|
bootId: string; pid: number; hostHash: string; processStartedAt: string | null; deployment?: ControllerDeployment;
|
|
};
|
|
|
|
export function currentControllerDeployment(env: NodeJS.ProcessEnv = process.env): ControllerDeployment | null {
|
|
const worker = currentWorkspaceWorker(env);
|
|
if (!worker) return null;
|
|
const volumeId = env.RAILWAY_VOLUME_ID?.trim() || null;
|
|
return { worker, exclusiveVolume: Boolean(volumeId && env.RAILWAY_VOLUME_MOUNT_PATH?.trim()), volumeId };
|
|
}
|
|
|
|
function deploymentField(): { deployment?: ControllerDeployment } {
|
|
const deployment = currentControllerDeployment();
|
|
return deployment ? { deployment } : {};
|
|
}
|
|
|
|
function readDeployment(value: unknown): ControllerDeployment | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const record = value as Record<string, unknown>;
|
|
const worker = record.worker as Record<string, unknown> | null | undefined;
|
|
if (!worker || typeof worker !== "object" || worker.provider !== "railway") return null;
|
|
const ids = [worker.projectId, worker.environmentId, worker.serviceId, worker.deploymentId];
|
|
if (ids.some(id => typeof id !== "string" || id.length === 0)) return null;
|
|
return { worker: { provider: "railway", projectId: ids[0] as string, environmentId: ids[1] as string,
|
|
serviceId: ids[2] as string, deploymentId: ids[3] as string },
|
|
exclusiveVolume: record.exclusiveVolume === true,
|
|
volumeId: typeof record.volumeId === "string" ? record.volumeId : null };
|
|
}
|
|
|
|
/**
|
|
* The one boot id of this server process. Native coordinator claims
|
|
* (`native_run_finalizations.controller_boot_id`), legacy controller leases
|
|
* (`heartbeat_runs.controller_boot_id`), sandbox creation intents and teardown
|
|
* claims all stamp this value, so "owned by this process" means the same thing
|
|
* in every table. Records written by an older release carry that release's
|
|
* random per-module ids; those never equal this value, exactly as a record
|
|
* from any earlier boot never did.
|
|
*/
|
|
const bootId = randomUUID();
|
|
export const UNKNOWN_PROCESS_SPACE = "unknown";
|
|
|
|
/**
|
|
* Names the space in which PIDs are comparable: one kernel boot and, on Linux,
|
|
* one PID namespace. Containers that share a hostname but not a PID namespace
|
|
* get different values. When the space cannot be read, no record is judged.
|
|
* A redeploy onto another host or namespace also never matches; such records
|
|
* keep their original deadlines.
|
|
*/
|
|
function processSpaceHash(): string {
|
|
try {
|
|
const parts = [hostname()];
|
|
if (process.platform === "linux") {
|
|
parts.push(readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(), readlinkSync("/proc/self/ns/pid"));
|
|
} else {
|
|
// No PID namespaces here. The boot time keeps a rebooted host distinct;
|
|
// rounding can only make a record unmatched, which fails closed.
|
|
parts.push(String(Math.round((Date.now() - uptime() * 1000) / 600_000)));
|
|
}
|
|
if (parts.some(part => !part)) return UNKNOWN_PROCESS_SPACE;
|
|
return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 32);
|
|
} catch {
|
|
return UNKNOWN_PROCESS_SPACE;
|
|
}
|
|
}
|
|
|
|
const hostHash = processSpaceHash();
|
|
|
|
/** OS-observed start time of any PID (epoch ms), or null when it cannot be read. */
|
|
export async function observedProcessStartedAt(pid: number): Promise<number | null> {
|
|
try {
|
|
const startedAt = Date.parse(await readProcessStartedAt(pid) ?? "");
|
|
return Number.isFinite(startedAt) ? startedAt : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// This process's own start time never changes, so it is read from the OS once.
|
|
// On macOS the read runs `ps -o lstart`; uncached, it ran on every native
|
|
// claim, preparation check and lease renewal. A failed read is not cached, so
|
|
// a transient failure is retried by the next caller.
|
|
let ownStartedAt: Promise<number | null> | undefined;
|
|
/** The start time from the last resolved read, for `currentControllerProcessStamp`. */
|
|
let resolvedProcessStartedAt: string | null = null;
|
|
|
|
/** OS-observed start time of this server process (epoch ms), read at most once
|
|
* successfully per process. Null when the OS cannot report it. */
|
|
export function currentProcessStartedAtMs(): Promise<number | null> {
|
|
if (!ownStartedAt) {
|
|
const read = observedProcessStartedAt(process.pid);
|
|
ownStartedAt = read;
|
|
void read.then(value => {
|
|
if (value === null && ownStartedAt === read) ownStartedAt = undefined;
|
|
// Any resolved read also feeds the no-I/O stamp.
|
|
if (value !== null) resolvedProcessStartedAt = new Date(value).toISOString();
|
|
});
|
|
}
|
|
return ownStartedAt;
|
|
}
|
|
|
|
/** Test seam: forget the cached start time so a test can stub the OS read. */
|
|
export function resetCurrentProcessStartedAtForTests(): void {
|
|
ownStartedAt = undefined;
|
|
}
|
|
|
|
export function currentControllerBootId(): string {
|
|
return bootId;
|
|
}
|
|
|
|
const stampListeners = new Set<() => void>();
|
|
/** Called (synchronously, without I/O) whenever this process stamps ownership of a record. */
|
|
export function onControllerStamp(listener: () => void): () => void {
|
|
stampListeners.add(listener);
|
|
return () => { stampListeners.delete(listener); };
|
|
}
|
|
function notifyStamp() {
|
|
for (const listener of stampListeners) {
|
|
try { listener(); } catch { /* a listener never blocks a stamp */ }
|
|
}
|
|
}
|
|
|
|
export async function currentControllerProcess(): Promise<ControllerProcess> {
|
|
const startedAt = await currentProcessStartedAtMs();
|
|
const iso = startedAt === null ? null : new Date(startedAt).toISOString();
|
|
resolvedProcessStartedAt = iso;
|
|
notifyStamp();
|
|
return { bootId, pid: process.pid, hostHash, processStartedAt: iso, ...deploymentField() };
|
|
}
|
|
|
|
/**
|
|
* This process's identity without any I/O, for stamps written on the turn
|
|
* path. The start time is present only once an earlier call resolved it. A
|
|
* stamp without one still proves exit when its PID is gone in this PID space;
|
|
* a live PID then stays ambiguous, which fails closed.
|
|
*/
|
|
export function currentControllerProcessStamp(): ControllerProcess {
|
|
notifyStamp();
|
|
return { bootId, pid: process.pid, hostHash, processStartedAt: resolvedProcessStartedAt, ...deploymentField() };
|
|
}
|
|
|
|
export function readControllerProcess(value: unknown): ControllerProcess | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const record = value as Record<string, unknown>;
|
|
if (typeof record.bootId !== "string" || typeof record.hostHash !== "string" ||
|
|
typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0) return null;
|
|
const deployment = readDeployment(record.deployment);
|
|
return { bootId: record.bootId, pid: record.pid, hostHash: record.hostHash,
|
|
processStartedAt: typeof record.processStartedAt === "string" ? record.processStartedAt : null,
|
|
...(deployment ? { deployment } : {}) };
|
|
}
|
|
|
|
/**
|
|
* True when some process owns the PID in this PID space. EPERM means the
|
|
* process exists but belongs to another user, so it counts as alive.
|
|
*/
|
|
export function processExists(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
/** Process start times closer than this may be the same process. */
|
|
export const PROCESS_START_PRECISION_MS = 1_000;
|
|
|
|
/**
|
|
* True only when two start times for one PID prove different processes.
|
|
* `ps -o lstart` on macOS and BSD has whole-second precision, while a recorded
|
|
* start can come from a millisecond clock, so a sub-second gap is ambiguous
|
|
* and fails closed (same process). A missing or unparsable time proves nothing.
|
|
*/
|
|
export function processStartsDefinitelyDiffer(
|
|
recorded: Date | string | null | undefined,
|
|
observed: Date | string | null | undefined,
|
|
): boolean {
|
|
const left = recorded instanceof Date ? recorded.getTime() : Date.parse(recorded ?? "");
|
|
const right = observed instanceof Date ? observed.getTime() : Date.parse(observed ?? "");
|
|
return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) >= PROCESS_START_PRECISION_MS;
|
|
}
|
|
|
|
/** True only with proof, in this process's own PID space, that the recorded
|
|
* server process has exited. Another space, an unreadable space, this server,
|
|
* or an ambiguous live PID returns false. */
|
|
export async function controllerProcessExited(controller: ControllerProcess): Promise<boolean> {
|
|
if (controller.bootId === bootId || hostHash === UNKNOWN_PROCESS_SPACE || controller.hostHash !== hostHash) return false;
|
|
if (!processExists(controller.pid)) return true;
|
|
// Without a recorded start time a live PID can never prove exit; skip the probe.
|
|
if (!Number.isFinite(Date.parse(controller.processStartedAt ?? ""))) return false;
|
|
// The PID now belongs to a live process, possibly this restarted server.
|
|
// Only a definitely different start time proves the recorded process is gone.
|
|
const observed = await observedProcessStartedAt(controller.pid);
|
|
return observed !== null && processStartsDefinitelyDiffer(controller.processStartedAt, new Date(observed));
|
|
}
|
|
|
|
/** This process's PID-space hash. An unreadable space is stored as
|
|
* "unknown", which `sameProcessSpace` never accepts. */
|
|
export function currentProcessSpaceHash(): string {
|
|
return hostHash;
|
|
}
|
|
|
|
/**
|
|
* Whether a PID recorded under `recorded` can be judged in this process.
|
|
* True only for the same readable PID space. A different or unreadable space
|
|
* is never proof: a dead PID here says nothing about a process elsewhere.
|
|
*/
|
|
export function sameProcessSpace(recorded: string | null | undefined): boolean {
|
|
return typeof recorded === "string" && hostHash !== UNKNOWN_PROCESS_SPACE && recorded === hostHash;
|
|
}
|