Files
PaperClipAI/server/src/services/controller-process-identity.ts
T
DottaandClaude Opus 5.5 ff53eb84db feat(recovery): add OwnerStamp and proveOwnerGone (gone | alive | unprovable) (#19853)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Restart recovery must decide when a dead process's in-progress
record may be released
> - About 31 sites decide "owner gone" today, with different rules for
EPERM, PID reuse and other hosts (Phase 0 inventory, finding 1)
> - The durable ownership plan (section A) asks for one owner stamp and
one prover that returns `gone | alive | unprovable`
> - This pull request adds that stamp and prover. Nothing calls it yet;
the next PR uses it in a report-only reconciler
> - The benefit is one tested definition of "owner gone" that later
phases move each record kind onto

## Linked Issues or Issue Description

Refs #19831 (plan, section A and Phase 1). Refs #19846 (Phase 0
inventory). Refs #19848 (Track 1: 1 s start-time gap and EPERM as
alive). Stacked on #19851.

**Problem**
Each subsystem proves "owner gone" with its own rules. Some accept a
dead PID on another host as proof. None returns "unprovable".

**Expected behavior**
One prover with one set of rules. A different host and an expired fence
alone are never proof. "Unprovable" never releases a record.

**Impact**
This is the primitive that Phase 1's report-only reconciler and Phase
2's migrations use.

## What Changed

- New `server/src/services/owner-proof.ts`:
- `OwnerStamp`: a local stamp (`host`, `processSpace`, `bootId`, `pid`,
`processStartedAt`, `processGroupId`, `generation`, `scope`) or a remote
stamp (`fenceToken`, `generation`, `scope`, `provider`).
- `proveOwnerGone(stamp, …)` returns `{ verdict: "gone" | "alive" |
"unprovable", reason }`.
- Local rules: this boot is alive; the stamp must name this host and PID
space (host, kernel boot, PID namespace); the PID must be dead or reused
(start-time gap of at least 1 s, via Track 1's
`processStartsDefinitelyDiffer`); EPERM is alive; the process group must
be empty or led by a newer process.
- Remote rules: a superseded fence token or generation is gone; a
provider stop proof is gone; a held fence is alive; a matching fence
that only expired is unprovable. Process liveness is never consulted for
a remote owner.
- `currentLocalOwnerStamp()` reads only cached identity, so writing a
stamp adds no OS call to the turn path.
- `controller-process-identity.ts` exports `observedProcessStartedAt`
and `UNKNOWN_PROCESS_SPACE` for the prover.
- New `owner-proof.test.ts`: the proof matrix (EPERM, ESRCH, PID reuse
inside and at 1 s, unreadable start time, a different host, a different
or unknown PID space, process groups, a held, superseded, expired or
provider-proved remote fence).

## Verification

- `npx vitest run src/services/owner-proof.test.ts` (in `server/`): 20
tests pass.
- `npx tsc --noEmit -p server`: no errors.

## Risks

- Low risk. No caller uses the prover yet, so no behavior changes.
- Previous-release story (`rules/upgrade-safe-run-state.mdc`): no
persisted shape changes. Stamps from older rows that lack fields return
`unprovable` (`missing_identity`), which never releases a record.
- Track 1 is still fixing proofs in place. The prover reuses Track 1's
helpers (`processExists`, `processStartsDefinitelyDiffer`), so a later
Track 1 rule change applies here too.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in 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
- [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>
2026-11-07 13:04:55 -06:00

178 lines
7.9 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";
/**
* 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 ControllerProcess = { bootId: string; pid: number; hostHash: string; processStartedAt: string | 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;
}
export async function currentControllerProcess(): Promise<ControllerProcess> {
const startedAt = await currentProcessStartedAtMs();
const iso = startedAt === null ? null : new Date(startedAt).toISOString();
resolvedProcessStartedAt = iso;
return { bootId, pid: process.pid, hostHash, processStartedAt: iso };
}
/**
* 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 {
return { bootId, pid: process.pid, hostHash, processStartedAt: resolvedProcessStartedAt };
}
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;
return { bootId: record.bootId, pid: record.pid, hostHash: record.hostHash,
processStartedAt: typeof record.processStartedAt === "string" ? record.processStartedAt : null };
}
/**
* 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;
}