/** * Run ownership and process-identity validation — the single gate every control * path goes through before it touches a process or bulk-mutates someone else's run. * * Two separate mistakes are prevented here, and both were live defects: * * 1. **Signalling a recorded pid.** `status.json` outlives the process it * describes. A pid read back from disk may belong to anything by the time it is * used, and `SIGUSR2`'s default disposition is *terminate*, so a "nudge" to a * recycled pid kills an unrelated process. R-CTRL-27/28 already forbid acting * on pid liveness alone for reclamation; the same proof is required before a * signal. `validateSignalTarget` is the only sanctioned way to turn a recorded * pid into a pid it is safe to signal. * * 2. **Bulk-stopping runs this orchestrator does not own.** `runs/` is shared by * every pi session in the repo. `stop_all`, toggle OFF and `session_shutdown` * must stop *this* session's work, not a concurrent session's — a terminal stop * is unresumable, so a wrong one destroys work permanently (R-TOOL-17). */ import * as os from "node:os"; import { getProcessStartIdentity, isProcessAlive, proveDeath, type LockOwner, } from "../proof-of-death.ts"; import type { RunStatus } from "./status.ts"; /** The orchestrator session that spawned (or adopted) a run. */ export interface RunOwner { /** pi session id of the owning orchestrator. */ sessionId: string; pid: number; hostname: string; processStartIdentity?: string; claimedAt: string; } export interface OwnershipDeps { hostname?: string; alive?: (pid: number) => boolean; currentIdentity?: (pid: number) => string | undefined; now?: number; } export function currentOwner(sessionId: string, now = Date.now()): RunOwner { const pid = process.pid; const identity = getProcessStartIdentity(pid); return { sessionId, pid, hostname: os.hostname(), claimedAt: new Date(now).toISOString(), ...(identity === undefined ? {} : { processStartIdentity: identity }), }; } export function readOwner(status: RunStatus): RunOwner | undefined { const raw = (status as { owner?: unknown }).owner; if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; const record = raw as Record; if ( typeof record.sessionId !== "string" || record.sessionId.length === 0 || typeof record.pid !== "number" || !Number.isInteger(record.pid) || typeof record.hostname !== "string" || typeof record.claimedAt !== "string" ) return undefined; return { sessionId: record.sessionId, pid: record.pid, hostname: record.hostname, claimedAt: record.claimedAt, ...(typeof record.processStartIdentity === "string" ? { processStartIdentity: record.processStartIdentity } : {}), }; } export type OwnershipVerdict = | { owned: true; reason: "same-session" | "live-pump" | "owner-dead" } | { owned: false; reason: string }; /** * Is this run in scope for a bulk terminal action by `self`? * * `livePump` short-circuits everything: a run this process is actively pumping is * unambiguously ours whatever the file says. Otherwise the recorded owner decides, * and an owner on another host or in a live foreign session is out of scope. A run * whose owner is *provably dead* (R-CTRL-27) is adoptable, because nothing is * driving it and leaving it running is the worse failure. */ export function ownershipOf( status: RunStatus, self: RunOwner, options: { livePump?: boolean } & OwnershipDeps = {}, ): OwnershipVerdict { if (options.livePump === true) return { owned: true, reason: "live-pump" }; const owner = readOwner(status); if (owner === undefined) { return { owned: false, reason: "run records no owning orchestrator session; only its own session or an explicit runId may stop it" }; } if (owner.sessionId === self.sessionId && owner.hostname === self.hostname) return { owned: true, reason: "same-session" }; if (owner.hostname !== self.hostname) { return { owned: false, reason: `run is owned by session ${owner.sessionId} on host '${owner.hostname}'` }; } const alive = options.alive ?? isProcessAlive; const identity = options.currentIdentity ?? getProcessStartIdentity; const holder: LockOwner = { pid: owner.pid, sessionId: owner.sessionId, hostname: owner.hostname, claimedAt: owner.claimedAt, ...(owner.processStartIdentity === undefined ? {} : { processStartIdentity: owner.processStartIdentity }), }; const verdict = proveDeath(holder, { hostname: self.hostname, alive: alive(owner.pid), currentIdentity: identity(owner.pid), ...(options.now === undefined ? {} : { now: options.now }), }); if (verdict.reclaimable) return { owned: true, reason: "owner-dead" }; return { owned: false, reason: `run is owned by live session ${owner.sessionId} (pid ${owner.pid}): ${verdict.reason}` }; } /** What a control path knows about a run's process, straight off disk. */ export interface RecordedProcess { pid: number | null; processStartIdentity: string | null; hostname: string; } export type SignalTargetVerdict = | { ok: true; pid: number } | { ok: false; reason: string }; export interface SignalDeps { hostname?: string; self?: number; alive?: (pid: number) => boolean; currentIdentity?: (pid: number) => string | undefined; } /** * Positive proof that `recorded.pid` is still the process that was recorded, and * therefore safe to signal. * * Refusal is cheap and correct: R-CTRL-1 makes the filesystem request * authoritative, and the signal is only a latency optimisation (R-CTRL-2). There * is no case in which guessing is better than not signalling. */ export function validateSignalTarget(recorded: RecordedProcess, deps: SignalDeps = {}): SignalTargetVerdict { const pid = recorded.pid; if (pid === null) return { ok: false, reason: "no pid is recorded for this run" }; if (!Number.isInteger(pid) || pid <= 1) return { ok: false, reason: `recorded pid ${String(pid)} is not a signalable process id` }; const self = deps.self ?? process.pid; if (pid === self) return { ok: false, reason: "recorded pid is this orchestrator; refusing to signal ourselves" }; const hostname = deps.hostname ?? os.hostname(); if (recorded.hostname !== hostname) { return { ok: false, reason: `run was started on host '${recorded.hostname}'; a pid from another host is never signalled` }; } const alive = deps.alive ?? isProcessAlive; if (!alive(pid)) return { ok: false, reason: `pid ${pid} is gone` }; // R-CTRL-28: liveness alone is never enough. Without a recorded identity to // compare, an alive pid is indistinguishable from a reused one. if (recorded.processStartIdentity === null) { return { ok: false, reason: `pid ${pid} is alive but no process start identity was recorded; refusing to signal a possibly reused pid` }; } const identity = (deps.currentIdentity ?? getProcessStartIdentity)(pid); if (identity === undefined) { return { ok: false, reason: `pid ${pid} is alive but its start identity is unreadable; refusing to signal a possibly reused pid` }; } if (identity !== recorded.processStartIdentity) { return { ok: false, reason: `pid ${pid} was reused: start identity ${identity} differs from the recorded ${recorded.processStartIdentity}` }; } return { ok: true, pid }; } export function recordedProcessOf(status: RunStatus): RecordedProcess { return { pid: status.pid, processStartIdentity: status.processStartIdentity, hostname: status.hostname, }; }