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 > - The control plane recovers in-progress records after a server restart through a shared ownership path > - Run-less sandbox leases (login, probe, harness verification) were released only by a five-minute idle sweep > - That sweep releases by age: it leaves a dead holder's sandbox for minutes and takes a live login session's sandbox > - This pull request settles a run-less lease only when its stamped holder is proven gone > - The benefit is no stuck sandbox after a restart, and no live session loses its sandbox ## Linked Issues or Issue Description Related: #19965 (named the owner of login/probe leases), #19964 (the run-lease settle this mirrors). No duplicate PR was found. **What happened?** The final Daytona chaos wave `w-20261108T083036-s9401` recorded I2 `environment_login_probe_lease` (`pid_dead`) on 2 records. These were the codex agent's harness-verification leases. A restart killed their holder. The leases stayed `active` until the five-minute idle sweep released them. The same sweep releases any run-less lease after five idle minutes, also when its holder is alive. **Expected behavior** A run-less lease is released as soon as its stamped holder is proven gone. A lease whose holder is alive is never released, however long it is idle. **Steps to reproduce** 1. Start a Daytona wave: `node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env daytona --daytona-image <image>` (K1/K4 faults). 2. Let a fault kill the server while an agent's harness verification holds its sandbox lease. 3. See the reconciler report I2 `environment_login_probe_lease` until the idle sweep releases it. **Paperclip version or commit** `master` at `8475826d2d`. ## What Changed - New `settleEnvironmentLoginProbeLease`, registered on `environment_login_probe_lease`. It runs only for a stamped holder that is proven gone, and compares and swaps on that exact stamp. - It moves the lease to `pending_cleanup` with the recovered-orphan reason, so the cleanup sweep destroys the sandbox in its next tick. - It leaves the lease alone when a legacy shutdown fence applies, or when another live lease holds the same provider resource. - The idle sweep for run-less leases now covers only legacy rows saved before holders were stamped (no `leaseController` key). Stamped rows are never released by age. - `holderUnchanged` is now exported from the run-lease settle so both settles share it. - The controller stamp (`leaseController`, and every other `ControllerProcess` stamp) now records the platform deployment when the platform names one (Railway: project, environment, service, deployment, volume). `controllerStamp` passes it to `proveOwnerGone`. A container redeploy changes the PID space, and the old stamp would read `different_process_space` (unprovable) forever. Now the existing `deployment_replaced` proof shows the old holder gone. This also covers `environment_run_lease_active` and the other kinds built on `controllerStamp`. - `currentDeploymentIdentity` in `owner-proof.ts` now delegates to `currentControllerDeployment`, so there is one parser. - Data doc: added the final Daytona wave, the six Daytona-only crash points (4 pass, 2 not reached, no violations), and the cause of the roughly 450 s codex TTFT. Runs waited behind the agent's harness verification, which restarted after each fault; boot itself took about 32 s. - Restart-chaos README: corrected which Daytona crash points a plain task reaches. ## Verification - Red, before the change: `environment-login-probe-lease-ownership.test.ts` failed 3 of 6. No settle happened, and the idle sweep released 3 live holders' leases. - Green: `pnpm exec vitest run` passes for `environment-login-probe-lease-ownership`, `environment-run-lease-ownership` and `heartbeat-orphaned-active-lease-sweep` (45/45), and for `environment-lease-owner-stamps`, `heartbeat-observed-workspace-preservation`, `ownership-reconciliation-report`, `heartbeat/recovery` and `workspace-recovery-warm-handoff`. - Redeploy: a stamp from a replaced Railway deployment settles with `deployment_replaced`. A stamp from the same deployment in another PID space stays `active`. This was red when `controllerStamp` dropped the deployment. - Live-owner counter-cases: the current process, another live PID, another PID space, and a lease re-stamped after the proof all stay `active`. A read-only pass never settles. - Not re-run live: the Daytona wave and crash points ran before this fix. The next Daytona wave should show no `environment_login_probe_lease` I2. ## Risks - A stamped run-less lease whose live holder never releases it, because of a bug in that process, is no longer released after five idle minutes. It is released once that process exits. This is the intended trade: never take a live holder's sandbox. - On an ephemeral server without a platform deployment identity (not Railway), a stamp from a replaced container is still unprovable. Such a run-less lease stays `active` until the provider expires the sandbox, and the reconciler reports it as unprovable. Before this PR, the five-minute sweep released it by age. This is the same open gap as unstamped legacy leases on an ephemeral server: there is no operator action yet. It is listed as a Phase 2 follow-up. - Legacy unstamped run-less leases keep the age fallback. These are rows written before #19965, and no current code writes them. ## 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>
332 lines
15 KiB
TypeScript
332 lines
15 KiB
TypeScript
import { hostname } from "node:os";
|
|
import {
|
|
currentControllerBootId,
|
|
currentControllerDeployment,
|
|
currentControllerProcessStamp,
|
|
currentProcessSpaceHash,
|
|
observedProcessStartedAt,
|
|
processExists,
|
|
processStartsDefinitelyDiffer,
|
|
PROCESS_START_PRECISION_MS,
|
|
UNKNOWN_PROCESS_SPACE,
|
|
} from "./controller-process-identity.js";
|
|
import type { WorkspaceWorker } from "./native-runtime/native-workspace-platform-proof.js";
|
|
import { ephemeralServerEnabled } from "./ephemeral-server-mode.js";
|
|
|
|
/** `processSpace` value written when a board operator confirmed that this exact
|
|
* recorded owner stopped (native-controller-host-recovery.ts). The attestation
|
|
* is the proof. */
|
|
export const OPERATOR_ATTESTED_PROCESS_SPACE = "operator_attested";
|
|
|
|
/**
|
|
* The owner recorded on an in-progress durable record (plan section A).
|
|
*
|
|
* A local owner is a process in a PID space. A remote owner is a fenced lease:
|
|
* ownership moves by fence token and generation, never by a process proof,
|
|
* because a remote runner keeps running across a control-plane replacement.
|
|
*/
|
|
export type OwnerStamp = LocalOwnerStamp | RemoteOwnerStamp;
|
|
|
|
export type LocalOwnerStamp = {
|
|
kind: "local";
|
|
/** Record kind or subsystem that wrote the stamp, for reports only. */
|
|
scope: string;
|
|
generation: number | null;
|
|
/** Hostname at stamp time. Informational; `processSpace` is the proof key. */
|
|
host: string | null;
|
|
/** Hash of host, kernel boot and PID namespace (`currentProcessSpaceHash`). */
|
|
processSpace: string | null;
|
|
bootId: string | null;
|
|
pid: number | null;
|
|
processStartedAt: string | null;
|
|
processGroupId?: number | null;
|
|
/** Start time of the process group's leader (PID = `processGroupId`). When
|
|
* the owner leads its own group this is `processStartedAt`. */
|
|
processGroupStartedAt?: string | null;
|
|
/** The platform deployment that ran the owner, when the platform names one. */
|
|
worker?: WorkspaceWorker | null;
|
|
/** The platform volume mounted by that deployment, when the stamp recorded one. */
|
|
volumeId?: string | null;
|
|
/** False when the owner's deployment was not volume-exclusive. Missing on older stamps. */
|
|
exclusiveVolume?: boolean | null;
|
|
/**
|
|
* The record was saved before its kind stored a PID space (`processSpace`
|
|
* is null for that reason, not because the space is unknown). Such rows keep
|
|
* the previous local proof for one release on single-host servers only, and
|
|
* the proof says so (`legacyHostFallback`) so the caller can record each use.
|
|
* An ephemeral (multi-host) server never judges them.
|
|
*/
|
|
processSpaceUnrecorded?: boolean;
|
|
};
|
|
|
|
export type RemoteOwnerStamp = {
|
|
kind: "remote";
|
|
scope: string;
|
|
generation: number | null;
|
|
/** The fence or lease token the owner holds, such as `native_run_finalizations.lease_owner`. */
|
|
fenceToken: string | null;
|
|
provider?: string | null;
|
|
};
|
|
|
|
export type OwnerVerdict = "gone" | "alive" | "unprovable";
|
|
|
|
export type OwnerProof = {
|
|
verdict: OwnerVerdict;
|
|
reason: OwnerProofReason;
|
|
/** The local proof judged a row saved before its PID space was recorded. */
|
|
legacyHostFallback?: true;
|
|
/** The caller named every run this process executes, so a record of another
|
|
* run is not held here (`settleOwnableKind` with `currentProcessIdle`). */
|
|
localExecutionsKnown?: true;
|
|
};
|
|
|
|
export type OwnerProofReason =
|
|
// gone
|
|
| "pid_dead"
|
|
| "pid_reused"
|
|
| "fence_superseded"
|
|
| "generation_superseded"
|
|
| "provider_stopped"
|
|
| "deployment_replaced"
|
|
| "operator_attested"
|
|
/** This process owns the record and is not executing its run. Only a
|
|
* caller that knows this process's executions may conclude it
|
|
* (`settleOwnableKind` with `currentProcessIdle`); the prover never does. */
|
|
| "current_process_idle"
|
|
// alive
|
|
| "current_process"
|
|
| "pid_alive"
|
|
| "process_group_alive"
|
|
| "fence_held"
|
|
| "provider_running"
|
|
// unprovable
|
|
| "missing_identity"
|
|
| "unknown_process_space"
|
|
| "different_process_space"
|
|
| "different_host"
|
|
| "process_space_unrecorded"
|
|
| "pid_start_unreadable"
|
|
| "pid_start_inconsistent"
|
|
| "process_group_ambiguous"
|
|
| "fence_expired_without_provider_proof";
|
|
|
|
/** The state a remote owner is judged against, read by the caller from the
|
|
* durable record that holds the fence. */
|
|
export type RemoteFenceState = {
|
|
/** The token that currently holds the fence, or null when released. */
|
|
currentFenceToken: string | null;
|
|
currentGeneration: number | null;
|
|
/** When the current fence expires. Expiry alone never proves an owner gone. */
|
|
leaseExpiresAt: Date | null;
|
|
/** A provider proof for the stamped lease, when the provider gives one
|
|
* (for example a remote termination receipt). */
|
|
provider?: "stopped" | "running" | "unknown";
|
|
};
|
|
|
|
/** OS access, injectable so the proof matrix is testable without real PIDs. */
|
|
export type ProcessProbe = {
|
|
bootId: string;
|
|
host: string;
|
|
processSpace: string;
|
|
pidExists(pid: number): boolean;
|
|
processGroupExists(processGroupId: number): boolean;
|
|
readStartedAt(pid: number): Promise<Date | null>;
|
|
/** This process's platform deployment. `exclusiveVolume` is true only when
|
|
* the platform stops every earlier deployment of the service before it
|
|
* starts this one (a Railway service with an attached volume). */
|
|
deployment?: { worker: WorkspaceWorker; exclusiveVolume: boolean; volumeId?: string | null } | null;
|
|
};
|
|
|
|
function processGroupExists(processGroupId: number): boolean {
|
|
if (process.platform === "win32") return false;
|
|
try {
|
|
process.kill(-processGroupId, 0);
|
|
return true;
|
|
} catch (error) {
|
|
// EPERM: the group has members owned by another user. Alive.
|
|
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
/** Railway mounts a volume into one deployment of a service at a time: it stops
|
|
* the previous deployment before the new one starts, so the two never overlap.
|
|
* A service with a volume cannot have replicas. */
|
|
export function currentDeploymentIdentity(env: NodeJS.ProcessEnv = process.env): ProcessProbe["deployment"] {
|
|
return currentControllerDeployment(env);
|
|
}
|
|
|
|
export function currentProcessProbe(): ProcessProbe {
|
|
return {
|
|
deployment: currentDeploymentIdentity(),
|
|
bootId: currentControllerBootId(),
|
|
host: hostname(),
|
|
processSpace: currentProcessSpaceHash(),
|
|
pidExists: processExists,
|
|
processGroupExists,
|
|
readStartedAt: async pid => {
|
|
const startedAt = await observedProcessStartedAt(pid);
|
|
return startedAt === null ? null : new Date(startedAt);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The stamp this process writes when it takes ownership of a record. It does
|
|
* no I/O: the start time comes from an earlier resolved identity read, and is
|
|
* null until one has happened. A stamp without a start time still proves exit
|
|
* when its PID is gone; a live PID then stays unprovable, which fails closed.
|
|
*
|
|
* It records no process group: the control-plane server owns the record, and
|
|
* its provider children are tracked by their own records (the run's process
|
|
* identity, the runner's startup attempt). A caller that stamps a child it
|
|
* leads must set `processGroupId` and `processGroupStartedAt` itself; without
|
|
* them a surviving group is never judged at all. */
|
|
export function currentLocalOwnerStamp(scope: string, generation: number | null = null): LocalOwnerStamp {
|
|
const controller = currentControllerProcessStamp();
|
|
return {
|
|
kind: "local", scope, generation, host: hostname(), processSpace: controller.hostHash,
|
|
bootId: controller.bootId, pid: controller.pid, processStartedAt: controller.processStartedAt,
|
|
processGroupId: null,
|
|
};
|
|
}
|
|
|
|
const validPid = (pid: number | null | undefined): pid is number =>
|
|
typeof pid === "number" && Number.isInteger(pid) && pid > 0;
|
|
|
|
/**
|
|
* Proves whether the owner in a stamp is gone. Proof is by identity, never by
|
|
* age. `unprovable` must never release a record.
|
|
*
|
|
* Local rules (one prover for every kind):
|
|
* - this process's boot id is alive;
|
|
* - the stamp must name this PID space (host, kernel boot, PID namespace); a
|
|
* different host or namespace is never proof;
|
|
* - the PID must be dead, or reused: a live PID that started at least 1 s
|
|
* after the stamp (`ps lstart` has 1 s precision). An earlier start is
|
|
* inconsistent and unprovable. EPERM is alive;
|
|
* - the recorded process group must be empty, or led by a process that
|
|
* started definitely after the recorded group leader. A non-empty group without a
|
|
* recorded leader start time is unprovable: it may hold surviving children.
|
|
*
|
|
* Remote rules: a superseded fence token or generation, or a provider proof
|
|
* that the stamped lease stopped, is gone. A matching live fence is alive. A
|
|
* matching fence that only expired, with no provider proof, is unprovable.
|
|
*/
|
|
export async function proveOwnerGone(
|
|
stamp: LocalOwnerStamp,
|
|
probe?: ProcessProbe,
|
|
): Promise<OwnerProof>;
|
|
export async function proveOwnerGone(
|
|
stamp: RemoteOwnerStamp,
|
|
fence: RemoteFenceState,
|
|
now?: Date,
|
|
): Promise<OwnerProof>;
|
|
export async function proveOwnerGone(
|
|
stamp: OwnerStamp,
|
|
context?: ProcessProbe | RemoteFenceState,
|
|
now: Date = new Date(),
|
|
): Promise<OwnerProof> {
|
|
if (stamp.kind === "remote") return proveRemoteOwnerGone(stamp, context as RemoteFenceState, now);
|
|
return proveLocalOwnerGone(stamp, (context as ProcessProbe | undefined) ?? currentProcessProbe());
|
|
}
|
|
|
|
async function proveLocalOwnerGone(stamp: LocalOwnerStamp, probe: ProcessProbe): Promise<OwnerProof> {
|
|
if (stamp.bootId && stamp.bootId === probe.bootId) return { verdict: "alive", reason: "current_process" };
|
|
// The platform stopped the owner's whole deployment, with every process and
|
|
// orphaned child in it, before it started this one. Host and PID are moot.
|
|
if (deploymentReplaced(stamp, probe.deployment)) return { verdict: "gone", reason: "deployment_replaced" };
|
|
if (stamp.processSpace === OPERATOR_ATTESTED_PROCESS_SPACE) return { verdict: "gone", reason: "operator_attested" };
|
|
// A row saved before its kind stored a PID space: one-release local
|
|
// fallback on single-host servers; an ephemeral server cannot assume the
|
|
// PID was recorded here, so a board operator decides.
|
|
const legacy = !stamp.processSpace && stamp.processSpaceUnrecorded === true;
|
|
if (legacy && ephemeralServerEnabled()) return { verdict: "unprovable", reason: "process_space_unrecorded" };
|
|
if (!validPid(stamp.pid) || (!stamp.processSpace && !legacy)) return { verdict: "unprovable", reason: "missing_identity" };
|
|
if (legacy) {
|
|
const proof = await proveLocalOwnerGone({ ...stamp, processSpace: probe.processSpace, host: null,
|
|
processSpaceUnrecorded: false }, probe);
|
|
return proof.verdict === "gone" ? { ...proof, legacyHostFallback: true } : proof;
|
|
}
|
|
if (stamp.processSpace === UNKNOWN_PROCESS_SPACE || probe.processSpace === UNKNOWN_PROCESS_SPACE) {
|
|
return { verdict: "unprovable", reason: "unknown_process_space" };
|
|
}
|
|
if (stamp.host && stamp.host !== probe.host) return { verdict: "unprovable", reason: "different_host" };
|
|
if (stamp.processSpace !== probe.processSpace) return { verdict: "unprovable", reason: "different_process_space" };
|
|
|
|
let processReason: "pid_dead" | "pid_reused";
|
|
if (!probe.pidExists(stamp.pid)) {
|
|
processReason = "pid_dead";
|
|
} else {
|
|
const observed = await probe.readStartedAt(stamp.pid);
|
|
if (!stamp.processStartedAt || observed === null) {
|
|
return { verdict: "unprovable", reason: "pid_start_unreadable" };
|
|
}
|
|
const order = compareStarts(stamp.processStartedAt, observed);
|
|
if (order === "same") return { verdict: "alive", reason: "pid_alive" };
|
|
// A reused PID belongs to a later process. An earlier start means the
|
|
// stamp is inconsistent (clock skew or a migrated value): never proof.
|
|
if (order !== "later") return { verdict: "unprovable", reason: "pid_start_inconsistent" };
|
|
processReason = "pid_reused";
|
|
}
|
|
|
|
const group = stamp.processGroupId;
|
|
if (validPid(group) && probe.processGroupExists(group)) {
|
|
// A non-empty group can hold the owner's surviving children, including
|
|
// detached ones started after the owner. The kernel never reuses a PID
|
|
// while a group with that id has members, so the group is a different one
|
|
// only when its leader is alive and is definitely not the recorded leader.
|
|
if (!probe.pidExists(group)) return { verdict: "alive", reason: "process_group_alive" };
|
|
const recordedLeaderStart = group === stamp.pid ? stamp.processStartedAt : stamp.processGroupStartedAt ?? null;
|
|
const leaderStartedAt = await probe.readStartedAt(group);
|
|
if (!recordedLeaderStart || leaderStartedAt === null) {
|
|
return { verdict: "unprovable", reason: "process_group_ambiguous" };
|
|
}
|
|
const order = compareStarts(recordedLeaderStart, leaderStartedAt);
|
|
if (order === "same") return { verdict: "alive", reason: "process_group_alive" };
|
|
if (order !== "later") return { verdict: "unprovable", reason: "process_group_ambiguous" };
|
|
}
|
|
return { verdict: "gone", reason: processReason };
|
|
}
|
|
|
|
function deploymentReplaced(stamp: LocalOwnerStamp, current: ProcessProbe["deployment"]): boolean {
|
|
const stamped = stamp.worker;
|
|
if (!stamped || !current?.exclusiveVolume) return false;
|
|
// A stamp that recorded its volume must name this same volume. Older stamps
|
|
// predate the field; the service and environment then name the volume.
|
|
if (stamp.exclusiveVolume === false) return false;
|
|
if (stamp.volumeId != null && stamp.volumeId !== current.volumeId) return false;
|
|
const worker = current.worker;
|
|
return stamped.provider === worker.provider && stamped.projectId === worker.projectId
|
|
&& stamped.environmentId === worker.environmentId && stamped.serviceId === worker.serviceId
|
|
&& typeof stamped.deploymentId === "string" && stamped.deploymentId.length > 0
|
|
&& stamped.deploymentId !== worker.deploymentId;
|
|
}
|
|
|
|
/** "same" within `ps lstart` precision (1 s), "later" when the observed process
|
|
* started definitely after the recorded one, "earlier" when definitely before,
|
|
* "unknown" when either time is unreadable. */
|
|
function compareStarts(recorded: string, observed: Date): "same" | "later" | "earlier" | "unknown" {
|
|
const left = Date.parse(recorded);
|
|
const right = observed.getTime();
|
|
if (!Number.isFinite(left) || !Number.isFinite(right)) return "unknown";
|
|
if (!processStartsDefinitelyDiffer(recorded, observed)) return "same";
|
|
return right - left >= PROCESS_START_PRECISION_MS ? "later" : "earlier";
|
|
}
|
|
|
|
function proveRemoteOwnerGone(stamp: RemoteOwnerStamp, fence: RemoteFenceState, now: Date): OwnerProof {
|
|
// The identity check comes first: a provider proof for the current lease
|
|
// cannot be tied to a stamp that names no token.
|
|
if (!stamp.fenceToken) return { verdict: "unprovable", reason: "missing_identity" };
|
|
if (fence.provider === "stopped") return { verdict: "gone", reason: "provider_stopped" };
|
|
if (stamp.generation !== null && fence.currentGeneration !== null && fence.currentGeneration > stamp.generation) {
|
|
return { verdict: "gone", reason: "generation_superseded" };
|
|
}
|
|
if (fence.currentFenceToken !== stamp.fenceToken) {
|
|
// Released (null) or handed to another owner: the stamped owner no longer
|
|
// holds authority, whether or not its process still runs.
|
|
return { verdict: "gone", reason: "fence_superseded" };
|
|
}
|
|
if (fence.provider === "running") return { verdict: "alive", reason: "provider_running" };
|
|
if (fence.leaseExpiresAt && fence.leaseExpiresAt > now) return { verdict: "alive", reason: "fence_held" };
|
|
return { verdict: "unprovable", reason: "fence_expired_without_provider_proof" };
|
|
}
|