/** * 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'; import type { ParserExecutionPort } from '@cleocode/contracts'; import type { ProcessCaptureOptions, ProcessCaptureResult, ProcessLaunchExecution, SystemdControlContext } from '@cleocode/contracts/resource-governor'; /** * 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; /** Optional hard kernel task limit (processes and threads); omitted preserves manager defaults. */ tasksMax?: number; } /** * Options for {@link buildSpawnArgs}. */ export interface BuildSpawnArgsOptions { /** Original caller deadline/cancellation, shared through probes and launch. * @defaultValue No additional execution deadline; legacy probe ceilings still apply. */ execution?: ProcessLaunchExecution; /** * Explicit local manager connection, separate from the child's isolated environment. * Only the launcher receives these path/address overrides; original child values * are restored before executing the requested command. No global environment changes. * @defaultValue Ambient manager discovery. */ systemdControl?: SystemdControlContext; /** * 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; /** * `true` only when win32 launches a `.cmd`/`.bat` through cmd.exe; the * pre-quoted line must reach cmd unmodified (T12618). */ windowsVerbatimArguments?: boolean; } /** * 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 the selected local user manager can launch transient scopes. * @param context - Explicit manager context; omitted callers retain ambient discovery. * @param execution - Original deadline/cancellation; each synchronous probe uses its remaining budget. * @returns Whether systemd binaries and the selected user bus answered the bounded probes. * @remarks Availability does not prove that a later scope or resource bound was established. * Probe results are keyed by platform, executable search path and manager connection, * so an unavailable ambient context cannot poison a later explicit context. * @example * ```typescript * const available = hasSystemdRun({ runtimeDirectory: '/run/user/1000' }); * ``` */ export declare function hasSystemdRun(context?: SystemdControlContext, execution?: ProcessLaunchExecution): boolean; /** * Override availability for deterministic tests, or clear the override and cached probes. * @param available - Forced availability; undefined restores real context-specific probing. * @remarks Test overrides do not establish actual manager availability or containment. * @example * ```typescript * _forceSystemdRunAvailable(undefined); * ``` */ export declare function _forceSystemdRunAvailable(available: boolean | undefined): 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 on POSIX; Windows has * neither `sh` nor core files, so it always spawns directly) 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. * @remarks This only constructs launch arguments; it does not establish or inspect a scope. * @example * ```typescript * const launch = buildSpawnArgs('node', ['--version']); * ``` */ 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. * @remarks An explicit manager context applies only to the launcher. Child manager variables * are restored before execution, while all other child environment values remain in env. * Scope membership, limits and cleanup require independent observation. * @example * ```typescript * const launched = spawnWrapped('node', ['--version'], { env: process.env }); * ``` */ export declare function spawnWrapped(command: string, args: readonly string[], spawnOpts?: Parameters[2], wrapOpts?: BuildSpawnArgsOptions): SpawnWrappedResult; /** * Capture an owned process with a shared deadline, cancellation and bounded output. * @param command - Exact executable; never interpreted as a shell expression. * @param args - Exact target arguments; no environment values are added to argv. * @param options - Explicit captured routing, environment and original execution budget. * @returns Target events and independent transport, stop and cleanup diagnostics. * @throws When admission inputs are invalid, the original context has ended, or the platform cannot provide process-group cleanup. * @remarks A fixed Node transport distinguishes the requested process from systemd/sh * launchers. Cleanup has a bounded additional manager timeout and can exceed the * execution deadline. POSIX process groups cannot contain deliberately escaped * descendants; Windows capture is refused. Requested hard memory/task limits require * observation inside the exact owned cgroup before the target is launched. Calls without * requests retain unverified memory limits. This is a transport, not a job scheduler. * @example * ```typescript * const result = await captureWrapped('node', ['check.mjs'], { * cwd: projectRoot, env: {}, execution: { deadlineAt: Date.now() + 2000 }, * }); * if (result.started && !result.stopped && result.exitCode === 0) inspect(result); * ``` */ export declare function captureWrapped(command: string, args: readonly string[], options: ProcessCaptureOptions): Promise; export declare function createParserExecutionPort(): ParserExecutionPort; //# sourceMappingURL=spawn-wrapper.d.ts.map