/** * `cleo run` admission: the one front door for heavy commands an agent runs * itself (test runners, compilers, builds, installs). * * Every heavy command is admitted by the admission ledger * (`admission-ledger.ts`, T13133): one byte budget and one FIFO queue shared * by every `cleo run` job and every `cleo verify` evidence run on the machine, * whichever agent, session or project asks. This module holds the pure * pieces; `run-governed.ts` runs the loop. * * - {@link resolveRunClass} / {@link isPausable}: the governor class and * whether the job may be paused under pressure * - the machine-wide job registry (`/run/jobs/*.json`): who is * running what, with a heartbeat, so the soft-stop envelope can name the * holders and runners can coordinate pauses * - orphan recovery ({@link reapOrphans}): a runner killed outright * (SIGKILL, jetsam) leaves its detached child group behind; the next reap * resumes and terminates it after checking it is the same process (start * time). A failed `ps` is "unknown" and never acted on. * - {@link decidePause}: under `backoff` only the oldest job runs; a job * resumed by the starvation cap gets a guaranteed run window * - {@link buildRunDeferral}: the `E_RESOURCE_DEFERRED` details and the ways * an agent can keep making progress * * The registry is per `CLEO_HOME` (the machine's CLEO data dir). * * @module resources/run-admission * @task T12979 * @task T12980 * @task T13127 * @task T13133 * @epic T12978 */ import type { MemoryPressureReading, ResourceClass } from '@cleocode/contracts'; import type { AdmissionScope, LedgerEntry } from './admission-ledger.js'; import type { PressureState } from './monitor.js'; export { type CommandTarget, canonicalForClass, commandTarget, isPausable, isWatchCommand, isWholeSuiteTestRun, looksHeavy, namedTestFileCount, RUN_CLASS_ALIASES, resolveRunClass, } from './run-class.js'; /** * The command line as recorded: secret-looking `name=value` pairs, the value * after a secret-looking flag (`--token X`), URL userinfo and bearer tokens * masked; truncated to 160 characters. */ export declare function redactCommand(argv: readonly string[]): string; /** Process start time as `ps` prints it, or `null` when the pid is gone. */ export type ProcessStartFn = (pid: number) => string | null; /** A pid we may ever signal or probe: an integer above 1 (never 0, 1 or -1). */ export declare function isSignalablePid(pid: unknown): pid is number; /** One `ps -o lstart=` exec. Used only off the hot path (spawn, stale records). */ export declare const processStart: ProcessStartFn; /** Signal one process (a nested job's non-detached child). Refuses pid ≤ 1. */ export declare function signalPid(pid: number, signal: NodeJS.Signals): boolean; /** Signal a whole process group. Never falls back to a bare pid. */ export declare function signalGroup(pid: number, signal: NodeJS.Signals): boolean; /** One running `cleo run` job, as recorded machine-wide. */ export interface RunJob { /** `-`, also the file name. */ readonly id: string; /** The `cleo run` process. */ readonly pid: number; /** `ps` start time of the runner, to tell a reused pid apart. */ readonly runnerStart: string | null; /** The command's process group leader, once spawned. */ readonly childPid: number | null; /** `ps` start time of the child, checked before any orphan signal. */ readonly childStart: string | null; readonly class: ResourceClass; /** Redacted, truncated command line. Never the environment. */ readonly command: string; readonly cwd: string; readonly startedAtMs: number; /** The CLEO or agent session that asked, when known. */ readonly sessionId: string | null; /** When the job was paused under pressure, else `null`. */ readonly pausedAtMs: number | null; /** Whether the job may be paused ({@link isPausable}). */ readonly pausable: boolean; /** Refreshed by the runner on every poll. */ readonly heartbeatAtMs: number; /** * The enclosing job when this run is nested in another `cleo run` (its * runner lives in that job's process group), else null. Absent in records * from older builds. */ readonly parentJob?: string | null; /** Whether this job holds a slot of its own (a same-class nested run does not). */ readonly holdsSlot?: boolean; /** * Whether the child leads its own process group (spawned detached). False * for a nested run and for a `--passthrough` run in a terminal's foreground * group: those are signalled by pid, never by group. Absent in records from * older builds, where only a nested run (`parentJob`) shares a group. */ readonly leadsGroup?: boolean; /** Resident memory of the child's process tree at the last sample (T13563). */ readonly measuredRssBytes?: number; /** CPU time the child's process tree has used, at the last sample (T13563). */ readonly cpuMs?: number; /** Since when the tree has used no CPU, else null (T13563). */ readonly idleSinceMs?: number | null; /** When the run passed its class's maximum wall time, else null (T13563). */ readonly overdueAtMs?: number | null; /** When the run was flagged hung (idle past the window), else null (T13563). */ readonly hungAtMs?: number | null; /** * The last signal this runner forwarded to its child's whole process group, * and when (T13002). A nested runner in that group got the same delivery, so * it does not forward it to its own child a second time. */ readonly forwardedSignal?: { readonly signal: NodeJS.Signals; readonly atMs: number; }; /** * Started with `cleo run --detach` (or `CLEO_RUN_DETACHED=1`): meant to * outlive the process that started it, so its parent's exit does not stop * it (T14080). */ readonly detached?: boolean; } /** A record whose heartbeat is older than this is checked for a dead runner. */ export declare const JOB_STALE_MS = 60000; /** Registry directory: `/run/jobs`. */ export declare function runJobsDir(cleoHome?: string): string; /** Overwrite a job record atomically. Never throws. */ export declare function writeRunJob(job: RunJob, dir?: string): void; /** Remove a job record. Never throws. */ export declare function removeRunJob(id: string, dir?: string): void; /** Injectable process probes for {@link listRunJobs} (tests). */ export interface RegistryProbes { readonly alive: (pid: number) => boolean; readonly start: ProcessStartFn; /** Signal a process group (a detached job's child leads its own). */ readonly signal: (pid: number, signal: NodeJS.Signals) => boolean; /** Signal one process (a nested job's child, which leads no group). */ readonly signalPid: (pid: number, signal: NodeJS.Signals) => boolean; readonly now: () => number; } /** What a record says about its runner, from probes that may fail. */ export type RunnerState = 'live' | 'dead' | 'unknown'; /** * The runner of a record: `dead` when its pid is free, or (only once the * heartbeat is stale, keeping `ps` off the hot path) when the pid now belongs * to a different process. A failed `ps` is `unknown`, never `dead`: under the * very overload this governor exists for, `ps` times out or cannot fork, and * acting on that would signal live jobs (#1777 round 3, H-1). */ export declare function runnerState(job: RunJob, probes: RegistryProbes): RunnerState; /** * Live jobs, oldest first. A pure read: it never signals or deletes (reaping * is {@link reapOrphans}). Records whose runner is dead, unknown, or alive * with a stale heartbeat (a suspended machine) are left out. */ export declare function listRunJobs(dir?: string, probes?: Partial): RunJob[]; /** * Reap the records of dead runners: resume and stop their orphaned child * groups ({@link recoverOrphan}) and delete the record, unless the child's * identity could not be read (kept for the next reap). Records whose runner * is `unknown` are never touched. Unreadable files older than the stale * window are removed. * * @returns how many records were removed. */ export declare function reapOrphans(dir?: string, probes?: Partial): number; /** The process group a pid belongs to, from one `ps` exec; null when unknown. */ export declare function processGroupOf(pid: number): number | null; /** * The process groups of several pids from ONE `ps` exec (T13965); a pid that * is gone or unreadable is absent from the map. * * Linux `ps` exits non-zero for a gone pid but still prints the others. macOS * `ps` prints nothing at all when any pid is out of range ("process id too * large"), so a failed exec that printed nothing falls back to one `ps` per * pid ({@link processGroupOf}). * * @param pids - The pids to look up. * @returns pid → process group. */ export declare function processGroupsOf(pids: readonly number[]): Map; /** CPU time and resident memory of a process and every descendant (T13563). */ export interface ProcessTreeUsage { /** Summed CPU time (user + system), ms. */ readonly cpuMs: number; /** Summed resident set size, bytes. */ readonly rssBytes: number; /** Processes in the tree. */ readonly processes: number; /** * The tree's command lines, when the table carried them (T13972: whether a * git hook is running in it). */ readonly commands?: readonly string[]; } /** * A `ps` `time` value (`[[DD-]HH:]MM:SS[.ff]`, as macOS and procps print it) * in ms, or NaN when malformed. * * @param value - The column text. * @returns Milliseconds. */ export declare function parsePsTime(value: string): number; /** * The usage of `rootPid`'s tree from a `ps -A -o pid=,ppid=,time=,rss=[,command=]` * table (rss in KiB); null when the root is not in it. * * @param table - The `ps` output. * @param rootPid - The tree's root. * @returns The summed usage. */ export declare function treeUsageFromTable(table: string, rootPid: number): ProcessTreeUsage | null; /** * The usage of `rootPid`'s process tree from ONE `ps` of the table; null when * `ps` fails or the root is gone. * * @param rootPid - The run's child. * @param withCommands - Also read every command line (T13972: a measured git * hook). The table is then much larger, so it is read only when asked. * @returns The summed usage. */ export declare function processTreeUsage(rootPid: number, withCommands?: boolean): ProcessTreeUsage | null; /** Default maximum wall time of an admitted run, per class (T13563). */ export declare const RUN_MAX_WALL_MS: Readonly>>; /** Default window of zero CPU after which an admitted run is flagged hung (T13563). */ export declare const RUN_IDLE_WINDOW_MS: number; /** The watchdog limits of a run (T13563). */ export interface RunWatchLimits { /** Maximum wall time, unpaused; null when the class has none. */ readonly maxWallMs: number | null; /** Zero-CPU window before a run is flagged hung. */ readonly idleWindowMs: number; } /** * The watchdog limits for `cls`: the defaults, overridden for every class by * `CLEO_RUN_MAX_WALL_MIN` and `CLEO_RUN_IDLE_MIN` (positive minutes). * * @param cls - The run's class. * @param env - Where the overrides are read. @defaultValue process.env * @returns The limits. */ export declare function runWatchLimits(cls: ResourceClass, env?: NodeJS.ProcessEnv): RunWatchLimits; /** What the watchdog knows between samples (T13563). */ export interface RunWatchState { readonly lastCpuMs: number | null; readonly idleSinceMs: number | null; readonly overdueAtMs: number | null; readonly hungAtMs: number | null; } /** The watchdog's starting state. */ export declare const RUN_WATCH_START: RunWatchState; /** A watchdog transition to report once. */ export type RunWatchEvent = 'overdue' | 'hung' | 'active-again'; /** * One watchdog step. Overdue: the unpaused run time passed the class's * maximum (reported once). Hung: the tree used no CPU for the idle window * (reported once per hang; `active-again` when it moves again). A paused run * is never idle: SIGSTOP stops its CPU on purpose. Nothing here stops a run. * * @param prev - The state after the previous step. * @param at - This step's clock, run times, pause state and tree usage (null: unmeasured). * @param limits - The run's limits. * @returns The next state and the transitions to report. */ export declare function watchRun(prev: RunWatchState, at: { readonly nowMs: number; readonly startedAtMs: number; readonly pausedTotalMs: number; readonly paused: boolean; readonly usage: ProcessTreeUsage | null; }, limits: RunWatchLimits): { readonly next: RunWatchState; readonly events: RunWatchEvent[]; }; /** * The ancestors of a pid, nearest first, from ONE `ps` of the process table * (pid 1 and below excluded); null when `ps` fails. */ export declare function processAncestors(pid: number): number[] | null; /** * The running `cleo run` job whose process tree `pid` belongs to, or null. * * A governed child is normally spawned detached, so it leads its own session * and process group (pgid = its pid). A process whose group id equals a live * job's `childPid`, with the leader's start time matching the record, runs * inside that job. This cannot be forged from outside: a session leader's * group can only be joined by processes in its own session, and the * start-time check rules out a reused pid. * * When the group says nothing, ancestry decides: `pid` descends from the * child of a live job that leads no group (a `--passthrough` run in a * terminal's foreground group, OR a nested run). Ancestry can't be forged * either (the kernel sets the parent; an orphan is reparented away from the * job). It costs one `ps` of the process table, read only while at least one * live job, foreground or nested, leads no group. * * Any failure to read `ps` returns null (normal admission). */ export declare function parentRunJob(input: { readonly pid: number; /** Only a parent holding THIS class passes a nested run through (#1777 round 4, MED-1). */ readonly cls?: ResourceClass; readonly jobsDir?: string; readonly probes?: Partial; readonly groupOf?: (pid: number) => number | null; readonly ancestorsOf?: (pid: number) => readonly number[] | null; }): RunJob | null; /** * A paused job resumes after this long even under pressure (no starvation). * * Kept below the slot-lock steal threshold: a pause freezes every process in * the job's group, including any slot holder nested in it (a `cleo run` * runner, or an in-process governor grant such as exodus-on-open or verify), * whose lock then stops refreshing. A lock refreshes every * `SLOT_LOCK_UPDATE_MS` (15 s) and is stolen once older than * `SLOT_LOCK_STALE_MS` (10 min), so a holder frozen for less than 9 min 45 s * keeps its slot (#1777 round 6, R6-1). */ export declare const MAX_PAUSE_MS: number; /** After a cap resume, the job runs at least this long before it can pause again. */ export declare const CAP_RUN_WINDOW_MS: number; /** Why {@link decidePause} decided what it did. */ export type PauseReason = 'below-backoff' | 'not-pausable' | 'nested-slot' | 'oldest' | 'cap' | 'run-window' | 'younger'; /** * Should this job run or pause right now? * * - Below `backoff`, or a job that may not be paused: run. * - A job with a live nested run that holds its own slot: run * (`'nested-slot'`), so that run's slot lock never goes stale while frozen. * - At `backoff`: the oldest live job keeps running so the machine always * makes progress; every younger pausable job pauses. Pausing frees CPU at * once and stops memory growth; nothing is killed and no work is lost. * - A job paused for {@link MAX_PAUSE_MS} resumes (`'cap'`) and then runs for * at least {@link CAP_RUN_WINDOW_MS} (`'run-window'`) before it can pause * again, so a long backoff alternates jobs instead of starving them. * * @example * ```ts * decidePause({ state: 'backoff', self, jobs: [old, self], nowMs }).decision; // 'pause' * decidePause({ state: 'hold', self, jobs: [old, self], nowMs }).decision; // 'run' * ``` */ export declare function decidePause(input: { readonly state: PressureState; readonly self: Pick; readonly jobs: readonly Pick[]; readonly nowMs: number; readonly pausedAtMs?: number | null; readonly capResumedAtMs?: number | null; readonly maxPauseMs?: number; readonly runWindowMs?: number; }): { decision: 'run' | 'pause'; reason: PauseReason; }; /** One way forward offered with a deferral. Matches the LAFS `alternatives` shape. */ export interface RunAlternative { readonly action: string; readonly command: string; } /** One entry of `running[]`: a `cleo run` job or a `cleo verify` tool slot. */ export interface RunningEntry { readonly source: 'run' | 'verify'; readonly class: string; readonly command: string; readonly cwd: string | null; readonly startedAtMs: number; readonly sessionId: string | null; readonly pausedAtMs: number | null; /** How much of the project it covers (`full` = a whole-suite run), when known (T13132). */ readonly scope?: AdmissionScope; /** The CLEO task it is evidence for, when known (T13132). */ readonly task?: string; /** Bytes of the machine budget it holds, when admitted through the ledger (T13132). */ readonly footprintBytes?: number; } /** `error.details` of an `E_RESOURCE_DEFERRED` from `cleo run`. */ export interface RunDeferralDetails { readonly class: ResourceClass; readonly reason: string; readonly retryAfterMs: number; /** Position in the admission queue when it timed out under `--wait`. */ readonly queuePosition: number | null; readonly pressure: { readonly state: PressureState; /** Memory-scale score (see `pressureScore`). */ readonly score: number; readonly reason: string; readonly memAvailableBytes: number | null; }; /** * The memory gate's readings when the job was refused for memory pressure * (T13127), else `null`. */ readonly memoryPressure: MemoryPressureReading | null; /** Live `cleo run` jobs and the ledger's other admitted runs, oldest first. */ readonly running: readonly RunningEntry[]; } /** * Merge live `cleo run` jobs and the admission ledger's other admitted runs * (evidence runs, probes) into one oldest-first list. A `cleo run` job's own * ledger entry is represented by its job record, which knows its session and * pause state. */ export declare function runningEntries(jobs: readonly RunJob[], ledger: readonly Pick[]): RunningEntry[]; /** * Build the deferral details plus the ways forward. The alternatives are * concrete commands, ordered by how little they cost the machine. */ export declare function buildRunDeferral(input: { readonly cls: ResourceClass; readonly argv: readonly string[]; readonly reason: string; readonly retryAfterMs: number; readonly queuePosition?: number | null; readonly pressure: RunDeferralDetails['pressure']; readonly running: readonly RunningEntry[]; /** Set when the refusal was the memory gate's (T13127). */ readonly memoryPressure?: MemoryPressureReading | null; }): { details: RunDeferralDetails; alternatives: RunAlternative[]; fix: string; }; //# sourceMappingURL=run-admission.d.ts.map