/** * Slice-aware launch-wrapper SSoT (T11993 · Epic T11992). * * This module is the ONLY constructor of `systemd-run` argv for cleo children. * Every consumer that needs to launch a cleo child in a memory-capped cgroup * MUST call {@link buildSpawnArgs} or {@link spawnWrapped} here instead of * constructing `systemd-run` arguments ad-hoc. * * ## Design * * Children are placed inside `cleo.slice` when systemd is available: * * ``` * cleo.slice * ├── cleo-daemon.service (managed by the daemon service unit) * └── cleo--.scope (transient scopes for child spawns) * ``` * * The slice applies MemoryHigh (primary soft throttle) and MemoryMax (hard * safety net) at the slice level, shared across all members. Child scopes * inherit these limits and may override them downward. * * ## Staged P1 budget (IMPORTANT — read before tuning) * * P1 ships MemoryHigh within 5 % of MemoryMax (80 % / 85 % of a reference * host total) so that reclaim stalls under the soft limit NEVER block the * SQLite WAL write-transaction held by a slice member. A stalled scope * holding a WAL write-txn for > 30 s (busy_timeout) cascades SQLITE_BUSY to * every other slice member. * * **P1 installed values (safe defaults):** * - `MemoryHigh = undefined` (disabled — no throttle in P1) * - `MemoryMax = 32G` (hard cap, benign cgroup kill) * * The 60 % / 85 % target shape is documented here and in `DEFAULT_SLICE_CONFIG` * as the P2 goal once the stall-escalator (T11994) is in place. * * **With `MemorySwapMax=0`:** reclaim has only page-cache to chew against * `MemoryHigh` on anonymous heaps — admission (P2) becomes the primary * control, throttling is not load-bearing in P1. * * ## oomd-avoid (selective) * * `ManagedOOMPreference=avoid` is applied ONLY to daemon/db-heavy scope * classes (`'daemon'`, `'db'`). Bulk agent/test scopes do NOT get `avoid` * because Fedora oomd monitors `user@1000.service` at 80 %/20 s and ranks * avoid-marked candidates last — blanket avoid redirects oomd kills onto * innocent user apps. * * ## Fallback * * On hosts without systemd-run (macOS, CI containers, minimal installs) * the wrapper degrades log-once to plain `spawn` in a new process-group * with NODE_OPTIONS heap cap inherited from the caller. This is mirrored * from the writer-lease `pgid-demotion` pattern. * * ## Return value — ownership handle (T11998 / T11995) * * {@link SpawnWrappedResult} carries `{ unitName?, pid, mode }` so the * suite-containment epic (T11998) and the janitor (T11995) can look up or * clean up the transient scope by unit name. * * @module @cleocode/core/resources/spawn-wrapper * @epic T11992 * @task T11993 */ import { type ChildProcess, spawn } from 'node:child_process'; /** * Scope classes understood by the wrapper. * * `'daemon'` and `'db'` receive `ManagedOOMPreference=avoid` (write-txn * holders must not be oom-killed mid-transaction). All other classes run * without the avoid flag. */ export type ScopeClass = 'daemon' | 'db' | 'agent' | 'test' | 'tool'; /** * Memory resource configuration for the slice / scope. * * Percentages are resolved against the host's MemTotal at build time by * {@link buildSpawnArgs}. Pass absolute strings (e.g. `'32G'`, `'768M'`) * to bypass percentage resolution. */ export interface SliceResourceConfig { /** * MemoryHigh for the transient scope. * * `undefined` (default P1) = disabled (no soft throttle). * Set to a fraction in (0, 1) to express as percent of MemTotal, or pass * an absolute systemd memory value (`'32G'`, `'2G'`, …`). * * **P1 default: `undefined`** (disabled). P2 target: `0.60` (60 %). */ memoryHigh?: number | string; /** * MemoryMax for the transient scope (hard kill ceiling). * * Default: `'32G'`. P2 target: 85 % of MemTotal (`0.85`). */ memoryMax?: number | string; } /** * Options for {@link buildSpawnArgs}. */ export interface BuildSpawnArgsOptions { /** * Scope class — controls `ManagedOOMPreference` and the transient unit name * prefix. * * Default: `'agent'`. */ scopeClass?: ScopeClass; /** * Optional caller-supplied discriminator appended to the unit name so that * concurrent same-class scopes are addressable. E.g. a task ID. */ scopeId?: string; /** * Memory resource overrides. Merged over {@link DEFAULT_SCOPE_RESOURCES}. */ resources?: SliceResourceConfig; /** * When `true` (default), suppress coredumps by wrapping the inner command * as `sh -c 'ulimit -c 0; exec "$@"' sh `. * * This achieves process-level core suppression without relying on * `LimitCORE=0`, which is a service-unit EXEC property and is rejected by * `systemd-run --scope` ("Unknown assignment: LimitCORE"). The `ulimit -c 0` * approach works identically in the systemd path and the pgid-fallback path, * preventing V8-heap-abort and cgroup-kill coredumps from producing * abrt-applet toasts on Fedora and Ubuntu. * * Default: `true`. */ noCoreFile?: boolean; } /** * Result returned by {@link buildSpawnArgs}. * * `mode = 'systemd'` means the argv leads with `systemd-run`. * `mode = 'pgid'` means the fallback path (no systemd-run available). */ export interface SpawnArgsBuildResult { /** The command to execute (e.g. `'systemd-run'` or the original command). */ command: string; /** Full argument list, including the original command if wrapped. */ args: string[]; /** * Whether the child will run inside a systemd transient scope. * `false` = plain pgid spawn (no systemd available). */ mode: 'systemd' | 'pgid'; /** * The transient scope unit name (e.g. `cleo-agent-T1234.scope`). * `undefined` when `mode = 'pgid'`. */ unitName?: string; } /** * Result returned by {@link spawnWrapped}. * * Carries the ownership/cleanup handle (T11998 / T11995). */ export interface SpawnWrappedResult { /** The spawned child process handle. */ child: ChildProcess; /** PID of the spawned child (may be undefined for detached+unreffed). */ pid: number | undefined; /** * Whether the child runs inside a systemd transient scope (`'systemd'`) or * in a plain process-group (`'pgid'`). */ mode: 'systemd' | 'pgid'; /** * The transient scope unit name (e.g. `cleo-agent-T1234.scope`). * `undefined` when `mode = 'pgid'`. * * Use this as the cleanup handle in T11998 / T11995: * `systemctl --user stop ` */ unitName?: string; } /** * The systemd user slice that all cleo child scopes are placed under. * * The slice unit (`~/.config/systemd/user/cleo.slice`) must be installed * by the doctor/install flow before this is effective. Placing an orphan * scope under a non-existent slice degrades silently to the default slice — * so the slice install is best-effort and the wrapper is always safe to call. */ export declare const CLEO_SLICE: "cleo.slice"; /** * Default per-scope memory resource config (P1 staged values). * * P1: MemoryHigh disabled, MemoryMax=32G. * P2 target (after T11994 stall-escalator lands): memoryHigh=0.60, memoryMax=0.85. */ export declare const DEFAULT_SCOPE_RESOURCES: Required; /** * Check whether `systemd-run --user` is usable on this host. * * Returns `false` on non-Linux, when systemd-run is not on PATH, or when * DBUS_SESSION_BUS_ADDRESS / XDG_RUNTIME_DIR are absent (CI containers, * ssh sessions without a user bus). */ export declare function hasSystemdRun(): boolean; /** * Force-override the cached systemd-run availability. * * Exposed for unit tests that need to exercise the pgid-fallback or * systemd paths without the real binary. * * @param available - `true` = force systemd path, `false` = force pgid path. */ export declare function _forceSystemdRunAvailable(available: boolean): void; /** * Build the argv for a cleo child spawn. * * When `systemd-run` is available, the result is: * * ``` * ['systemd-run', '--user', '--scope', '--slice=cleo.slice', * '--unit=cleo--.scope', * '-p', 'MemoryHigh=', '-p', 'MemoryMax=', * '-p', 'MemorySwapMax=0', * ['-p', 'ManagedOOMPreference=avoid'], // daemon/db only * '--', * // when noCoreFile=true (default): * 'sh', '-c', 'ulimit -c 0; exec "$@"', 'sh', command, ...args * // when noCoreFile=false: * command, ...args] * ``` * * `LimitCORE` is intentionally NOT used — it is a service-unit EXEC property * and is rejected by `systemd-run --scope` with "Unknown assignment: LimitCORE". * Core-dump suppression is applied at the process level instead via * `ulimit -c 0` (POSIX sh, works in both systemd and pgid-fallback paths). * * When `systemd-run` is NOT available, the result is `[command, ...args]` * (or the sh/ulimit-wrapped form when noCoreFile=true) and `mode` is `'pgid'`. * * @param command - The executable to run (e.g. `'node'`). * @param args - Arguments to pass to the executable. * @param opts - Wrapper options (scope class, resources, etc.). * @returns Build result with final command, args, mode, and unit name. */ export declare function buildSpawnArgs(command: string, args: readonly string[], opts?: BuildSpawnArgsOptions): SpawnArgsBuildResult; /** * Spawn a command wrapped in a cleo.slice transient cgroup scope. * * This is a thin convenience wrapper over {@link buildSpawnArgs} + * Node.js `child_process.spawn`. All cleo child spawns SHOULD route through * this function instead of constructing `systemd-run` arguments inline. * * The returned {@link SpawnWrappedResult} carries `unitName` — the systemd * transient scope unit name — as the ownership/cleanup handle for * T11998 (suite containment) and T11995 (janitor). * * @param command - The executable to run. * @param args - Arguments to pass to the executable. * @param spawnOpts - Options forwarded to `child_process.spawn`. * @param wrapOpts - Wrapper options (scope class, resources, etc.). * @returns Wrapped spawn result. */ export declare function spawnWrapped(command: string, args: readonly string[], spawnOpts?: Parameters[2], wrapOpts?: BuildSpawnArgsOptions): SpawnWrappedResult; //# sourceMappingURL=spawn-wrapper.d.ts.map