Files
PaperClipAI/server/src/services/controller-process-identity.ts
T
DottaandClaude Opus 5.5 630411af53 fix(recovery): require a 1 s start-time gap and treat EPERM as alive in owner proofs (#19848)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a restart, recovery code decides whether the process that owns
an in-progress run is gone
> - Two proofs compare process start times to the millisecond, but `ps
-o lstart` on macOS and BSD reports whole seconds
> - So a live owner can look like a reused PID, and recovery can release
it while it still runs
> - Signal-0 handling also disagrees: one check treats EPERM as dead,
while all others treat it as alive
> - This pull request puts one start-time rule and one EPERM rule in
`controller-process-identity.ts` and uses them at every site the Phase 0
ownership inventory named
> - The benefit is that no proof releases a live owner because of clock
precision or a permission error

## Linked Issues or Issue Description

**Bug description**
The Phase 0 ownership inventory (finding 1) found inconsistent "owner
gone" proofs. `native-failed-run-owner-release.ts`
(`recordedProcessMayBeLive`, `runProcessMayBeLive`) and
`native-session-executor.ts` (`verifiedRecoveryProcessIsAlive`) treat
any start-time difference of 1 ms or more as a reused PID.
`native-restart-recovery.ts` and `controllerProcessExited` require a gap
of at least 1 s. `local-service-supervisor.ts` (`isPidAlive`,
`isProcessGroupAlive`) treats EPERM as dead.

**Steps to reproduce**
1. On macOS, record a live process start time with millisecond
precision.
2. Run the abandoned-owner release pass, or the recovered-runner
liveness check, against that live PID.
3. The pass releases the owner, or the check reports the runner as gone.

**Expected behavior**
A sub-second start-time gap is ambiguous and keeps the owner. EPERM
means the process exists.

Refs #19846

## What Changed

- `controller-process-identity.ts`: export `processExists` (EPERM =
alive) and `processStartsDefinitelyDiffer` (gap >= 1 s; a missing time
proves nothing). `controllerProcessExited` uses the new helper.
- `native-failed-run-owner-release.ts`: the controller and run-process
checks use the shared helpers instead of exact millisecond equality.
- `native-session-executor.ts`: `verifiedRecoveryProcessIsAlive` accepts
a start time within 1 s as the same runner. Its signal path uses the
same check.
- `native-restart-recovery.ts`: `definitivelyDifferentProcessStart`
delegates to the shared helper. No behavior change.
- `local-service-supervisor.ts`: `isPidAlive` and `isProcessGroupAlive`
treat EPERM as alive.

## Verification

- New `controller-process-identity.test.ts`: the 1 s boundary, EPERM and
ESRCH for every signal-0 check, and a live spawned runner whose recorded
start is 500 ms off.
- `native-failed-run-owner-release.test.ts`: two new cases (controller
and run process) with a live child and a recorded start 500 ms off. The
owner stays `observed`.
- Red before the fix: with the old call sites, all 4 new cases fail
(`releaseAbandonedNativeOwners` returns 1 and releases the live owner;
EPERM returns false; the live runner reads as gone). Green after the
fix.
- `npx vitest run src/services/controller-process-identity.test.ts
src/services/native-runtime/native-failed-run-owner-release.test.ts
src/services/native-runtime/native-restart-recovery.test.ts
src/__tests__/local-service-supervisor.test.ts`: 59 passed.

## Risks

- Low risk. The change only makes proofs stricter: a sub-second gap or
EPERM now keeps an owner that the old code released. Such a row waits
for the existing lease, TTL, or real process exit.
- A recycled PID whose new process started within 1 s of the recorded
one now stays held until it exits. That is the same rule the takeover
path already used.
- Previous-release story: no persisted shape changes. Rows saved by the
previous release are evaluated with the stricter rule.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used with tool use and
code execution in Claude Code.

## 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 10:06:35 -06:00

113 lines
4.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 };
const bootId = randomUUID();
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();
let processStartedAt: Promise<string | null> | undefined;
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;
}
}
export function currentControllerBootId(): string {
return bootId;
}
export async function currentControllerProcess(): Promise<ControllerProcess> {
processStartedAt ??= observedProcessStartedAt(process.pid)
.then(value => value === null ? null : new Date(value).toISOString());
return { bootId, pid: process.pid, hostHash, processStartedAt: await processStartedAt };
}
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;
// 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));
}