1use 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
29pub const EXIT_QUEUE_TIMEOUT: i32 = 75;
31pub const EXIT_NO_WAIT: i32 = 76;
32pub const EXIT_RAM_REFUSED: i32 = 77;
33
34const QUEUE_POLL: Duration = Duration::from_secs(2);
36const QUEUE_PROGRESS_EVERY: Duration = Duration::from_secs(30);
37const QUEUE_TIMEOUT: Duration = Duration::from_secs(600);
38const GATE_CLAIM_TTL_MS: i64 = 5 * 60 * 1000;
41const MUTEX_WAIT_BUDGET: Duration = Duration::from_secs(60);
51const WORKER_CLAIM_TTL_MS: i64 = 4 * 60 * 60 * 1000;
54
55fn 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
70pub fn parse_vm_stat(text: &str) -> Option<u64> {
78 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 found_free.then_some(counted * page_size)
111}
112
113pub 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
124pub 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
149pub fn slot_count(registry_path: &Path, warnings: &mut Vec<String>) -> usize {
178 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 Some(p) => pid_is_ours(p, e.pid_start_time),
206 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
228fn 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, };
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 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
267fn 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
286fn gate_claims_root() -> Option<PathBuf> {
290 claims::global_claims_root()
291}
292
293#[derive(Debug, Clone, Copy, Default)]
299pub struct GateFlags {
300 pub force: bool,
302 pub no_wait: bool,
304}
305
306#[derive(Debug, Default)]
310pub struct GateGuard {
311 gate_key: Option<(String, String)>, worker_key: Option<(String, String)>,
316 root: Option<PathBuf>,
317}
318
319impl GateGuard {
320 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 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
346pub 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
359fn 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
383pub 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 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 let mut mutex_blocked_since: Option<Instant> = None;
427
428 loop {
429 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 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 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 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 guard.release_gate_mutex();
495 }
496 return Ok(guard);
499 }
500 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
538fn 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 claims::AcquireOutcome::HeldByOther { .. } | claims::AcquireOutcome::Error(_) => {
577 eprintln!("spawn-gate: worker slot claim {key} unavailable; proceeding uncounted");
578 }
579 }
580}
581
582pub 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 if !resolves_on_path(&argv[0]) {
597 return argv;
598 }
599 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
624fn 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
634pub 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
658pub 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 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 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 assert_eq!(
728 parse_vm_stat("Mach Virtual Memory Statistics: (page size of 16384 bytes)\n"),
729 None
730 );
731 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 #[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 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 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 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 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 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 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 let _g = claims::test_env_lock()
890 .lock()
891 .unwrap_or_else(|e| e.into_inner());
892 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 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 #[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 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, }
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 std::env::set_var("FNO_CLAIMS_ROOT", dir.join("claims-root"));
943 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 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 ®,
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(®, &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}