/** * Holder records for the governor's local class slots (T12963, gh#1222). The * heavy classes and evidence runs moved to the admission ledger (T13133); the * classes left (`agent-session`, `db-heavy`, …) still use slots. * * `proper-lockfile` frees a slot whose holder died only after * {@link SLOT_LOCK_STALE_MS} (10 min) of mtime staleness. With a one-slot * `test-run` budget, a SIGKILLed `cleo verify tool:test` blocked every later * heavy run for ten minutes, and the deferral named no one. Each slot carries * a `.holder.json` record: the holder's pid and process start time, the * lock directory it describes, and the process groups of the tools it started. * A slot is reaped only when the holder pid is provably gone (or recycled) AND * every one of those tool groups is gone too: a tool started detached outlives * a SIGKILLed cleo, and it is the tool that uses the machine. * * Start times are `ps -o lstart=` strings rendered under `PS_STABLE_ENV` (UTC, * C locale), so a string written by one process compares equal to the same * process's start time read by another, whatever either one's `TZ` or locale. * * @task T12963 */ import type { ResourceClass } from '@cleocode/contracts'; /** * A slot lock older than this may be stolen (its holder presumed dead): the * `proper-lockfile` stale window of every governor slot. */ export declare const SLOT_LOCK_STALE_MS = 600000; /** * How often a governor slot holder refreshes its lock. Short, so a holder * frozen by a job pause keeps its slot for nearly the whole stale window * (#1777 round 6). */ export declare const SLOT_LOCK_UPDATE_MS = 15000; /** * The liveness fields every slot holder record carries. * * @task T12963 */ export interface SlotHolderIdentity { /** OS process id of the holder. */ pid: number; /** Host the pid belongs to. Liveness is only judged on this host. */ host: string; /** * The holder's process start time (`ps -o lstart=` under `PS_STABLE_ENV`), * or `null` when `ps` could not say. A live process at `pid` with a * different start time is not the holder: the pid was recycled. Absent from * records written before T12963. */ startedAt?: string | null; /** * The lock directory this record describes (`:`), or * `null` when it has no usable identity. A record whose lock was since * replaced, or that has no identity, never authorises a reap. */ lockId?: string | null; /** * Process groups of the tools the holder started while holding the slot. * The slot stays held while any of them has a member. */ toolGroups?: readonly number[]; } /** * Who holds a local governor slot, written to `.holder.json` once the * lock is taken. * * @task T12963 */ export interface GovernorSlotHolder extends SlotHolderIdentity { /** The holder's process start time, or `null` when `ps` could not say. */ startedAt: string | null; /** The lock directory this record describes, or `null` when it has none. */ lockId: string | null; /** Class the slot belongs to. */ cls: ResourceClass; /** Wall-clock acquisition time (epoch ms), for deferral messages. */ acquiredAtMs: number; } /** * How a holder's pid and tool groups are probed. Tests inject one; production * uses `kill(pid, 0)`, `kill(-pgid, 0)` and `ps`. * * @task T12963 */ export interface PidProbe { /** `gone` (no such process), `alive`, or `unknown` when the probe itself failed. */ liveness(pid: number): 'gone' | 'alive' | 'unknown'; /** The process start time as `ps -o lstart=` prints it, or `null` when it cannot be read. */ startedAt(pid: number): string | null; /** `gone` (no member left), `alive`, or `unknown` when the probe failed. */ groupLiveness(pgid: number): 'gone' | 'alive' | 'unknown'; } /** * Verdict on a slot's recorded holder. Only `dead` frees the slot; `unknown` * keeps it held, because reaping a live holder would admit two heavy runs * against one budget. * * @task T12963 */ export type GovernorHolderState = 'alive' | 'dead' | 'unknown'; /** * This process's start time (`ps -o lstart=` under `PS_STABLE_ENV`), read once * per process, or `null` when `ps` could not say. * * @returns The start time, or `null`. * * @task T12963 */ export declare function ownProcessStartedAt(): string | null; /** * The system probe: `kill(pid, 0)`, `kill(-pgid, 0)` and a cached `ps` start * time (the same format {@link ownProcessStartedAt} writes). * * @task T12963 * @task T13133 */ export declare const systemPidProbe: PidProbe; /** * A lock directory's identity: `:`, or `null` when the * filesystem reports no birth time (NFS, FUSE, older kernels report 0). An * inode alone can be reused by the next lock, so such a lock has no identity * and is never reaped (T12963 re-review F3). * * @param ino - The lock directory's inode. * @param birthtimeMs - Its birth time, as `fs.Stats.birthtimeMs`. * @returns The identity, or `null`. * * @internal Exported for tests. * @task T12963 */ export declare function lockIdentity(ino: number, birthtimeMs: number): string | null; /** * The identity of a slot's current lock directory, for a holder record's * `lockId`, or `null` when there is no lock or it has no usable identity. * * @param slotPath - Slot lock file path. * @returns The lock identity, or `null`. * * @task T12963 */ export declare function currentLockId(slotPath: string): string | null; /** * Write a slot's holder record. Best-effort: a failed write leaves the slot * unreapable (it waits out the stale timeout), never unheld. * * @param slotPath - Slot lock file path. * @param record - The record to write. * * @task T12963 */ export declare function writeHolderRecord(slotPath: string, record: SlotHolderIdentity): void; /** * Keep a held slot's record listing every tool group this process runs while * it holds the slot (a finished group is dropped, T14066). Rewrites only * while the slot's lock is still the one the record describes. * * @param slotPath - Slot lock file path. * @param record - The record as first written (its `toolGroups` are replaced). * @returns The groups already running (put them in the first record) and `stop`. * * @task T12963 */ export declare function recordToolGroupsWhileHeld(slotPath: string, record: () => T): { groups: readonly number[]; stop: () => void; }; /** * Write the holder record for a governor slot whose lock is held, stamping it * with the current lock's identity. * * @param slotPath - Slot lock file path. * @param holder - Who holds it. * @returns The record as written. * * @internal Exported so tests can plant a foreign holder. * @task T12963 */ export declare function writeGovernorHolder(slotPath: string, holder: Omit): GovernorSlotHolder; /** * Read a governor slot's holder record, or `null` when absent or malformed. * * @param slotPath - Slot lock file path. * @returns The record, or `null`. * * @task T12963 */ export declare function readGovernorHolder(slotPath: string): GovernorSlotHolder | null; /** * Decide whether a slot's recorded holder still holds it. * * - `dead`: the holder pid is gone, or alive with a different start time * than the record's (a recycled pid), AND every tool group it started is * gone. * - `unknown`: no record, another host's pid, a pid of 1 or below, a record * that does not describe the current lock (or a lock with no identity), or * a probe that failed. Never treated as dead. * - `alive`: otherwise, including a live pid whose start time was never * recorded, and a dead holder whose tool is still running. * * A live holder's `proper-lockfile` timer refreshes the lock mtime (governor * slots every 15 s, tool slots every 5 min). While the mtime is within * {@link LOCK_REFRESH_WINDOW_MS} a live pid is taken as the holder without * asking `ps`, so a contended acquire spawns nothing. A pid recycled inside that window therefore reads as alive until * the refresh lapses; that is the safe direction. * * @param holder - The slot's record, or `null`. * @param slotPath - Slot lock file path. Without it the lock identity and * mtime are not consulted, so a live pid is always `alive` (report-only). * @param probe - Pid probe; tests inject one. * @returns The verdict. * * @task T12963 */ export declare function assessSlotHolder(holder: SlotHolderIdentity | null, slotPath?: string, probe?: PidProbe): GovernorHolderState; /** * {@link assessSlotHolder} for a governor slot's record. * * @param holder - The slot's record, or `null`. * @param slotPath - Slot lock file path. * @param probe - Pid probe; tests inject one. * @returns The verdict. * * @task T12963 */ export declare function assessGovernorHolder(holder: GovernorSlotHolder | null, slotPath: string, probe?: PidProbe): GovernorHolderState; /** * Free a held slot whose holder is provably dead: its pid gone or recycled * and every tool group it started gone. * * Runs under a per-slot guard and re-checks the holder inside it. The holder * record is removed with the lock, so a second reaper that takes the guard * afterwards finds no record (or the next holder's) and stands down. A lock * within {@link STALE_TAKEOVER_MARGIN_MS} of `staleMs` is left to * `proper-lockfile`, and the lock identity is re-read immediately before the * `rm`, so a concurrent stale takeover's fresh lock is never removed. * * @param slotPath - Slot lock file path. * @param readHolder - Reads the slot's record (the governor's or the tool semaphore's). * @param opts - The slot's `proper-lockfile` stale threshold, and a probe (tests). * @returns `true` when the slot was reaped; the caller then takes it. * * @task T12963 */ export declare function reapSlotIfHolderDead(slotPath: string, readHolder: (slotPath: string) => SlotHolderIdentity | null, opts?: { staleMs?: number; probe?: PidProbe; }): boolean; /** * Name every held governor slot and its holder, for a deferral reason. * * @param slots - Slot lock file paths of one class. * @returns `; held by …`, or `''` when no slot is held. * * @task T12963 */ export declare function describeSlotHolders(slots: readonly string[]): string; /** * Take a slot without waiting: free, or freed by reaping a dead holder. * * @param slotPath - Slot lock file path. * @param cls - Class the slot belongs to. * @returns The grant's release (drops the holder record, then the lock), or * `null` when the slot is held. * @throws Any lock error other than `ELOCKED` (an unwritable slot directory), * for the governor's fail-open path. * * @task T12963 */ export declare function lockSlot(slotPath: string, cls: ResourceClass): Promise<(() => Promise) | null>; /** * Forget cached start times, this process's included. Tests only. * @internal */ export declare function _resetSlotHolderStateForTest(): void; //# sourceMappingURL=slot-holder.d.ts.map