Skip to main content

fno_agents/
spawn_gate.rs

1//! Spawn gate (x-c5cc): global concurrency cap + free-RAM floor + queue loop.
2//!
3//! Called at the top of the client `spawn` arm for the `bg`/`headless`
4//! substrates only (`pane` re-execs into the Python CLI, whose mirrored gate
5//! in `fno/agents/spawn_gate.py` is the sole gate on that path — exactly one
6//! gate evaluation per spawn, LD1).
7//!
8//! The gate is READ-ONLY: the `max_live` slot cap counts the fno registry
9//! (worker provenance) and the RAM floor reads system `vm_stat`/meminfo. The
10//! claude daemon roster is consulted only as a LIVENESS ORACLE for fno bg rows
11//! that carry no local pid, and by the post-spawn QoS demotion helper — never
12//! as a population to count (x-bdf9: the roster's non-work sessions must not
13//! consume worker slots; only rows that are ALSO in the fno registry count).
14//! The gate's only writes are its own claims (`spawn-gate` check→dispatch mutex,
15//! `worker:<name>` headless slot claims). Every guard fails OPEN on read errors
16//! (LD5): the gate is protective infrastructure and must never become the thing
17//! that bricks spawning.
18
19use std::path::{Path, PathBuf};
20use std::time::{Duration, Instant};
21
22use crate::agents_config;
23use crate::claims;
24use crate::claude_roster::ClaudeRoster;
25use crate::daemon::pid_is_ours;
26use crate::state::{load_registry, Registry};
27use crate::AgentStatus;
28
29/// Exit codes, distinct from existing dispatch codes (2, 13, 14, 15, 18, 127).
30pub const EXIT_QUEUE_TIMEOUT: i32 = 75;
31pub const EXIT_NO_WAIT: i32 = 76;
32pub const EXIT_RAM_REFUSED: i32 = 77;
33
34/// Queue mechanics (Claude's Discretion 2: targets, not contracts).
35const QUEUE_POLL: Duration = Duration::from_secs(2);
36const QUEUE_PROGRESS_EVERY: Duration = Duration::from_secs(30);
37const QUEUE_TIMEOUT: Duration = Duration::from_secs(600);
38/// spawn-gate mutex TTL: generous vs the seconds-scale check→dispatch window;
39/// PID liveness frees it instantly if the spawner dies.
40const GATE_CLAIM_TTL_MS: i64 = 5 * 60 * 1000;
41/// How long to tolerate an UNBROKEN run of failed mutex acquisitions before
42/// proceeding unserialized. The mutex is a check→dispatch serializer, not a
43/// state owner: a spawner that dies inside the critical section leaves it
44/// `Suspect` for the full [`GATE_CLAIM_TTL_MS`], and with no bound here EVERY
45/// spawner on the machine then queues behind that corpse until its own queue
46/// timeout; the gate becomes the very thing that bricks spawning, which LD5
47/// forbids. Failing open can overshoot the cap by the number of racing
48/// spawners; wedging the whole mesh is strictly worse. Mirrors
49/// `spawn_gate.py::MUTEX_WAIT_BUDGET_S`.
50const MUTEX_WAIT_BUDGET: Duration = Duration::from_secs(60);
51/// worker:<name> headless slot TTL: bounds a one-shot that outlives its
52/// client pid record; PID liveness is the primary release.
53const WORKER_CLAIM_TTL_MS: i64 = 4 * 60 * 60 * 1000;
54
55/// Registry statuses that can hold a live process (idle counts: an
56/// idle-but-unreaped process still holds RAM; a reaped pid drops out via the
57/// liveness check). Mirrors `spawn_gate.py::LIVE_STATUSES`.
58fn status_is_liveish(s: &AgentStatus) -> bool {
59    matches!(
60        s,
61        AgentStatus::Spawning
62            | AgentStatus::Ready
63            | AgentStatus::Idle
64            | AgentStatus::Busy
65            | AgentStatus::Live
66            | AgentStatus::Restarting
67    )
68}
69
70// ---------------------------------------------------------------------------
71// Layer 2: available-RAM readers (pure parsers + platform dispatch)
72// ---------------------------------------------------------------------------
73
74/// Parse `vm_stat` output (macOS) to available bytes: (free + inactive +
75/// speculative + purgeable) pages × page size. `None` on any shape surprise
76/// so the guard fails open.
77pub fn parse_vm_stat(text: &str) -> Option<u64> {
78    // "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
79    let page_size: u64 = text
80        .lines()
81        .next()?
82        .split("page size of")
83        .nth(1)?
84        .split_whitespace()
85        .next()?
86        .parse()
87        .ok()?;
88    let mut counted: u64 = 0;
89    let mut found_free = false;
90    for line in text.lines().skip(1) {
91        let (label, value) = match line.split_once(':') {
92            Some(kv) => kv,
93            None => continue,
94        };
95        let label = label.trim();
96        let want = matches!(
97            label,
98            "Pages free" | "Pages inactive" | "Pages speculative" | "Pages purgeable"
99        );
100        if !want {
101            continue;
102        }
103        let pages: u64 = value.trim().trim_end_matches('.').parse().ok()?;
104        counted += pages;
105        if label == "Pages free" {
106            found_free = true;
107        }
108    }
109    // A vm_stat with no "Pages free" line is not vm_stat; refuse to guess.
110    found_free.then_some(counted * page_size)
111}
112
113/// Parse `/proc/meminfo` (Linux) `MemAvailable:` kB to bytes.
114pub fn parse_meminfo(text: &str) -> Option<u64> {
115    for line in text.lines() {
116        if let Some(rest) = line.strip_prefix("MemAvailable:") {
117            let kb: u64 = rest.trim().split_whitespace().next()?.parse().ok()?;
118            return Some(kb * 1024);
119        }
120    }
121    None
122}
123
124/// Available system RAM in GB, or `None` when unreadable (guard skipped, fail
125/// open — a broken vm_stat must never brick spawning).
126pub fn available_ram_gb() -> Option<f64> {
127    available_bytes().map(|b| b as f64 / (1024.0 * 1024.0 * 1024.0))
128}
129
130#[cfg(target_os = "macos")]
131fn available_bytes() -> Option<u64> {
132    let out = std::process::Command::new("vm_stat").output().ok()?;
133    if !out.status.success() {
134        return None;
135    }
136    parse_vm_stat(&String::from_utf8_lossy(&out.stdout))
137}
138
139#[cfg(target_os = "linux")]
140fn available_bytes() -> Option<u64> {
141    parse_meminfo(&std::fs::read_to_string("/proc/meminfo").ok()?)
142}
143
144#[cfg(not(any(target_os = "macos", target_os = "linux")))]
145fn available_bytes() -> Option<u64> {
146    None
147}
148
149// ---------------------------------------------------------------------------
150// Layer 1: the worker-slot count
151// ---------------------------------------------------------------------------
152
153/// Count fno WORKER SLOTS in use for the `max_live` cap: liveness-filtered fno
154/// registry rows + live `worker:<name>` headless slot claims.
155///
156/// This is deliberately NOT the full claude daemon roster (x-bdf9). The roster
157/// carries every live claude session — dozens of claude-mem observers and
158/// resident-idle sessions among them — none of which is fno work; counting them
159/// let the slot cap read "20/15" with zero real build workers running and wedge
160/// `/target bg`. Registry membership IS the "fno spawned this for work"
161/// provenance (spawn writes the row), so the registry alone is the slot
162/// denominator. The roster's RAM cost is still honored elsewhere:
163/// [`check_ram_floor`] reads real available RAM from `vm_stat`/meminfo, which
164/// already reflects every process the roster holds.
165///
166/// The roster IS still read here, but only as a LIVENESS ORACLE, not as a
167/// population to count: a fno `claude --bg` row is minted with a jobId in
168/// `short_id` but NO local `pid` (its process lives in the claude daemon, so
169/// liveness is in the roster — see `claude_ask.rs`). Such a row's liveness is
170/// resolved by looking its `short_id` up in the roster. This counts real fno bg
171/// workers (which a pid-only filter would drop, letting the cap admit unbounded
172/// bg workers — Codex P1 on PR #235) WITHOUT counting non-fno sessions: a
173/// claude-mem observer has no registry row, so it is never reached.
174///
175/// Read-only; a registry read failure degrades to a 0 contribution with one
176/// warning line pushed to `warnings` (LD5, fail open).
177pub fn slot_count(registry_path: &Path, warnings: &mut Vec<String>) -> usize {
178    // Live roster short_ids: the liveness oracle for pid-less fno bg rows only.
179    // A roster read failure degrades this to empty (bg rows then fall back to
180    // their local pid, i.e. uncounted) — fail open, never wedge.
181    let live_roster_short_ids: std::collections::HashSet<String> =
182        match ClaudeRoster::load_default() {
183            Ok(roster) => roster
184                .workers_deduped()
185                .iter()
186                .filter(|w| w.pid.map(|p| pid_is_ours(p, w.proc_start)).unwrap_or(false))
187                .map(|w| w.short_id().to_string())
188                .collect(),
189            Err(e) => {
190                warnings.push(format!(
191                    "spawn-gate: claude roster unreadable ({e}); pid-less bg rows uncounted"
192                ));
193                Default::default()
194            }
195        };
196    let mut count = 0usize;
197    match load_registry(registry_path) {
198        Ok(Registry { entries, .. }) => {
199            for e in &entries {
200                if !status_is_liveish(&e.status) {
201                    continue;
202                }
203                let alive = match e.pid {
204                    // Local pid: liveness by PID/start-time, same as claims.
205                    Some(p) => pid_is_ours(p, e.pid_start_time),
206                    // No local pid: a fno bg/adopted row whose process is the
207                    // claude daemon's — resolve liveness via the roster by its
208                    // jobId (in short_id since v9). (A row without either signal
209                    // is a disk-only ghost and stays uncounted.)
210                    None => e
211                        .transport_short()
212                        .map(|sid| live_roster_short_ids.contains(sid))
213                        .unwrap_or(false),
214                };
215                if alive {
216                    count += 1;
217                }
218            }
219        }
220        Err(e) => warnings.push(format!(
221            "spawn-gate: fno registry unreadable ({e}); slot count degraded to 0"
222        )),
223    }
224
225    count + live_worker_slot_claims(warnings)
226}
227
228/// Live `worker:<name>` slot claims under the GLOBAL claims root. Headless
229/// one-shots write no registry row, so their gate acquires one of these for
230/// the call duration; concurrent gates see them here. `Suspect` counts like
231/// `Live` (TTL-protected, never up for grabs).
232fn live_worker_slot_claims(warnings: &mut Vec<String>) -> usize {
233    let root = match gate_claims_root() {
234        Some(r) => r,
235        None => return 0,
236    };
237    let dir = root.join(".fno/claims");
238    let entries = match std::fs::read_dir(&dir) {
239        Ok(e) => e,
240        Err(_) => return 0, // no claims dir yet: nothing held.
241    };
242    let prefix = claims::encode_key("worker:");
243    let mut n = 0usize;
244    for entry in entries.flatten() {
245        let fname = entry.file_name();
246        let fname = fname.to_string_lossy();
247        if !fname.starts_with(prefix.as_str()) {
248            continue;
249        }
250        // strip_suffix, not trim_end_matches: a worker name ending in ".lock"
251        // must lose exactly one suffix (gemini MEDIUM).
252        let key = match fname.strip_suffix(".lock").and_then(urldecode) {
253            Some(k) => k,
254            None => continue,
255        };
256        match claims::status(&key, Some(&root)) {
257            (claims::ClaimState::Live, _) | (claims::ClaimState::Suspect, _) => n += 1,
258            (claims::ClaimState::Corrupted, _) => {
259                warnings.push(format!("spawn-gate: corrupted slot claim {key} ignored"));
260            }
261            _ => {}
262        }
263    }
264    n
265}
266
267/// Minimal percent-decoder for claim filenames (inverse of
268/// `claims::encode_key`). `None` on malformed escapes.
269fn urldecode(s: &str) -> Option<String> {
270    let bytes = s.as_bytes();
271    let mut out = Vec::with_capacity(bytes.len());
272    let mut i = 0;
273    while i < bytes.len() {
274        if bytes[i] == b'%' {
275            let hex = s.get(i + 1..i + 3)?;
276            out.push(u8::from_str_radix(hex, 16).ok()?);
277            i += 3;
278        } else {
279            out.push(bytes[i]);
280            i += 1;
281        }
282    }
283    String::from_utf8(out).ok()
284}
285
286/// The gate's claims live under the GLOBAL root: the RAM budget is
287/// machine-wide, so `spawn-gate` / `worker:<name>` must be visible across
288/// projects and worktrees (unlike default project-local claims).
289fn gate_claims_root() -> Option<PathBuf> {
290    claims::global_claims_root()
291}
292
293// ---------------------------------------------------------------------------
294// The gate
295// ---------------------------------------------------------------------------
296
297/// Flags the spawn arm parses for the gate.
298#[derive(Debug, Clone, Copy, Default)]
299pub struct GateFlags {
300    /// Bypass cap AND RAM floor (still QoS-demotes); prints a forced line.
301    pub force: bool,
302    /// Fail immediately at cap instead of queueing.
303    pub no_wait: bool,
304}
305
306/// Held gate state. The caller keeps this alive across its dispatch call and
307/// calls [`GateGuard::release`] (or drops it) when the dispatch result exists,
308/// so the next waiter's count includes the newcomer.
309#[derive(Debug, Default)]
310pub struct GateGuard {
311    /// `spawn-gate` mutex (bg path: held across dispatch until the
312    /// registry/roster row exists).
313    gate_key: Option<(String, String)>, // (key, holder)
314    /// `worker:<name>` slot claim (headless path: held for the call duration).
315    worker_key: Option<(String, String)>,
316    root: Option<PathBuf>,
317}
318
319impl GateGuard {
320    /// Release everything still held. Idempotent.
321    pub fn release(&mut self) {
322        let root = self.root.clone();
323        if let Some((key, holder)) = self.gate_key.take() {
324            let _ = claims::release(&key, &holder, root.as_deref(), None);
325        }
326        if let Some((key, holder)) = self.worker_key.take() {
327            let _ = claims::release(&key, &holder, root.as_deref(), None);
328        }
329    }
330
331    /// Release only the check→dispatch mutex, keeping the worker slot claim
332    /// (headless: the slot must stay visible for the one-shot's duration).
333    fn release_gate_mutex(&mut self) {
334        if let Some((key, holder)) = self.gate_key.take() {
335            let _ = claims::release(&key, &holder, self.root.as_deref(), None);
336        }
337    }
338}
339
340impl Drop for GateGuard {
341    fn drop(&mut self) {
342        self.release();
343    }
344}
345
346/// Pure parity core (x-91b5, AC2-FR): would a bypass in this env emit
347/// `spawn-cap`? True iff `FNO_SPAWN_GATE=0` AND no non-empty test-context
348/// marker. Mirrors `fno.events.gate_escape.should_emit_spawn_cap` exactly; a
349/// shared JSON fixture (`gate_escape_spawn_cap_parity.json`) asserts the two
350/// implementations agree on every row, so neither can drift (Locked Decision 5).
351pub fn spawn_cap_would_emit(get: impl Fn(&str) -> Option<String>) -> bool {
352    let is_set = |k: &str| get(k).is_some_and(|v| !v.is_empty());
353    get("FNO_SPAWN_GATE").as_deref() == Some("0")
354        && !["PYTEST_CURRENT_TEST", "CI", "FNO_E2E"]
355            .iter()
356            .any(|k| is_set(k))
357}
358
359/// Auto-emit `gate_escape{reason:spawn-cap}` on an operator bypass of THIS gate
360/// (`FNO_SPAWN_GATE=0`) outside a test context (Locked Decision 2). Best-effort:
361/// shells the shared `fno event gate-escape` verb (which owns the dedup key +
362/// canonical-log resolution, one emit path) and ignores every failure so a
363/// spawn is never blocked by telemetry (AC1-FR). The verb, not this shell,
364/// computes the `(reason, session, day)` dedup bucket, so a Rust-emitted and a
365/// Python-emitted spawn-cap in the same session/day still collapse to one.
366fn maybe_emit_spawn_cap_escape() {
367    if !spawn_cap_would_emit(|k| std::env::var(k).ok()) {
368        return;
369    }
370    let _ = std::process::Command::new("fno")
371        .args([
372            "event",
373            "gate-escape",
374            "spawn-cap",
375            "--detail",
376            "FNO_SPAWN_GATE=0 operator bypass",
377        ])
378        .stdout(std::process::Stdio::null())
379        .stderr(std::process::Stdio::null())
380        .status();
381}
382
383/// Run the full gate for a `bg`/`headless` spawn. Returns a guard to keep
384/// alive across dispatch on pass, or `Err(exit_code)` on refusal/timeout.
385/// All human-facing output goes to stderr (LD10: the stdout receipt is
386/// byte-reserved for the pass path).
387pub fn run_gate(
388    config_cwd: &Path,
389    registry_path: &Path,
390    name: &str,
391    substrate: &str,
392    flags: GateFlags,
393) -> Result<GateGuard, i32> {
394    // FNO_SPAWN_GATE=0 disables the gate entirely (the FNO_THINK_SPAWN=0
395    // precedent): test suites exercising spawn plumbing must not queue behind
396    // the REAL machine's live workers, and it doubles as an operator escape.
397    if std::env::var_os("FNO_SPAWN_GATE").is_some_and(|v| v == "0") {
398        maybe_emit_spawn_cap_escape();
399        return Ok(GateGuard::default());
400    }
401    let cap = agents_config::max_live(config_cwd) as usize;
402    let floor_gb = agents_config::min_free_gb(config_cwd);
403    let holder = format!("spawn-gate:{}:{}", std::process::id(), name);
404    let root = gate_claims_root();
405
406    let mut guard = GateGuard {
407        gate_key: None,
408        worker_key: None,
409        root: root.clone(),
410    };
411
412    if flags.force {
413        eprintln!("spawn-gate: forced past cap and RAM floor (--force)");
414        if substrate == "headless" {
415            acquire_worker_slot(&mut guard, name, &holder);
416        }
417        return Ok(guard);
418    }
419
420    let started = Instant::now();
421    let mut last_progress = Instant::now();
422    let mut announced = false;
423    // Start of the current UNBROKEN run of failed acquisitions (None = holding
424    // or not yet contended). Reset on every success so a long legitimate queue
425    // never accumulates into a spurious fail-open.
426    let mut mutex_blocked_since: Option<Instant> = None;
427
428    loop {
429        // Serialize check→dispatch under the spawn-gate mutex so N concurrent
430        // spawners at cap-1 can't all pass. Not held across the wait sleep.
431        let mut acquired_mutex = match claims::acquire(
432            "spawn-gate",
433            &holder,
434            claims::AcquireOpts {
435                ttl_ms: Some(GATE_CLAIM_TTL_MS),
436                root: root.clone(),
437                ..Default::default()
438            },
439        ) {
440            claims::AcquireOutcome::Acquired(_) => true,
441            claims::AcquireOutcome::HeldByOther { .. } => false,
442            claims::AcquireOutcome::Error(e) => {
443                // Fail open: the mutex is a serializer, not a state owner.
444                eprintln!("spawn-gate: mutex unavailable ({e}); proceeding unserialized");
445                true
446            }
447        };
448
449        if acquired_mutex {
450            mutex_blocked_since = None;
451        } else {
452            let now = Instant::now();
453            let since = *mutex_blocked_since.get_or_insert(now);
454            // --no-wait means "do not queue", and a busy mutex is queueing.
455            // Refusing here (rather than falling through to the sleep) is what
456            // keeps the promise: without it the caller waits the full
457            // QUEUE_TIMEOUT and then gets EXIT_QUEUE_TIMEOUT, so it cannot even
458            // tell "cap is full" from "the gate is wedged".
459            if flags.no_wait {
460                eprintln!(
461                    "spawn-gate: another spawner holds the gate mutex; refusing \
462                     (--no-wait). See `fno agents top`."
463                );
464                return Err(EXIT_NO_WAIT);
465            }
466            if now.duration_since(since) >= MUTEX_WAIT_BUDGET {
467                eprintln!(
468                    "spawn-gate: gate mutex still held after {}s (holder likely died \
469                     mid-gate); proceeding unserialized",
470                    MUTEX_WAIT_BUDGET.as_secs()
471                );
472                acquired_mutex = true;
473            }
474        }
475
476        if acquired_mutex {
477            guard.gate_key = Some(("spawn-gate".to_string(), holder.clone()));
478            let mut warnings = Vec::new();
479            let slots = slot_count(registry_path, &mut warnings);
480            for w in &warnings {
481                eprintln!("{w}");
482            }
483            if slots < cap {
484                // Slot free. RAM recheck happens NOW (at dequeue too — a spawn
485                // that queued 5 minutes must not dispatch into a tight machine).
486                if let Err(code) = check_ram_floor(floor_gb) {
487                    guard.release();
488                    return Err(code);
489                }
490                if substrate == "headless" {
491                    acquire_worker_slot(&mut guard, name, &holder);
492                    // Slot claim is visible to concurrent gates: the mutex has
493                    // done its job for this spawn.
494                    guard.release_gate_mutex();
495                }
496                // bg path: keep the mutex until the caller's dispatch returns
497                // (registry/roster row exists) — released via GateGuard.
498                return Ok(guard);
499            }
500            // At cap: drop the mutex before waiting.
501            guard.release_gate_mutex();
502
503            if flags.no_wait {
504                eprintln!(
505                    "spawn-gate: {slots} live worker slots >= max_live {cap}; refusing (--no-wait). \
506                     See `fno agents top`."
507                );
508                return Err(EXIT_NO_WAIT);
509            }
510            if !announced {
511                eprintln!(
512                    "spawn queued: {slots} live worker slots >= max_live {cap}; waiting for a free \
513                     slot (--no-wait to fail fast, --force to bypass)"
514                );
515                announced = true;
516                last_progress = Instant::now();
517            } else if last_progress.elapsed() >= QUEUE_PROGRESS_EVERY {
518                eprintln!(
519                    "still queued: {slots}/{cap} live worker slots, waited {}s",
520                    started.elapsed().as_secs()
521                );
522                last_progress = Instant::now();
523            }
524        }
525
526        if started.elapsed() >= QUEUE_TIMEOUT {
527            eprintln!(
528                "spawn-gate: queue timeout after {}s at max_live {cap}; \
529                 inspect live workers with `fno agents top`, or retry with --no-wait/--force",
530                QUEUE_TIMEOUT.as_secs()
531            );
532            return Err(EXIT_QUEUE_TIMEOUT);
533        }
534        std::thread::sleep(QUEUE_POLL);
535    }
536}
537
538/// RAM floor check (Layer 2): refuse below `floor_gb` (never queue — low RAM
539/// with an under-cap worker count means something ELSE is eating the machine).
540/// `<= 0` disables; unreadable RAM skips with a warning (fail open).
541fn check_ram_floor(floor_gb: f64) -> Result<(), i32> {
542    if floor_gb <= 0.0 {
543        return Ok(());
544    }
545    match available_ram_gb() {
546        Some(avail) if avail >= floor_gb => Ok(()),
547        Some(avail) => {
548            eprintln!(
549                "spawn-gate: available RAM {avail:.1}GB is below the min_free_gb floor \
550                 {floor_gb:.1}GB; refusing to spawn (--force to bypass)"
551            );
552            Err(EXIT_RAM_REFUSED)
553        }
554        None => {
555            eprintln!("spawn-gate: could not read available RAM; skipping the floor check");
556            Ok(())
557        }
558    }
559}
560
561fn acquire_worker_slot(guard: &mut GateGuard, name: &str, holder: &str) {
562    let key = format!("worker:{name}");
563    match claims::acquire(
564        &key,
565        holder,
566        claims::AcquireOpts {
567            ttl_ms: Some(WORKER_CLAIM_TTL_MS),
568            root: guard.root.clone(),
569            ..Default::default()
570        },
571    ) {
572        claims::AcquireOutcome::Acquired(_) => {
573            guard.worker_key = Some((key, holder.to_string()));
574        }
575        // Fail open: a slot claim is count VISIBILITY, not a correctness gate.
576        claims::AcquireOutcome::HeldByOther { .. } | claims::AcquireOutcome::Error(_) => {
577            eprintln!("spawn-gate: worker slot claim {key} unavailable; proceeding uncounted");
578        }
579    }
580}
581
582// ---------------------------------------------------------------------------
583// Layer 3: background QoS
584// ---------------------------------------------------------------------------
585
586/// Exec-wrap a child command at background priority when
587/// `config.agents.worker_qos` is `utility`: `taskpolicy -c utility -- <cmd>`
588/// on macOS, `nice -n 10 <cmd>` on Linux. Identity on `off` / other OSes.
589pub fn qos_wrap(config_cwd: &Path, argv: Vec<String>) -> Vec<String> {
590    if !agents_config::worker_qos_enabled(config_cwd) || argv.is_empty() {
591        return argv;
592    }
593    // Don't wrap a command that won't resolve: callers report a missing
594    // provider CLI as NotFound/127, and a taskpolicy prefix would swallow
595    // that into the wrapper's own error.
596    if !resolves_on_path(&argv[0]) {
597        return argv;
598    }
599    // Absolute paths + existence check: a missing wrapper must degrade to an
600    // unwrapped exec (fail open), never surface as a "CLI not found" spawn
601    // failure for the actual worker command.
602    let mut wrapped: Vec<String> = if cfg!(target_os = "macos") {
603        if !Path::new("/usr/sbin/taskpolicy").exists() {
604            return argv;
605        }
606        vec![
607            "/usr/sbin/taskpolicy".into(),
608            "-c".into(),
609            "utility".into(),
610            "--".into(),
611        ]
612    } else if cfg!(target_os = "linux") {
613        if !Path::new("/usr/bin/nice").exists() {
614            return argv;
615        }
616        vec!["/usr/bin/nice".into(), "-n".into(), "10".into()]
617    } else {
618        return argv;
619    };
620    wrapped.extend(argv);
621    wrapped
622}
623
624/// Does `cmd` resolve to an executable (explicit path, or a PATH lookup)?
625fn resolves_on_path(cmd: &str) -> bool {
626    if cmd.contains('/') {
627        return Path::new(cmd).exists();
628    }
629    std::env::var_os("PATH")
630        .map(|paths| std::env::split_paths(&paths).any(|d| d.join(cmd).is_file()))
631        .unwrap_or(false)
632}
633
634/// Best-effort post-hoc demotion of a claude-daemon-owned bg worker pid
635/// (`taskpolicy -b -p` on macOS, `renice 10 -p` on Linux; same uid, so
636/// permitted). Non-fatal: failure prints one warning, the spawn stands.
637pub fn qos_demote_pid(config_cwd: &Path, pid: u32) {
638    if !agents_config::worker_qos_enabled(config_cwd) {
639        return;
640    }
641    let status = if cfg!(target_os = "macos") {
642        std::process::Command::new("/usr/sbin/taskpolicy")
643            .args(["-b", "-p", &pid.to_string()])
644            .status()
645    } else if cfg!(target_os = "linux") {
646        std::process::Command::new("/usr/bin/renice")
647            .args(["10", "-p", &pid.to_string()])
648            .status()
649    } else {
650        return;
651    };
652    match status {
653        Ok(s) if s.success() => {}
654        _ => eprintln!("spawn-gate: QoS demotion of pid {pid} failed (non-fatal)"),
655    }
656}
657
658/// After a `--substrate bg` dispatch, poll the roster briefly for the new
659/// worker's pid and demote it post-hoc (its exec is claude's, not ours).
660/// Bounded ~10s; one warning if the pid never appears (AC3-UI).
661pub fn qos_demote_bg_worker(config_cwd: &Path, job_id: &str) {
662    if !agents_config::worker_qos_enabled(config_cwd) || job_id.is_empty() {
663        return;
664    }
665    let deadline = Instant::now() + Duration::from_secs(10);
666    loop {
667        if let Ok(roster) = ClaudeRoster::load_default() {
668            if let Some(pid) = roster.find(job_id).and_then(|w| w.pid) {
669                qos_demote_pid(config_cwd, pid);
670                return;
671            }
672        }
673        if Instant::now() >= deadline {
674            eprintln!(
675                "spawn-gate: bg worker {job_id} pid not in roster within 10s; \
676                 QoS demotion skipped (non-fatal)"
677            );
678            return;
679        }
680        std::thread::sleep(Duration::from_millis(500));
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn spawn_cap_guard_agrees_with_python_gate_fixture() {
690        // x-91b5 AC2-FR: this Rust guard must agree with the Python
691        // should_emit_spawn_cap on every fixture row. Both read the same JSON;
692        // a drift on either side fails its own assertion.
693        let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
694            .join("../../cli/tests/agents/fixtures/gate_escape_spawn_cap_parity.json");
695        let raw = std::fs::read_to_string(&fixture_path)
696            .unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
697        let fixture: serde_json::Value = serde_json::from_str(&raw).unwrap();
698        for sc in fixture["scenarios"].as_array().unwrap() {
699            let name = sc["name"].as_str().unwrap();
700            let env = sc["env"].clone();
701            let get = |k: &str| env.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
702            let expect = sc["expect"].as_bool().unwrap();
703            assert_eq!(spawn_cap_would_emit(get), expect, "row {name}");
704        }
705    }
706
707    const VM_STAT: &str = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
708Pages free:                              100000.\n\
709Pages active:                            500000.\n\
710Pages inactive:                          200000.\n\
711Pages speculative:                        50000.\n\
712Pages throttled:                              0.\n\
713Pages wired down:                        300000.\n\
714Pages purgeable:                          25000.\n";
715
716    #[test]
717    fn vm_stat_counts_free_inactive_speculative_purgeable() {
718        // (100000 + 200000 + 50000 + 25000) * 16384
719        assert_eq!(parse_vm_stat(VM_STAT), Some(375_000 * 16_384));
720    }
721
722    #[test]
723    fn vm_stat_unrecognized_shape_is_none() {
724        assert_eq!(parse_vm_stat(""), None);
725        assert_eq!(parse_vm_stat("something else entirely\n"), None);
726        // Header without any "Pages free" line: refuse to guess.
727        assert_eq!(
728            parse_vm_stat("Mach Virtual Memory Statistics: (page size of 16384 bytes)\n"),
729            None
730        );
731        // Garbage page count: None, not a partial sum.
732        let bad = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
733Pages free: banana.\n";
734        assert_eq!(parse_vm_stat(bad), None);
735    }
736
737    #[test]
738    fn meminfo_reads_memavailable_kb() {
739        let text = "MemTotal:       16384000 kB\nMemFree:         1000000 kB\n\
740MemAvailable:    8000000 kB\n";
741        assert_eq!(parse_meminfo(text), Some(8_000_000 * 1024));
742        assert_eq!(parse_meminfo("MemTotal: 1 kB\n"), None);
743        assert_eq!(parse_meminfo("MemAvailable: banana kB\n"), None);
744    }
745
746    /// Mirrors `test_no_wait_refuses_fast_when_the_mutex_is_contended` on the
747    /// Python side: a busy gate mutex IS queueing, so `--no-wait` must refuse on
748    /// it instead of falling through to the queue loop. The regression it pins
749    /// made every `--no-wait` caller wait the full `QUEUE_TIMEOUT` behind a
750    /// spawner that died mid-gate, then exit `EXIT_QUEUE_TIMEOUT`, so the
751    /// caller could not tell "cap is full" from "the gate is wedged".
752    #[test]
753    fn no_wait_refuses_fast_when_the_mutex_is_contended() {
754        let _g = claims::test_env_lock()
755            .lock()
756            .unwrap_or_else(|e| e.into_inner());
757        let dir = std::env::temp_dir().join(format!("fno-gate-nowait-{}", std::process::id()));
758        let _ = std::fs::remove_dir_all(&dir);
759        let root = dir.join("claims-root");
760        std::fs::create_dir_all(&root).unwrap();
761        std::env::set_var("FNO_CLAIMS_ROOT", &root);
762        // A high cap so the ONLY thing that can refuse here is the mutex.
763        let fnodir = dir.join(".fno");
764        std::fs::create_dir_all(&fnodir).unwrap();
765        std::fs::write(
766            fnodir.join("config.toml"),
767            "[agents]\nmax_live = 999\nmin_free_gb = 0\n",
768        )
769        .unwrap();
770
771        // Hold the mutex as somebody else, exactly as a corpse would.
772        let held = claims::acquire(
773            "spawn-gate",
774            "spawn-gate:999999:ghost",
775            claims::AcquireOpts {
776                ttl_ms: Some(GATE_CLAIM_TTL_MS),
777                root: Some(root.clone()),
778                ..Default::default()
779            },
780        );
781        assert!(
782            matches!(held, claims::AcquireOutcome::Acquired(_)),
783            "test setup: ghost must hold the mutex, got {held:?}"
784        );
785        // Positive control on the test's own premise. The ghost pid is dead, so
786        // the claim is `Suspect` (TTL unexpired, holder gone) and acquire must
787        // still report it held by another. Assert that instead of assuming it:
788        // if claim semantics ever let a dead holder be reclaimed, the mutex
789        // would be FREE, run_gate would sail through, and this test would pass
790        // while exercising none of the branch it exists to pin.
791        let contended = claims::acquire(
792            "spawn-gate",
793            "spawn-gate:probe",
794            claims::AcquireOpts {
795                ttl_ms: Some(GATE_CLAIM_TTL_MS),
796                root: Some(root.clone()),
797                ..Default::default()
798            },
799        );
800        assert!(
801            matches!(contended, claims::AcquireOutcome::HeldByOther { .. }),
802            "test premise broken: a dead-holder claim must still read as held, got {contended:?}"
803        );
804
805        let started = Instant::now();
806        let got = run_gate(
807            &dir,
808            &dir.join("registry.json"),
809            "w2",
810            "bg",
811            GateFlags {
812                force: false,
813                no_wait: true,
814            },
815        );
816        let elapsed = started.elapsed();
817        let _ = claims::release("spawn-gate", "spawn-gate:999999:ghost", Some(&root), None);
818        std::env::remove_var("FNO_CLAIMS_ROOT");
819
820        assert_eq!(
821            got.err(),
822            Some(EXIT_NO_WAIT),
823            "must refuse with the no-wait code"
824        );
825        assert!(
826            elapsed < QUEUE_TIMEOUT,
827            "must refuse fast, not queue: took {elapsed:?}"
828        );
829    }
830
831    #[test]
832    fn urldecode_inverts_encode_key() {
833        let key = "worker:my agent/x";
834        assert_eq!(urldecode(&claims::encode_key(key)).as_deref(), Some(key));
835        assert_eq!(urldecode("bad%zz"), None);
836    }
837
838    #[test]
839    fn qos_wrap_wraps_or_passes_through() {
840        // test_env_lock: qos_wrap reads config via FNO_CONFIG-sensitive
841        // resolve; serialize with the other env-touching tests.
842        let _g = claims::test_env_lock()
843            .lock()
844            .unwrap_or_else(|e| e.into_inner());
845        let dir = std::env::temp_dir().join(format!("fno-gate-qos-{}", std::process::id()));
846        let fnodir = dir.join(".fno");
847        std::fs::create_dir_all(&fnodir).unwrap();
848
849        std::fs::write(
850            fnodir.join("config.toml"),
851            "[agents]\nworker_qos = \"off\"\n",
852        )
853        .unwrap();
854        // `sh` resolves on every CI platform (a non-resolving argv[0] is
855        // deliberately left unwrapped so NotFound/127 semantics survive).
856        let argv = vec!["sh".to_string(), "-c".to_string(), "true".to_string()];
857        assert_eq!(qos_wrap(&dir, argv.clone()), argv, "off = identity");
858
859        std::fs::write(
860            fnodir.join("config.toml"),
861            "[agents]\nworker_qos = \"utility\"\n",
862        )
863        .unwrap();
864        let wrapped = qos_wrap(&dir, argv.clone());
865        if cfg!(target_os = "macos") && Path::new("/usr/sbin/taskpolicy").exists() {
866            assert_eq!(
867                &wrapped[..4],
868                &["/usr/sbin/taskpolicy", "-c", "utility", "--"]
869            );
870            assert_eq!(&wrapped[4..], &argv[..]);
871        } else if cfg!(target_os = "linux") && Path::new("/usr/bin/nice").exists() {
872            assert_eq!(&wrapped[..3], &["/usr/bin/nice", "-n", "10"]);
873            assert_eq!(&wrapped[3..], &argv[..]);
874        } else {
875            assert_eq!(wrapped, argv, "no wrapper binary -> identity (fail open)");
876        }
877
878        // A non-resolving command is never wrapped (NotFound must stay the
879        // caller's error, not taskpolicy's).
880        let ghost = vec!["definitely-not-a-real-cli-xyz".to_string()];
881        assert_eq!(qos_wrap(&dir, ghost.clone()), ghost);
882    }
883
884    #[test]
885    fn slot_count_absent_sources_is_zero_with_rows_needing_pids() {
886        // A registry path that does not exist must not panic; the count is >= 0
887        // and a malformed file warns rather than errors (LD5, fail open).
888        // Serialize: slot_count reads the claims root.
889        let _g = claims::test_env_lock()
890            .lock()
891            .unwrap_or_else(|e| e.into_inner());
892        // Missing registry: fresh-machine semantics, zero contribution, no
893        // panic (load_registry treats absent as empty).
894        let mut warnings = Vec::new();
895        let missing = std::env::temp_dir().join("fno-gate-noreg/registry.json");
896        let _ = slot_count(&missing, &mut warnings);
897
898        // Malformed registry: fail OPEN with one warning (LD5), never an error.
899        let dir = std::env::temp_dir().join(format!("fno-gate-badreg-{}", std::process::id()));
900        std::fs::create_dir_all(&dir).unwrap();
901        let bad = dir.join("registry.json");
902        std::fs::write(&bad, "{ not json").unwrap();
903        let mut warnings = Vec::new();
904        let _ = slot_count(&bad, &mut warnings);
905        assert!(
906            warnings.iter().any(|w| w.contains("registry unreadable")),
907            "malformed registry must warn, got {warnings:?}"
908        );
909    }
910
911    /// AC1-FR (x-bdf9): the Rust gate and the Python mirror must return the same
912    /// slot count for the same synthetic registry+roster. Both suites read this
913    /// ONE fixture; a divergence in either gate's counting rule (e.g. re-adding
914    /// the roster to the slot count) fails its own assertion. A populated roster
915    /// is materialized deliberately: `slot_count` must ignore it, so a future
916    /// re-introduction of roster counting inflates the count and trips here.
917    #[test]
918    fn slot_count_agrees_with_python_gate_fixture() {
919        let _g = claims::test_env_lock()
920            .lock()
921            .unwrap_or_else(|e| e.into_inner());
922        let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
923            .join("../../cli/tests/agents/fixtures/spawn_gate_slot_agreement.json");
924        let raw = std::fs::read_to_string(&fixture_path)
925            .unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
926        let fixture: serde_json::Value = serde_json::from_str(&raw).unwrap();
927        let self_pid = std::process::id();
928        // 2^22+17: realistically never a live pid (mirrors the Python fixture).
929        let dead_pid: u32 = 4_194_321;
930        let resolve = |v: &serde_json::Value| -> Option<u32> {
931            match v.as_str() {
932                Some("self") => Some(self_pid),
933                Some("dead") => Some(dead_pid),
934                _ => None, // absent pid = disk-only row
935            }
936        };
937        let base = std::env::temp_dir().join(format!("fno-gate-agree-{self_pid}"));
938        for (i, sc) in fixture["scenarios"].as_array().unwrap().iter().enumerate() {
939            let dir = base.join(format!("s{i}"));
940            std::fs::create_dir_all(&dir).unwrap();
941            // Isolate the claims root: no real worker:<name> slot claim leaks in.
942            std::env::set_var("FNO_CLAIMS_ROOT", dir.join("claims-root"));
943            // Populate a roster the slot count must ignore.
944            let daemon = dir.join("daemon");
945            std::fs::create_dir_all(&daemon).unwrap();
946            std::env::set_var("FNO_CLAUDE_DAEMON_DIR", &daemon);
947            let mut rworkers = Vec::new();
948            for (j, r) in sc["roster"].as_array().unwrap().iter().enumerate() {
949                let short = r["short"]
950                    .as_str()
951                    .map(|s| s.to_string())
952                    .unwrap_or_else(|| format!("{:08x}", 0xaaaa_0000u32 + j as u32));
953                let pidf = resolve(&r["pid"])
954                    .map(|p| format!(r#","pid":{p}"#))
955                    .unwrap_or_default();
956                rworkers.push(format!(
957                    r#""{short}":{{"sessionId":"{short}-1-2-3-4"{pidf}}}"#
958                ));
959            }
960            std::fs::write(
961                daemon.join("roster.json"),
962                format!(
963                    r#"{{"proto":1,"supervisorPid":1,"workers":{{{}}}}}"#,
964                    rworkers.join(",")
965                ),
966            )
967            .unwrap();
968            // Materialize the registry.
969            let mut entries = Vec::new();
970            for row in sc["registry"].as_array().unwrap() {
971                let name = row["name"].as_str().unwrap();
972                let status = row["status"].as_str().unwrap();
973                let pidf = resolve(&row["pid"])
974                    .map(|p| format!(r#","pid":{p}"#))
975                    .unwrap_or_default();
976                let csidf = row["short_id"]
977                    .as_str()
978                    .map(|s| format!(r#","short_id":"{s}""#))
979                    .unwrap_or_default();
980                entries.push(format!(
981                    r#"{{"name":"{name}","provider":"claude","cwd":"/tmp","status":"{status}","created_at":"2026-01-01T00:00:00Z"{pidf}{csidf}}}"#
982                ));
983            }
984            let reg = dir.join("registry.json");
985            std::fs::write(
986                &reg,
987                format!(
988                    r#"{{"schema_version":1,"entries":[{}]}}"#,
989                    entries.join(",")
990                ),
991            )
992            .unwrap();
993
994            let mut warnings = Vec::new();
995            let got = slot_count(&reg, &mut warnings);
996            let want = sc["expect_slot_count"].as_u64().unwrap() as usize;
997            assert_eq!(
998                got,
999                want,
1000                "scenario {:?}: got {got}, want {want}",
1001                sc["name"].as_str().unwrap_or("?")
1002            );
1003        }
1004        std::env::remove_var("FNO_CLAIMS_ROOT");
1005        std::env::remove_var("FNO_CLAUDE_DAEMON_DIR");
1006    }
1007}