1use crate::events::EventEmitter;
15use crate::paths::{self, AgentsHome};
16use crate::protocol::{
17 read_request, write_request, write_response, ErrorCode, Namespace, Request, Response,
18};
19use crate::state::{self, RegistryEntry};
20use crate::AgentStatus;
21use base64::Engine as _;
22use serde_json::{json, Map, Value};
23use std::os::unix::process::CommandExt; use std::path::PathBuf;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27use tokio::net::{UnixListener, UnixStream};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum DaemonState {
33 ColdStart,
34 Recovering,
35 Serving,
36 IdlePendingExit,
37 ShuttingDown,
38 Exited,
39}
40
41impl DaemonState {
42 pub fn as_str(&self) -> &'static str {
43 match self {
44 DaemonState::ColdStart => "cold_start",
45 DaemonState::Recovering => "recovering",
46 DaemonState::Serving => "serving",
47 DaemonState::IdlePendingExit => "idle_pending_exit",
48 DaemonState::ShuttingDown => "shutting_down",
49 DaemonState::Exited => "exited",
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
56pub struct DaemonOptions {
57 pub idle_exit: Duration,
58 pub worker_bin: PathBuf,
62 pub reconcile_on_start: bool,
67}
68
69impl Default for DaemonOptions {
70 fn default() -> Self {
71 DaemonOptions {
72 idle_exit: Duration::from_secs(1800),
73 worker_bin: resolve_worker_bin(),
74 reconcile_on_start: true,
75 }
76 }
77}
78
79fn resolve_worker_bin() -> PathBuf {
80 if let Some(v) = std::env::var_os("FNO_AGENTS_WORKER_BIN") {
81 return PathBuf::from(v);
82 }
83 std::env::current_exe()
85 .ok()
86 .and_then(|p| p.parent().map(|d| d.join("fno-agents-worker")))
87 .unwrap_or_else(|| PathBuf::from("fno-agents-worker"))
88}
89
90#[derive(Debug, thiserror::Error)]
91pub enum DaemonError {
92 #[error("io: {0}")]
93 Io(#[from] std::io::Error),
94 #[error("another daemon is already serving on {0}")]
95 AlreadyRunning(PathBuf),
96 #[error("socket permission invariant failed: {0}")]
97 Permission(String),
98 #[error("filesystem does not support advisory locking at {0}: {1}")]
99 FlockUnsupported(PathBuf, String),
100 #[error("state: {0}")]
101 State(#[from] state::StateError),
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum InconsistencyReason {
110 MissingStateJson,
113 UnreadableStateJson,
115}
116
117impl InconsistencyReason {
118 pub fn as_str(&self) -> &'static str {
119 match self {
120 InconsistencyReason::MissingStateJson => "missing_state_json",
121 InconsistencyReason::UnreadableStateJson => "unreadable_state_json",
122 }
123 }
124}
125
126#[derive(Debug, Default, PartialEq)]
128pub struct RecoveryReport {
129 pub inconsistent: Vec<(String, InconsistencyReason)>,
133 pub archived_orphans: Vec<String>,
134 pub reaped_pids: Vec<u32>,
135 pub recovered_drives: Vec<String>,
136}
137
138pub fn recover(home: &AgentsHome, emitter: &EventEmitter) -> RecoveryReport {
148 let mut report = RecoveryReport::default();
149 let registry = state::load_registry(&home.registry_json()).unwrap_or_default();
150
151 let registered: std::collections::BTreeSet<String> = registry
152 .entries
153 .iter()
154 .map(|e| e.short_id.clone())
155 .collect();
156
157 for entry in ®istry.entries {
159 if entry.short_id.is_empty() {
166 continue;
167 }
168 let state_path = home.state_json(&entry.short_id);
169 match state::load_state(&state_path) {
170 Ok(Some(mut st)) => {
171 let taken = st.pty.as_mut().and_then(|p| p.take_active_drive());
173 if let Some(drive) = taken {
174 let mut fields = Map::new();
175 if let Some(sid) = &drive.session_id {
176 fields.insert("session_id".into(), Value::String(sid.clone()));
177 }
178 fields.insert("reason".into(), Value::String("daemon_restart".into()));
179 let _ = emitter.emit_fields("drive_crashed", fields);
182 let _ = state::write_state_atomic(&state_path, &st);
183 report.recovered_drives.push(entry.short_id.clone());
184 }
185 }
186 Ok(None) => {
187 let reason = InconsistencyReason::MissingStateJson;
190 let _ = emitter.emit_fields(
191 "agent_inconsistent",
192 json_obj(&[
193 ("short_id", Value::String(entry.short_id.clone())),
194 ("reason", Value::String(reason.as_str().into())),
195 ]),
196 );
197 report.inconsistent.push((entry.short_id.clone(), reason));
198 }
199 Err(_) => {
200 let reason = InconsistencyReason::UnreadableStateJson;
204 let _ = emitter.emit_fields(
205 "agent_inconsistent",
206 json_obj(&[
207 ("short_id", Value::String(entry.short_id.clone())),
208 ("reason", Value::String(reason.as_str().into())),
209 ]),
210 );
211 report.inconsistent.push((entry.short_id.clone(), reason));
212 }
213 }
214 }
215
216 if let Ok(read) = std::fs::read_dir(home.root()) {
218 for entry in read.flatten() {
219 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
220 continue;
221 }
222 let name = match entry.file_name().into_string() {
223 Ok(n) if !n.starts_with('.') => n,
224 _ => continue,
225 };
226 if registered.contains(&name) {
227 continue;
228 }
229 if home.state_json(&name).exists() {
231 let ts = now_compact();
232 let dest = home.orphan_archive_dest(&name, &ts);
233 let _ = std::fs::create_dir_all(home.orphaned_dir());
234 if std::fs::rename(home.agent_dir(&name), &dest).is_ok() {
235 let _ = emitter.emit_fields(
236 "agent_orphan_state_archived",
237 json_obj(&[
238 ("short_id", Value::String(name.clone())),
239 (
240 "archived_to",
241 Value::String(dest.to_string_lossy().into_owned()),
242 ),
243 ]),
244 );
245 report.archived_orphans.push(name);
246 }
247 }
248 }
249 }
250
251 let live_workers = home.scan_worker_sockets();
258 let mut to_reap: Vec<(String, u32)> = Vec::new();
259 for entry in ®istry.entries {
260 if live_workers.contains(&entry.short_id) {
261 continue; }
263 if let Some(pid) = entry.pid {
264 if !pid_is_ours(pid, entry.pid_start_time) {
265 to_reap.push((entry.short_id.clone(), pid));
266 }
267 }
268 }
269 if !to_reap.is_empty() {
270 let reaped: std::collections::BTreeSet<String> =
271 to_reap.iter().map(|(s, _)| s.clone()).collect();
272 if let Err(e) = state::update_registry(&home.registry_json(), |r| {
275 for e in r.entries.iter_mut() {
276 if reaped.contains(&e.short_id) {
277 e.status = AgentStatus::Exited;
278 }
279 }
280 }) {
281 let _ = emitter.emit(
282 "daemon_recovery_error",
283 &json!({"op": "reap_orphans", "error": e.to_string()}),
284 );
285 }
286 for (short_id, pid) in to_reap {
287 let _ = emitter.emit_fields(
288 "agent_orphan_reaped",
289 json_obj(&[
290 ("short_id", Value::String(short_id)),
291 ("pid", Value::Number(pid.into())),
292 ]),
293 );
294 report.reaped_pids.push(pid);
295 }
296 }
297
298 report
299}
300
301#[cfg(target_os = "linux")]
307pub fn process_start_time(pid: u32) -> Option<u64> {
308 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
313 let after = stat.rsplit_once(')')?.1;
314 after.split_whitespace().nth(19)?.parse::<u64>().ok()
315}
316
317#[cfg(target_os = "macos")]
321pub fn process_start_time(pid: u32) -> Option<u64> {
322 use std::mem;
323 let mut info: libc::proc_bsdinfo = unsafe { mem::zeroed() };
324 let size = mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
325 let written = unsafe {
329 libc::proc_pidinfo(
330 pid as libc::c_int,
331 libc::PROC_PIDTBSDINFO,
332 0,
333 &mut info as *mut _ as *mut libc::c_void,
334 size,
335 )
336 };
337 if written != size {
338 return None;
339 }
340 Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
341}
342
343#[cfg(not(any(target_os = "linux", target_os = "macos")))]
344pub fn process_start_time(_pid: u32) -> Option<u64> {
345 None
346}
347
348pub fn pid_is_ours(pid: u32, recorded: Option<u64>) -> bool {
355 if pid <= 1 {
362 return false;
363 }
364 if unsafe { libc::kill(pid as libc::pid_t, 0) } != 0 {
372 return false;
373 }
374 match (recorded, process_start_time(pid)) {
375 (Some(rec), Some(now)) => rec == now,
376 _ => true,
378 }
379}
380
381pub async fn bind_supervisor_socket(home: &AgentsHome) -> Result<UnixListener, DaemonError> {
394 home.ensure_root()?;
395 flock_self_test(home)?;
396
397 let sock = home.supervisor_sock();
398 if sock.exists() {
399 if UnixStream::connect(&sock).await.is_ok() {
401 return Err(DaemonError::AlreadyRunning(sock));
402 }
403 let _ = std::fs::remove_file(&sock);
405 }
406
407 let listener = match UnixListener::bind(&sock) {
408 Ok(l) => l,
409 Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
410 return Err(DaemonError::AlreadyRunning(sock));
412 }
413 Err(e) => return Err(e.into()),
414 };
415
416 paths::set_file_mode_0600(&sock)?;
417
418 #[cfg(unix)]
420 {
421 if !paths::is_dir_mode_0700(home.root()) {
422 return Err(DaemonError::Permission(format!(
423 "{} is not mode 0700",
424 home.root().display()
425 )));
426 }
427 if !paths::is_file_mode_0600(&sock) {
428 return Err(DaemonError::Permission(format!(
429 "{} is not mode 0600",
430 sock.display()
431 )));
432 }
433 }
434
435 Ok(listener)
436}
437
438fn flock_self_test(home: &AgentsHome) -> Result<(), DaemonError> {
442 let probe = home.root().join(".flock-probe");
443 let file = std::fs::OpenOptions::new()
444 .create(true)
445 .read(true)
446 .write(true)
447 .truncate(false)
448 .open(&probe)
449 .map_err(|e| DaemonError::FlockUnsupported(probe.clone(), e.to_string()))?;
450 let lock_res = file.lock();
453 if lock_res.is_ok() {
454 let _ = file.unlock();
455 }
456 let _ = std::fs::remove_file(&probe);
457 lock_res.map_err(|e| DaemonError::FlockUnsupported(probe.clone(), e.to_string()))?;
458 Ok(())
459}
460
461pub async fn run(home: AgentsHome, opts: DaemonOptions) -> Result<(), DaemonError> {
470 let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
471
472 let listener = match bind_supervisor_socket(&home).await {
474 Ok(l) => l,
475 Err(DaemonError::AlreadyRunning(_)) => {
476 return Ok(());
478 }
479 Err(e) => return Err(e),
480 };
481
482 emit_state(&emitter, DaemonState::Recovering);
484 let report = recover(&home, &emitter);
485
486 if opts.reconcile_on_start {
497 let swept: Result<ReconcileSweepResult, String> =
502 if std::env::var("FNO_AGENTS_FAIL_STARTUP_RECONCILE").is_ok() {
503 Err(
504 "forced startup-reconcile failure (FNO_AGENTS_FAIL_STARTUP_RECONCILE)"
505 .to_string(),
506 )
507 } else {
508 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
509 run_reconcile_sweep(&home, &emitter)
510 }))
511 .unwrap_or_else(|_| {
512 Err(
513 "startup reconcile sweep panicked; serving last-recorded status"
514 .to_string(),
515 )
516 })
517 };
518 match swept {
519 Ok(result) => {
520 let _ = emitter.emit(
521 "startup_reconcile_done",
522 &json!({
523 "updated": result.outcome.updated.len(),
524 "deferred": result.outcome.deferred,
525 }),
526 );
527 }
528 Err(msg) => {
529 let _ = emitter.emit("startup_reconcile_failed", &json!({"error": msg}));
530 }
531 }
532 }
533
534 let started_at = Instant::now();
537 let exe_fingerprint = crate::drift::ExeFingerprint::current();
542 if exe_fingerprint.is_none() {
543 let _ = emitter.emit("daemon_exe_fingerprint_unavailable", &json!({}));
546 }
547 let pid_start_time = process_start_time(std::process::id());
548 let _ = emitter.emit(
549 "daemon_started",
550 &json!({
551 "pid": std::process::id(),
552 "version": env!("CARGO_PKG_VERSION"),
553 "recovered_drives": report.recovered_drives.len(),
554 }),
555 );
556 emit_state(&emitter, DaemonState::Serving);
557
558 let ctx = Arc::new(Ctx {
560 home,
561 emitter,
562 opts,
563 started_at,
564 exe_fingerprint,
565 pid_start_time,
566 drives: crate::drive::new_table(),
567 });
568
569 let ab_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
577 let ab_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
578 let ab_handle = {
579 let abi_bin = std::env::var("FNO_BIN").unwrap_or_else(|_| "fno".to_string());
580 let ab_emitter = EventEmitter::new(ctx.home.events_jsonl(), "active-backlog");
581 let live = Arc::clone(&ab_live);
582 let shutdown = Arc::clone(&ab_shutdown);
583 tokio::spawn(crate::active_backlog::run_supervisor(
584 abi_bin, ab_emitter, live, shutdown,
585 ))
586 };
587
588 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
590 let mut idle_check = tokio::time::interval(Duration::from_secs(5));
591 idle_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
592 let mut last_activity = Instant::now();
593
594 loop {
595 tokio::select! {
596 accepted = listener.accept() => {
597 if let Ok((stream, _)) = accepted {
598 last_activity = Instant::now();
599 let ctx = Arc::clone(&ctx);
604 tokio::spawn(async move {
605 serve_connection(ctx, stream).await;
606 });
607 }
608 }
609 _ = sigterm.recv() => {
610 emit_state(&ctx.emitter, DaemonState::ShuttingDown);
611 let _ = ctx.emitter.emit("daemon_shutting_down", &json!({"reason": "sigterm"}));
612 break;
613 }
614 _ = idle_check.tick() => {
615 reap_zombies();
618 let empty = state::load_registry(&ctx.home.registry_json())
619 .map(|r| r.entries.is_empty())
620 .unwrap_or(true);
621 let no_drives = ctx.drives.lock().await.is_empty();
624 let ab_active = ab_live.load(std::sync::atomic::Ordering::SeqCst);
628 if empty && no_drives && !ab_active && last_activity.elapsed() >= ctx.opts.idle_exit {
629 emit_state(&ctx.emitter, DaemonState::IdlePendingExit);
630 let _ = ctx.emitter.emit("daemon_idle_pending_exit", &json!({}));
631 emit_state(&ctx.emitter, DaemonState::ShuttingDown);
632 let _ = ctx.emitter.emit(
633 "daemon_shutting_down",
634 &json!({"reason": "idle"}),
635 );
636 break;
637 }
638 }
639 }
640 }
641
642 ab_shutdown.store(true, std::sync::atomic::Ordering::SeqCst);
648 ab_handle.abort();
649
650 let _ = std::fs::remove_file(ctx.home.supervisor_sock());
651 emit_state(&ctx.emitter, DaemonState::Exited);
652 let _ = ctx.emitter.emit("daemon_exited", &json!({"clean": true}));
653 Ok(())
654}
655
656struct Ctx {
658 home: AgentsHome,
659 emitter: EventEmitter,
660 opts: DaemonOptions,
661 started_at: Instant,
662 exe_fingerprint: Option<crate::drift::ExeFingerprint>,
666 pid_start_time: Option<u64>,
670 drives: crate::drive::DriveTable,
673}
674
675fn emit_state(emitter: &EventEmitter, state: DaemonState) {
676 let _ = emitter.emit("daemon_state", &json!({"state": state.as_str()}));
677}
678
679const CONN_READ_TIMEOUT: Duration = Duration::from_secs(30);
682
683async fn serve_connection(ctx: Arc<Ctx>, mut stream: UnixStream) {
684 let req = match tokio::time::timeout(CONN_READ_TIMEOUT, read_request(&mut stream)).await {
689 Err(_elapsed) => return, Ok(Ok(r)) => r,
691 Ok(Err(crate::protocol::ProtocolError::UnexpectedEof)) => return, Ok(Err(e)) => {
693 let resp = Response::err(0, ErrorCode::MalformedFrame, format!("{e}"));
696 let _ = write_response(&mut stream, &resp).await;
697 return;
698 }
699 };
700 if req.method == "agent.drive" {
706 crate::drive::handle_drive(&ctx.home, &ctx.emitter, &ctx.drives, &req, stream).await;
707 return;
708 }
709 if req.method == "agent.logs" {
713 crate::logs::handle_logs(&ctx.home, &req, stream).await;
714 return;
715 }
716 let resp = dispatch(&ctx, &req).await;
717 let _ = write_response(&mut stream, &resp).await;
718}
719
720async fn run_blocking<F>(ctx: &Arc<Ctx>, req: &Request, f: F) -> Response
723where
724 F: FnOnce(&Ctx, &Request) -> Response + Send + 'static,
725{
726 let ctx = Arc::clone(ctx);
727 let req = req.clone();
728 let id = req.id;
729 match tokio::task::spawn_blocking(move || f(&ctx, &req)).await {
730 Ok(resp) => resp,
731 Err(_) => Response::err(id, ErrorCode::Internal, "handler task panicked"),
732 }
733}
734
735async fn load_registry_offloaded(path: PathBuf) -> state::Registry {
741 tokio::task::spawn_blocking(move || state::load_registry(&path))
742 .await
743 .ok()
744 .and_then(|r| r.ok())
745 .unwrap_or_default()
746}
747
748async fn update_registry_offloaded<F, T>(path: PathBuf, f: F) -> Result<T, state::StateError>
753where
754 F: FnOnce(&mut state::Registry) -> T + Send + 'static,
755 T: Send + 'static,
756{
757 match tokio::task::spawn_blocking(move || state::update_registry(&path, f)).await {
758 Ok(result) => result,
759 Err(e) => Err(state::StateError::Io(std::io::Error::other(format!(
760 "update_registry task panicked: {e}"
761 )))),
762 }
763}
764
765async fn dispatch(ctx: &Arc<Ctx>, req: &Request) -> Response {
766 match Namespace::of(&req.method) {
767 Namespace::Agent => dispatch_agent(ctx, req).await,
768 Namespace::Channel => dispatch_channel(ctx, req).await,
769 Namespace::Unknown => Response::err(
770 req.id,
771 ErrorCode::UnknownMethod,
772 format!("unknown namespace for method `{}`", req.method),
773 ),
774 }
775}
776
777async fn dispatch_agent(ctx: &Arc<Ctx>, req: &Request) -> Response {
778 match Namespace::verb(&req.method) {
781 Some("spawn") => handle_spawn(ctx, req).await,
782 Some("ask") => handle_ask(ctx, req).await,
783 Some("deliver") => handle_deliver(ctx, req).await,
784 Some("switchboard") => handle_switchboard(ctx, req).await,
785 Some("gate_check") => handle_gate_check(ctx, req).await,
786 Some("stop") => handle_stop(ctx, req).await,
787 Some("rm") => handle_rm(ctx, req).await,
788 Some("list") => run_blocking(ctx, req, handle_list).await,
789 Some("status") => handle_status(ctx, req).await,
792 Some("reconcile") => run_blocking(ctx, req, handle_reconcile).await,
793 _ => Response::err(
794 req.id,
795 ErrorCode::UnknownMethod,
796 format!("unknown agent verb in `{}`", req.method),
797 ),
798 }
799}
800
801fn valid_agent_name(name: &str) -> bool {
803 !name.is_empty()
804 && name.len() <= 64
805 && name
806 .chars()
807 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
808}
809
810fn derive_short_id(name: &str, registry: &state::Registry) -> String {
812 let base: String = name
813 .chars()
814 .filter(|c| c.is_ascii_alphanumeric())
815 .take(8)
816 .collect();
817 let base = if base.is_empty() {
818 "agent".into()
819 } else {
820 base
821 };
822 if registry.entries.iter().all(|e| e.short_id != base) {
823 return base;
824 }
825 for n in 1..10_000 {
826 let cand = format!("{base}{n}");
827 if registry.entries.iter().all(|e| e.short_id != cand) {
828 return cand;
829 }
830 }
831 format!("{base}-{}", now_compact())
832}
833
834fn entry_holds_session(e: &RegistryEntry, uuid: &str) -> bool {
836 e.codex_session_id.as_deref() == Some(uuid)
837 || e.gemini_session_id.as_deref() == Some(uuid)
838 || e.session_id.as_deref() == Some(uuid)
839}
840
841fn is_non_terminal(s: AgentStatus) -> bool {
844 !matches!(s, AgentStatus::Exited | AgentStatus::PermanentDead)
845}
846
847#[derive(Debug, PartialEq, Eq)]
849enum PromoteAdmission {
850 Ok { provider: String },
852 Reject(String),
854}
855
856fn admit_promote(entries: &[RegistryEntry], uuid: &str) -> PromoteAdmission {
874 if uuid.trim().is_empty() {
875 return PromoteAdmission::Reject(
876 "promote requires a non-empty --from <session-uuid>".into(),
877 );
878 }
879 if let Some(h) = entries
883 .iter()
884 .rev()
885 .find(|e| e.is_interactive() && entry_holds_session(e, uuid) && is_non_terminal(e.status))
886 {
887 return PromoteAdmission::Reject(format!(
888 "session '{uuid}' is already hosted by live interactive agent '{}'; one interactive host per session",
889 h.name
890 ));
891 }
892 let Some(source) = entries.iter().rev().find(|e| entry_holds_session(e, uuid)) else {
893 return PromoteAdmission::Reject(format!(
894 "unknown session '{uuid}': no recorded agent has it as a codex/gemini session id"
895 ));
896 };
897 if matches!(
898 source.status,
899 AgentStatus::Spawning | AgentStatus::Restarting | AgentStatus::Busy
900 ) {
901 return PromoteAdmission::Reject(format!(
902 "source session '{uuid}' still running (agent '{}' status {:?}); only a settled session may be promoted",
903 source.name, source.status
904 ));
905 }
906 if source.provider.trim().is_empty() {
907 return PromoteAdmission::Reject(format!(
910 "source agent '{}' for session '{uuid}' has no provider recorded; cannot infer interactive host",
911 source.name
912 ));
913 }
914 PromoteAdmission::Ok {
915 provider: source.provider.clone(),
916 }
917}
918
919async fn handle_spawn(ctx: &Ctx, req: &Request) -> Response {
920 let p = &req.params;
921 let name = match p.get("name").and_then(|v| v.as_str()) {
922 Some(n) if valid_agent_name(n) => n.to_string(),
923 Some(_) => {
924 return Response::err(
925 req.id,
926 ErrorCode::InvalidParams,
927 "name must be 1-64 chars of [A-Za-z0-9_-]",
928 )
929 }
930 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
931 };
932 let mut provider = p
933 .get("provider")
934 .and_then(|v| v.as_str())
935 .unwrap_or("codex")
936 .to_string();
937 let cwd = match p.get("cwd").and_then(|v| v.as_str()) {
945 Some(c) => PathBuf::from(c),
946 None => {
947 let fallback = std::env::temp_dir();
948 let _ = ctx.emitter.emit(
949 "agent_spawn_cwd_fallback",
950 &json!({"name": name, "fallback": fallback.to_string_lossy()}),
951 );
952 fallback
953 }
954 };
955
956 let message = p
959 .get("message")
960 .and_then(|v| v.as_str())
961 .unwrap_or("")
962 .to_string();
963 let from_name = p
964 .get("from_name")
965 .and_then(|v| v.as_str())
966 .map(String::from);
967 let yolo = p.get("yolo").and_then(|v| v.as_bool()).unwrap_or(false);
968 let host_mode = p
973 .get("host_mode")
974 .and_then(|v| v.as_str())
975 .unwrap_or(crate::state::HOST_MODE_EXEC)
976 .to_string();
977 let interactive = host_mode == crate::state::HOST_MODE_INTERACTIVE;
978 let resume_id = p
979 .get("resume_id")
980 .and_then(|v| v.as_str())
981 .map(|s| s.to_string());
982
983 if interactive {
990 if provider == "claude" {
1002 let explicit_argv = p.get("argv").and_then(|v| v.as_array()).map(|a| {
1003 a.iter()
1004 .filter_map(|v| v.as_str().map(String::from))
1005 .collect::<Vec<String>>()
1006 });
1007 return spawn_claude_stream_lane(
1008 ctx,
1009 req,
1010 &name,
1011 &cwd,
1012 resume_id.as_deref(),
1013 explicit_argv,
1014 )
1015 .await;
1016 }
1017 if let Some(uuid) = resume_id.as_deref() {
1018 let registry = load_registry_offloaded(ctx.home.registry_json()).await;
1019 match admit_promote(®istry.entries, uuid) {
1020 PromoteAdmission::Ok { provider: inferred } => provider = inferred,
1021 PromoteAdmission::Reject(reason) => {
1022 let _ = ctx.emitter.emit(
1023 "agent_spawn_failed",
1024 &json!({"name": name, "reason": "promote_rejected", "detail": reason}),
1025 );
1026 return Response::err(req.id, ErrorCode::InvalidParams, reason);
1027 }
1028 }
1029 }
1030 if provider != "codex" && provider != "gemini" {
1040 let _ = ctx.emitter.emit(
1041 "agent_spawn_failed",
1042 &json!({"name": name, "reason": "bad_interactive_provider", "provider": provider}),
1043 );
1044 return Response::err(
1045 req.id,
1046 ErrorCode::InvalidParams,
1047 format!(
1048 "interactive host_mode supports only codex, gemini, or claude (stream-json adopt via `promote --from <uuid> --provider claude`), not '{provider}'"
1049 ),
1050 );
1051 }
1052 }
1053
1054 let argv: Vec<String> = match p.get("argv").and_then(|v| v.as_array()) {
1055 Some(a) => a
1056 .iter()
1057 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1058 .collect(),
1059 None => {
1060 match provider_for_pty(&provider) {
1062 Some(pty_provider) => {
1063 let create_ctx = crate::provider::CreateContext {
1064 name: name.clone(),
1065 message: message.clone(),
1066 cwd: cwd.clone(),
1067 from_name: from_name.clone(),
1068 session_id: None,
1069 yolo,
1070 };
1071 let resolved = if interactive {
1075 let built = match &resume_id {
1076 Some(uuid) => {
1077 pty_provider.resume_interactive_argv(&crate::provider::ResumeContext {
1078 session_id: uuid.clone(),
1079 message: message.clone(),
1080 cwd: cwd.clone(),
1081 from_name: from_name.clone(),
1082 yolo,
1083 })
1084 }
1085 None => pty_provider.create_interactive_argv(&create_ctx),
1086 };
1087 match built {
1088 Some(a) => a,
1089 None => {
1090 return Response::err(
1091 req.id,
1092 ErrorCode::InvalidParams,
1093 format!(
1094 "provider '{provider}' has no interactive host_mode; only codex/gemini support `fno agents host`/`promote`"
1095 ),
1096 )
1097 }
1098 }
1099 } else {
1100 pty_provider.create_argv(&create_ctx)
1101 };
1102 if resolved.is_empty() {
1103 return Response::err(
1104 req.id,
1105 ErrorCode::InvalidParams,
1106 format!("provider '{provider}' returned empty argv"),
1107 );
1108 }
1109 resolved
1110 }
1111 None => {
1112 return Response::err(
1113 req.id,
1114 ErrorCode::InvalidParams,
1115 format!(
1116 "provider '{provider}' has no PTY implementation; pass --argv to spawn it manually"
1117 ),
1118 )
1119 }
1120 }
1121 }
1122 };
1123 if argv.is_empty() {
1124 return Response::err(req.id, ErrorCode::InvalidParams, "empty argv");
1125 }
1126
1127 let registry = load_registry_offloaded(ctx.home.registry_json()).await;
1129 if let Some(existing) = registry.find(&name) {
1130 return Response::err(
1131 req.id,
1132 ErrorCode::AgentExists,
1133 format!(
1134 "agent {name} already exists (short_id={}); use `fno agents rm` first",
1135 existing.short_id
1136 ),
1137 );
1138 }
1139 let short_id = derive_short_id(&name, ®istry);
1140
1141 let mut cmd = std::process::Command::new(&ctx.opts.worker_bin);
1148 cmd.arg("--short-id")
1149 .arg(&short_id)
1150 .arg("--home")
1151 .arg(ctx.home.root())
1152 .arg("--cwd")
1153 .arg(&cwd)
1154 .arg("--")
1155 .args(&argv);
1156 cmd.process_group(0);
1157 let child = match cmd.spawn() {
1158 Ok(c) => c,
1159 Err(e) => {
1160 let _ = ctx.emitter.emit(
1161 "agent_spawn_failed",
1162 &json!({"name": name, "reason": "binary_not_found", "detail": e.to_string()}),
1163 );
1164 return Response::err(
1165 req.id,
1166 ErrorCode::SpawnFailed,
1167 format!("could not launch worker: {e}"),
1168 );
1169 }
1170 };
1171 let worker_pid = child.id();
1172 let worker_pid_start_time = process_start_time(worker_pid);
1176 drop(child);
1180
1181 let sock = ctx.home.worker_sock(&short_id);
1183 let start = Instant::now();
1184 while !sock.exists() && start.elapsed() < Duration::from_secs(10) {
1185 tokio::time::sleep(Duration::from_millis(25)).await;
1186 }
1187 if !sock.exists() {
1188 let _ = ctx.emitter.emit(
1189 "agent_create_no_session",
1190 &json!({"name": name, "short_id": short_id}),
1191 );
1192 return Response::err(
1193 req.id,
1194 ErrorCode::SpawnFailed,
1195 "worker did not come up within 10s",
1196 );
1197 }
1198
1199 if !worker_reports_child_alive(&sock).await {
1204 let _ = ctx.emitter.emit(
1205 "agent_create_no_session",
1206 &json!({"name": name, "short_id": short_id, "reason": "child_not_alive"}),
1207 );
1208 if let Ok(mut conn) = UnixStream::connect(&sock).await {
1210 let _ = write_request(&mut conn, &Request::new(1, "worker.shutdown", json!({}))).await;
1211 let _ = crate::protocol::read_response(&mut conn).await;
1212 }
1213 return Response::err(
1214 req.id,
1215 ErrorCode::SpawnFailed,
1216 "worker came up but its PTY child is not alive",
1217 );
1218 }
1219
1220 if interactive {
1227 if let Err(last_bytes) = await_interactive_readiness(&sock).await {
1228 let tail: String = last_bytes
1229 .chars()
1230 .rev()
1231 .take(400)
1232 .collect::<Vec<_>>()
1233 .into_iter()
1234 .rev()
1235 .collect();
1236 let _ = ctx.emitter.emit(
1237 "agent_spawn_failed",
1238 &json!({
1239 "name": name,
1240 "short_id": short_id,
1241 "reason": "interactive_child_exited_before_ready",
1242 "last_bytes": tail,
1243 }),
1244 );
1245 best_effort_worker_shutdown(&sock).await;
1246 return Response::err(
1247 req.id,
1248 ErrorCode::SpawnFailed,
1249 format!(
1250 "interactive {provider} worker exited before readiness (resume/launch failed); last output: {tail}"
1251 ),
1252 );
1253 }
1254 }
1255
1256 let entry = RegistryEntry {
1258 name: name.clone(),
1259 short_id: short_id.clone(),
1260 provider: provider.clone(),
1261 cwd: cwd.to_string_lossy().into_owned(),
1262 project_root: cwd.to_string_lossy().into_owned(),
1263 session_id: p
1264 .get("session_id")
1265 .and_then(|v| v.as_str())
1266 .map(String::from),
1267 claude_short_id: None,
1268 claude_session_uuid: None,
1269 messaging_socket_path: None,
1270 codex_session_id: if interactive && provider == "codex" {
1275 resume_id.clone()
1276 } else {
1277 None
1278 },
1279 gemini_session_id: if interactive && provider == "gemini" {
1280 resume_id.clone()
1281 } else {
1282 None
1283 },
1284 mcp_channel_id: None,
1285 cc_session_id: None,
1286 host_mode: if interactive {
1289 Some(host_mode.clone())
1290 } else {
1291 None
1292 },
1293 status: AgentStatus::Live,
1294 last_message_at: Some(now_rfc3339_like()),
1295 created_at: now_rfc3339_like(),
1296 pid: Some(worker_pid),
1297 pid_start_time: worker_pid_start_time,
1298 log_path: Some(
1299 ctx.home
1300 .timeline_jsonl(&short_id)
1301 .to_string_lossy()
1302 .into_owned(),
1303 ),
1304 last_reconciled_at: None,
1305 };
1306 let resume_for_lock = resume_id.clone();
1314 let interactive_for_lock = interactive;
1315 let insert = update_registry_offloaded(ctx.home.registry_json(), move |r| {
1316 if r.entries.iter().any(|e| e.name == entry.name) {
1317 return false;
1318 }
1319 if interactive_for_lock {
1325 if let Some(uuid) = resume_for_lock.as_deref() {
1326 if r.entries.iter().any(|e| {
1327 e.is_interactive() && entry_holds_session(e, uuid) && is_non_terminal(e.status)
1328 }) {
1329 return false;
1330 }
1331 }
1332 }
1333 r.entries.push(entry);
1334 true
1335 })
1336 .await;
1337 match insert {
1338 Ok(true) => {}
1339 Ok(false) => {
1340 best_effort_worker_shutdown(&sock).await;
1344 let _ = ctx.emitter.emit(
1345 "agent_spawn_failed",
1346 &json!({"name": name, "short_id": short_id, "reason": "name_taken_concurrent"}),
1347 );
1348 return Response::err(
1349 req.id,
1350 ErrorCode::AgentExists,
1351 format!("agent {name} already exists (won by a concurrent spawn); use `fno agents rm` first"),
1352 );
1353 }
1354 Err(e) => {
1355 best_effort_worker_shutdown(&sock).await;
1359 let _ = ctx.emitter.emit(
1360 "agent_spawn_failed",
1361 &json!({"name": name, "short_id": short_id, "reason": "registry_write_failed"}),
1362 );
1363 return Response::err(req.id, ErrorCode::Internal, format!("registry write: {e}"));
1364 }
1365 }
1366 let _ = ctx.emitter.emit(
1367 "agent_spawned",
1368 &json!({"name": name, "provider": provider, "short_id": short_id}),
1369 );
1370
1371 Response::ok(
1372 req.id,
1373 json!({"short_id": short_id, "provider": provider, "status": "live"}),
1374 )
1375}
1376
1377fn stream_claim_holder(short_id: &str) -> String {
1386 format!("stream:{short_id}")
1387}
1388
1389fn is_live_writer(status: AgentStatus) -> bool {
1398 matches!(
1399 status,
1400 AgentStatus::Live
1401 | AgentStatus::Ready
1402 | AgentStatus::Idle
1403 | AgentStatus::Busy
1404 | AgentStatus::Spawning
1405 | AgentStatus::Restarting
1406 )
1407}
1408
1409fn claim_acquire_argv(uuid: &str, holder: &str) -> Vec<String> {
1413 vec![
1414 "claim".into(),
1415 "acquire".into(),
1416 format!("session:{uuid}"),
1417 "--holder".into(),
1418 holder.into(),
1419 "-J".into(),
1420 ]
1421}
1422
1423fn claude_stream_worker_args(
1429 short_id: &str,
1430 home: &std::path::Path,
1431 cwd: &std::path::Path,
1432 uuid: &str,
1433 holder: &str,
1434 child_argv: &[String],
1435) -> Vec<String> {
1436 let mut args = vec![
1437 "--stream".into(),
1438 "--short-id".into(),
1439 short_id.into(),
1440 "--home".into(),
1441 home.to_string_lossy().into_owned(),
1442 "--cwd".into(),
1443 cwd.to_string_lossy().into_owned(),
1444 "--session-uuid".into(),
1445 uuid.into(),
1446 "--holder".into(),
1447 holder.into(),
1448 "--".into(),
1449 ];
1450 args.extend(child_argv.iter().cloned());
1451 args
1452}
1453
1454fn build_claude_stream_entry(
1460 name: &str,
1461 short_id: &str,
1462 cwd: &std::path::Path,
1463 uuid: &str,
1464 pid: u32,
1465 pid_start_time: Option<u64>,
1466 log_path: PathBuf,
1467) -> RegistryEntry {
1468 let cwd_s = cwd.to_string_lossy().into_owned();
1469 RegistryEntry {
1470 name: name.into(),
1471 short_id: short_id.into(),
1472 provider: "claude".into(),
1473 cwd: cwd_s.clone(),
1474 project_root: cwd_s,
1475 session_id: None,
1476 claude_short_id: None,
1477 claude_session_uuid: Some(uuid.into()),
1478 messaging_socket_path: None,
1479 codex_session_id: None,
1480 gemini_session_id: None,
1481 mcp_channel_id: None,
1482 cc_session_id: None,
1483 host_mode: Some(crate::state::HOST_MODE_INTERACTIVE.into()),
1484 status: AgentStatus::Live,
1485 last_message_at: Some(now_rfc3339_like()),
1486 created_at: now_rfc3339_like(),
1487 pid: Some(pid),
1488 pid_start_time,
1489 log_path: Some(log_path.to_string_lossy().into_owned()),
1490 last_reconciled_at: None,
1491 }
1492}
1493
1494enum ClaimOutcome {
1496 Acquired,
1498 HeldByOther(String),
1500 Unavailable(String),
1505}
1506
1507fn acquire_session_claim(uuid: &str, holder: &str) -> ClaimOutcome {
1514 let output = std::process::Command::new("fno")
1515 .args(claim_acquire_argv(uuid, holder))
1516 .stdin(std::process::Stdio::null())
1517 .output();
1518 match output {
1519 Err(e) => ClaimOutcome::Unavailable(format!("fno claim acquire failed to run: {e}")),
1520 Ok(o) if !o.status.success() => {
1521 let detail = String::from_utf8_lossy(&o.stderr).trim().to_string();
1524 ClaimOutcome::HeldByOther(if detail.is_empty() {
1525 "held by another writer".into()
1526 } else {
1527 detail
1528 })
1529 }
1530 Ok(o) => {
1531 match serde_json::from_slice::<Value>(&o.stdout) {
1535 Ok(v) => match v.get("holder").and_then(Value::as_str) {
1536 Some(h) if h == holder => ClaimOutcome::Acquired,
1537 Some(other) => ClaimOutcome::HeldByOther(other.to_string()),
1538 None => ClaimOutcome::Acquired,
1539 },
1540 Err(_) => ClaimOutcome::Acquired,
1543 }
1544 }
1545 }
1546}
1547
1548struct DaemonClaimGuard {
1558 session_uuid: String,
1559 holder: String,
1560 armed: bool,
1561}
1562
1563impl DaemonClaimGuard {
1564 fn disarm(mut self) {
1567 self.armed = false;
1568 }
1569}
1570
1571impl Drop for DaemonClaimGuard {
1572 fn drop(&mut self) {
1573 if !self.armed {
1574 return;
1575 }
1576 let _ = std::process::Command::new("fno")
1580 .args([
1581 "claim",
1582 "release",
1583 &format!("session:{}", self.session_uuid),
1584 "--holder",
1585 &self.holder,
1586 ])
1587 .stdin(std::process::Stdio::null())
1588 .stdout(std::process::Stdio::null())
1589 .stderr(std::process::Stdio::null())
1590 .spawn();
1591 }
1592}
1593
1594async fn stream_worker_reports_child_alive(sock: &std::path::Path) -> bool {
1602 let probe = async {
1603 let mut conn = UnixStream::connect(sock).await.ok()?;
1604 write_request(&mut conn, &Request::new(1, "stream.status", json!({})))
1605 .await
1606 .ok()?;
1607 let resp = crate::protocol::read_response(&mut conn).await.ok()?;
1608 Some(
1609 resp.result()
1610 .and_then(|r| r.get("child_alive"))
1611 .and_then(Value::as_bool)
1612 .unwrap_or(false),
1613 )
1614 };
1615 matches!(
1616 tokio::time::timeout(Duration::from_secs(STREAM_PROBE_TIMEOUT_S), probe).await,
1617 Ok(Some(true))
1618 )
1619}
1620
1621async fn spawn_claude_stream_lane(
1630 ctx: &Ctx,
1631 req: &Request,
1632 name: &str,
1633 cwd: &std::path::Path,
1634 resume_id: Option<&str>,
1635 explicit_argv: Option<Vec<String>>,
1636) -> Response {
1637 let uuid = match resume_id {
1640 Some(u) if !u.trim().is_empty() => u,
1641 _ => {
1642 let _ = ctx.emitter.emit(
1643 "agent_spawn_failed",
1644 &json!({"name": name, "reason": "claude_host_needs_from"}),
1645 );
1646 return Response::err(
1647 req.id,
1648 ErrorCode::InvalidParams,
1649 "claude has no fresh interactive host; adopt an idle session: `fno agents promote <name> --from <session-uuid> --provider claude`",
1650 );
1651 }
1652 };
1653
1654 let registry = load_registry_offloaded(ctx.home.registry_json()).await;
1657 if let Some(existing) = registry.find(name) {
1658 return Response::err(
1659 req.id,
1660 ErrorCode::AgentExists,
1661 format!(
1662 "agent {name} already exists (short_id={}); use `fno agents rm` first",
1663 existing.short_id
1664 ),
1665 );
1666 }
1667 if let Some(h) = registry.entries.iter().rev().find(|e| {
1671 e.provider == "claude"
1672 && e.claude_session_uuid.as_deref() == Some(uuid)
1673 && is_live_writer(e.status)
1674 }) {
1675 return Response::err(
1676 req.id,
1677 ErrorCode::InvalidParams,
1678 format!(
1679 "session '{uuid}' is already hosted by live stream thread '{}'; one writer per session",
1680 h.name
1681 ),
1682 );
1683 }
1684 let short_id = derive_short_id(name, ®istry);
1685 let holder = stream_claim_holder(&short_id);
1686
1687 let uuid_owned = uuid.to_string();
1692 let holder_for_acq = holder.clone();
1693 let claim_outcome =
1694 tokio::task::spawn_blocking(move || acquire_session_claim(&uuid_owned, &holder_for_acq))
1695 .await
1696 .unwrap_or_else(|e| ClaimOutcome::Unavailable(format!("claim task panicked: {e}")));
1697 let claim_guard = match claim_outcome {
1700 ClaimOutcome::Acquired => DaemonClaimGuard {
1701 session_uuid: uuid.to_string(),
1702 holder: holder.clone(),
1703 armed: true,
1704 },
1705 ClaimOutcome::HeldByOther(who) => {
1706 let _ = ctx.emitter.emit(
1707 "agent_spawn_failed",
1708 &json!({"name": name, "reason": "session_claimed", "detail": who}),
1709 );
1710 return Response::err(
1711 req.id,
1712 ErrorCode::InvalidParams,
1713 format!(
1714 "session '{uuid}' is held by another writer ({who}); refusing to double-adopt"
1715 ),
1716 );
1717 }
1718 ClaimOutcome::Unavailable(why) => {
1719 let _ = ctx.emitter.emit(
1720 "agent_stream_claim_unavailable",
1721 &json!({"name": name, "session_uuid": uuid, "detail": why}),
1722 );
1723 DaemonClaimGuard {
1726 session_uuid: uuid.to_string(),
1727 holder: holder.clone(),
1728 armed: false,
1729 }
1730 }
1731 };
1732
1733 let child_argv =
1738 explicit_argv.unwrap_or_else(|| crate::provider::claude_stream_json_resume_argv(uuid));
1739 let worker_args =
1740 claude_stream_worker_args(&short_id, ctx.home.root(), cwd, uuid, &holder, &child_argv);
1741 let mut cmd = std::process::Command::new(&ctx.opts.worker_bin);
1742 cmd.args(&worker_args);
1743 cmd.process_group(0);
1744 let child = match cmd.spawn() {
1745 Ok(c) => c,
1746 Err(e) => {
1747 let _ = ctx.emitter.emit(
1749 "agent_spawn_failed",
1750 &json!({"name": name, "reason": "binary_not_found", "detail": e.to_string()}),
1751 );
1752 return Response::err(
1753 req.id,
1754 ErrorCode::SpawnFailed,
1755 format!("could not launch stream worker: {e}"),
1756 );
1757 }
1758 };
1759 let worker_pid = child.id();
1760 let worker_pid_start_time = process_start_time(worker_pid);
1761 drop(child);
1762
1763 let sock = ctx.home.worker_sock(&short_id);
1765 let start = Instant::now();
1766 while !sock.exists() && start.elapsed() < Duration::from_secs(10) {
1767 tokio::time::sleep(Duration::from_millis(25)).await;
1768 }
1769 if !sock.exists() {
1770 let _ = ctx.emitter.emit(
1772 "agent_create_no_session",
1773 &json!({"name": name, "short_id": short_id, "lane": "stream"}),
1774 );
1775 return Response::err(
1776 req.id,
1777 ErrorCode::SpawnFailed,
1778 "stream worker did not come up within 10s",
1779 );
1780 }
1781
1782 if !is_live_stream_thread(&sock).await {
1787 best_effort_worker_shutdown(&sock).await;
1788 let _ = ctx.emitter.emit(
1789 "agent_create_no_session",
1790 &json!({"name": name, "short_id": short_id, "reason": "not_a_stream_thread"}),
1791 );
1792 return Response::err(
1793 req.id,
1794 ErrorCode::SpawnFailed,
1795 "stream worker came up but does not serve the stream protocol",
1796 );
1797 }
1798
1799 if !stream_worker_reports_child_alive(&sock).await {
1806 best_effort_worker_shutdown(&sock).await;
1807 let _ = ctx.emitter.emit(
1808 "agent_create_no_session",
1809 &json!({"name": name, "short_id": short_id, "reason": "resume_child_exited"}),
1810 );
1811 return Response::err(
1812 req.id,
1813 ErrorCode::SpawnFailed,
1814 "claude --resume child exited before adoption (bad/expired session id, auth failure, or dead cwd)",
1815 );
1816 }
1817
1818 let entry = build_claude_stream_entry(
1824 name,
1825 &short_id,
1826 cwd,
1827 uuid,
1828 worker_pid,
1829 worker_pid_start_time,
1830 ctx.home.timeline_jsonl(&short_id),
1831 );
1832 let uuid_for_lock = uuid.to_string();
1833 let insert = update_registry_offloaded(ctx.home.registry_json(), move |r| {
1834 if r.entries.iter().any(|e| e.name == entry.name) {
1835 return false;
1836 }
1837 if r.entries.iter().any(|e| {
1838 e.provider == "claude"
1839 && e.claude_session_uuid.as_deref() == Some(&uuid_for_lock)
1840 && is_live_writer(e.status)
1841 }) {
1842 return false;
1843 }
1844 r.entries.push(entry);
1845 true
1846 })
1847 .await;
1848 match insert {
1849 Ok(true) => {}
1850 Ok(false) => {
1851 best_effort_worker_shutdown(&sock).await;
1852 let _ = ctx.emitter.emit(
1853 "agent_spawn_failed",
1854 &json!({"name": name, "short_id": short_id, "reason": "session_taken_concurrent"}),
1855 );
1856 return Response::err(
1857 req.id,
1858 ErrorCode::AgentExists,
1859 format!("session '{uuid}' was adopted by a concurrent call; this one refused"),
1860 );
1861 }
1862 Err(e) => {
1863 best_effort_worker_shutdown(&sock).await;
1864 let _ = ctx.emitter.emit(
1865 "agent_spawn_failed",
1866 &json!({"name": name, "short_id": short_id, "reason": "registry_write_failed"}),
1867 );
1868 return Response::err(req.id, ErrorCode::Internal, format!("registry write: {e}"));
1869 }
1870 }
1871 claim_guard.disarm();
1874 let _ = ctx.emitter.emit(
1875 "agent_spawned",
1876 &json!({"name": name, "provider": "claude", "short_id": short_id, "lane": "stream", "session_uuid": uuid}),
1877 );
1878
1879 Response::ok(
1880 req.id,
1881 json!({"short_id": short_id, "provider": "claude", "status": "live", "lane": "stream"}),
1882 )
1883}
1884
1885fn provider_readiness_detector(provider: &str) -> Box<dyn crate::readiness::ReadinessDetector> {
1893 use crate::provider::ProviderWithPty as _;
1894 match provider {
1895 "codex" => crate::provider::CodexProvider.readiness_detector(),
1896 "gemini" => crate::provider::GeminiProvider.readiness_detector(),
1897 other => Box::new(crate::readiness::NoSignalDetector {
1901 provider: other.to_string(),
1902 }),
1903 }
1904}
1905
1906fn provider_for_pty(provider: &str) -> Option<Box<dyn crate::provider::ProviderWithPty>> {
1913 match provider {
1914 "codex" => Some(Box::new(crate::provider::CodexProvider)),
1915 "gemini" => Some(Box::new(crate::provider::GeminiProvider)),
1916 _ => None,
1918 }
1919}
1920
1921#[derive(Debug, PartialEq, Eq)]
1944enum PollError {
1945 Timeout { secs: u64 },
1947 WorkerUnresponsive { secs: u64 },
1949}
1950
1951impl std::fmt::Display for PollError {
1952 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1953 match self {
1954 PollError::Timeout { secs } => {
1955 write!(f, "ask timed out after {secs}s before reply settled")
1956 }
1957 PollError::WorkerUnresponsive { secs } => write!(
1958 f,
1959 "ask timed out after {secs}s before reply settled (worker snapshot read did not return)"
1960 ),
1961 }
1962 }
1963}
1964
1965async fn poll_until_ready<F, Fut>(
1966 fetcher: F,
1967 detector: Box<dyn crate::readiness::ReadinessDetector>,
1968 poll_interval: Duration,
1969 timeout: Duration,
1970) -> Result<String, PollError>
1971where
1972 F: Fn() -> Fut,
1973 Fut: std::future::Future<Output = Option<String>>,
1974{
1975 let deadline = tokio::time::Instant::now() + timeout;
1976 loop {
1977 let now = tokio::time::Instant::now();
1978 if now >= deadline {
1979 return Err(PollError::Timeout {
1980 secs: timeout.as_secs(),
1981 });
1982 }
1983 let remaining = deadline.saturating_duration_since(now);
1990 let fetched = match tokio::time::timeout(remaining, fetcher()).await {
1991 Ok(opt) => opt,
1992 Err(_) => {
1993 return Err(PollError::WorkerUnresponsive {
1994 secs: timeout.as_secs(),
1995 })
1996 }
1997 };
1998 if let Some(text) = fetched {
1999 let mut grid = crate::screen::TerminalGrid::with_default_size();
2001 grid.feed(text.as_bytes());
2002 let owned = grid.snapshot();
2003 let view = owned.view();
2004 match detector.is_ready(&view) {
2005 Ok(true) => return Ok(owned.text),
2006 Ok(false) | Err(_) => {} }
2008 }
2009 tokio::time::sleep(poll_interval).await;
2010 }
2011}
2012
2013async fn handle_ask(ctx: &Ctx, req: &Request) -> Response {
2014 let name = match req.params.get("name").and_then(|v| v.as_str()) {
2015 Some(n) => n.to_string(),
2016 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
2017 };
2018 let message = req
2019 .params
2020 .get("message")
2021 .and_then(|v| v.as_str())
2022 .unwrap_or("")
2023 .to_string();
2024
2025 let provider_param = req
2026 .params
2027 .get("provider")
2028 .and_then(|v| v.as_str())
2029 .map(String::from);
2030 let cwd_param = req
2031 .params
2032 .get("cwd")
2033 .and_then(|v| v.as_str())
2034 .map(PathBuf::from);
2035 let from_name_param = req
2036 .params
2037 .get("from_name")
2038 .and_then(|v| v.as_str())
2039 .map(String::from);
2040 let yolo_param = req
2041 .params
2042 .get("yolo")
2043 .and_then(|v| v.as_bool())
2044 .unwrap_or(false);
2045
2046 let registry = load_registry_offloaded(ctx.home.registry_json()).await;
2047 let entry = match registry.find(&name) {
2048 Some(e) => e.clone(),
2049 None => {
2050 let provider = match provider_param {
2053 Some(p) => p,
2054 None => {
2055 return Response::err(
2056 req.id,
2057 ErrorCode::InvalidParams,
2058 format!(
2059 "agent '{name}' not found; pass --provider to create it on first contact"
2060 ),
2061 )
2062 }
2063 };
2064 let spawn_cwd = match cwd_param {
2069 Some(c) => c,
2070 None => {
2071 let fallback = std::env::temp_dir();
2072 let _ = ctx.emitter.emit(
2073 "agent_spawn_cwd_fallback",
2074 &json!({
2075 "name": name,
2076 "fallback": fallback.to_string_lossy(),
2077 "via": "ask_first_contact",
2078 }),
2079 );
2080 fallback
2081 }
2082 };
2083 let mut spawn_params = serde_json::Map::new();
2085 spawn_params.insert("name".into(), serde_json::Value::String(name.clone()));
2086 spawn_params.insert("provider".into(), serde_json::Value::String(provider));
2087 spawn_params.insert(
2088 "cwd".into(),
2089 serde_json::Value::String(spawn_cwd.to_str().unwrap_or(".").to_string()),
2090 );
2091 spawn_params.insert("message".into(), serde_json::Value::String(message.clone()));
2092 if let Some(ref fn_val) = from_name_param {
2093 spawn_params.insert(
2094 "from_name".into(),
2095 serde_json::Value::String(fn_val.clone()),
2096 );
2097 }
2098 if yolo_param {
2099 spawn_params.insert("yolo".into(), serde_json::Value::Bool(true));
2100 }
2101 let spawn_req = Request::new(
2102 req.id,
2103 "agent.spawn",
2104 serde_json::Value::Object(spawn_params),
2105 );
2106 let spawn_resp = handle_spawn(ctx, &spawn_req).await;
2107 return match spawn_resp.payload {
2108 crate::protocol::ResponsePayload::Ok(ref result) => {
2109 let short_id = result
2110 .get("short_id")
2111 .and_then(|v| v.as_str())
2112 .unwrap_or("")
2113 .to_string();
2114 Response::ok(req.id, json!({"created": true, "short_id": short_id}))
2115 }
2116 crate::protocol::ResponsePayload::Err(_) => spawn_resp,
2117 };
2118 }
2119 };
2120 if entry.status == AgentStatus::Orphaned {
2121 return Response::err(
2122 req.id,
2123 ErrorCode::InvalidStatus,
2124 format!("agent {name} is orphaned; use `fno agents reconcile` or `rm`"),
2125 );
2126 }
2127
2128 let sock = ctx.home.worker_sock(&entry.short_id);
2129 let mut conn = match UnixStream::connect(&sock).await {
2130 Ok(c) => c,
2131 Err(_) => {
2132 return Response::err(
2133 req.id,
2134 ErrorCode::InvalidStatus,
2135 format!("worker for {name} is not reachable"),
2136 )
2137 }
2138 };
2139
2140 let mut payload = message.clone();
2144 if !payload.ends_with('\n') {
2145 payload.push('\n');
2146 }
2147 if write_request(
2148 &mut conn,
2149 &Request::new(1, "worker.write", json!({"data": payload})),
2150 )
2151 .await
2152 .is_err()
2153 {
2154 return Response::err(req.id, ErrorCode::Internal, "worker write failed");
2155 }
2156 match crate::protocol::read_response(&mut conn).await {
2160 Ok(ack) if ack.is_err() => {
2161 let msg = ack
2162 .error()
2163 .map(|e| e.message.clone())
2164 .unwrap_or_else(|| "worker rejected the write".into());
2165 return Response::err(req.id, ErrorCode::Internal, msg);
2166 }
2167 Ok(_) => {}
2168 Err(_) => {
2169 return Response::err(req.id, ErrorCode::Internal, "no write-ack from worker");
2170 }
2171 }
2172
2173 let timeout_secs = req
2177 .params
2178 .get("timeout")
2179 .and_then(|v| v.as_u64())
2180 .unwrap_or(600);
2181 let detector = provider_readiness_detector(&entry.provider);
2182 let sock_path = sock.clone();
2183 let fetcher = move || {
2184 let p = sock_path.clone();
2185 async move { read_worker_snapshot(&p).await }
2186 };
2187 let reply = match poll_until_ready(
2188 fetcher,
2189 detector,
2190 Duration::from_millis(200),
2191 Duration::from_secs(timeout_secs),
2192 )
2193 .await
2194 {
2195 Ok(text) => text,
2196 Err(e) => {
2197 return Response::err(req.id, ErrorCode::Internal, e.to_string());
2198 }
2199 };
2200
2201 let ask_name = name.clone();
2202 let _ = update_registry_offloaded(ctx.home.registry_json(), move |r| {
2203 if let Some(e) = r.find_mut(&ask_name) {
2204 e.last_message_at = Some(now_rfc3339_like());
2205 }
2206 })
2207 .await;
2208 let _ = ctx
2209 .emitter
2210 .emit("agent_ask_done", &json!({"name": name, "backend": "pty"}));
2211
2212 Response::ok(req.id, json!({"reply": reply, "backend": "pty"}))
2213}
2214
2215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2233pub enum InjectionGate {
2234 Passed,
2236 Failed,
2240 Unverified,
2242}
2243
2244std::thread_local! {
2248 static INJECTION_GATE_ALLOW: std::cell::RefCell<Option<String>> =
2249 const { std::cell::RefCell::new(None) };
2250}
2251
2252pub fn injection_gate_with_home(provider: &str, home: &AgentsHome) -> InjectionGate {
2259 let mut thread_allow = false;
2261 INJECTION_GATE_ALLOW.with(|cell| {
2262 if let Some(ref allow) = *cell.borrow() {
2263 for p in allow.split(',') {
2264 if p.trim() == provider {
2265 thread_allow = true;
2266 }
2267 }
2268 }
2269 });
2270 if thread_allow {
2271 return InjectionGate::Passed;
2272 }
2273
2274 let path = home.injection_gate_json();
2276 let text = match std::fs::read_to_string(&path) {
2277 Ok(t) => t,
2278 Err(_) => return InjectionGate::Unverified, };
2280 let doc: serde_json::Value = match serde_json::from_str(&text) {
2281 Ok(v) => v,
2282 Err(_) => return InjectionGate::Unverified, };
2284 if doc.get("v").and_then(|v| v.as_u64()) != Some(1) {
2287 return InjectionGate::Unverified;
2288 }
2289 let status = doc
2290 .get("providers")
2291 .and_then(|p| p.get(provider))
2292 .and_then(|e| e.get("status"))
2293 .and_then(|s| s.as_str());
2294 match status {
2295 Some("passed") => InjectionGate::Passed,
2296 Some("failed") => InjectionGate::Failed,
2297 _ => InjectionGate::Unverified, }
2299}
2300
2301pub fn injection_gate(provider: &str) -> InjectionGate {
2304 injection_gate_with_home(provider, &AgentsHome::from_env())
2305}
2306
2307const MAX_INJECT_BODY_BYTES: usize = 16 * 1024 * 1024;
2311
2312async fn inject_into_pty(
2336 worker_sock: &std::path::Path,
2337 body: &str,
2338 from_name: &str,
2339) -> Result<(), String> {
2340 if body.len() > MAX_INJECT_BODY_BYTES {
2342 return Err(format!(
2343 "body too large: {} bytes > {MAX_INJECT_BODY_BYTES}",
2344 body.len()
2345 ));
2346 }
2347
2348 let safe_from = xml_attr_escape(from_name);
2351 let container = format!(
2352 "<cross-session-message from-name=\"{safe_from}\">\n{body}\n</cross-session-message>"
2353 );
2354
2355 let mut frame: Vec<u8> = Vec::new();
2357 frame.extend_from_slice(b"\x1b[200~");
2358 frame.extend_from_slice(container.as_bytes());
2359 frame.extend_from_slice(b"\x1b[201~");
2360 frame.push(b'\r'); let mut conn = UnixStream::connect(worker_sock)
2365 .await
2366 .map_err(|e| format!("worker unreachable: {e}"))?;
2367
2368 let b64 = base64::engine::general_purpose::STANDARD.encode(&frame);
2369 if write_request(
2370 &mut conn,
2371 &Request::new(1, "worker.write", json!({"bytes_b64": b64})),
2372 )
2373 .await
2374 .is_err()
2375 {
2376 return Err("worker write request failed".into());
2377 }
2378
2379 match crate::protocol::read_response(&mut conn).await {
2380 Ok(ack) if ack.is_err() => Err(format!(
2381 "worker rejected inject: {}",
2382 ack.error().map(|e| e.message.as_str()).unwrap_or("?")
2383 )),
2384 Ok(_) => Ok(()),
2385 Err(e) => Err(format!("no ack from worker: {e}")),
2386 }
2387}
2388
2389fn xml_attr_escape(s: &str) -> String {
2392 let mut out = String::with_capacity(s.len());
2393 for ch in s.chars() {
2394 match ch {
2395 '&' => out.push_str("&"),
2396 '"' => out.push_str("""),
2397 '<' => out.push_str("<"),
2398 '>' => out.push_str(">"),
2399 '\'' => out.push_str("'"),
2400 c => out.push(c),
2401 }
2402 }
2403 out
2404}
2405
2406async fn handle_deliver(ctx: &Ctx, req: &Request) -> Response {
2427 let name = match req.params.get("name").and_then(|v| v.as_str()) {
2428 Some(n) => n.to_string(),
2429 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
2430 };
2431 let body = match req.params.get("body").and_then(|v| v.as_str()) {
2432 Some(b) => b.to_string(),
2433 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `body`"),
2434 };
2435 let from_name = req
2436 .params
2437 .get("from_name")
2438 .and_then(|v| v.as_str())
2439 .unwrap_or("unknown")
2440 .to_string();
2441
2442 if body.len() > MAX_INJECT_BODY_BYTES {
2444 return Response::err(
2445 req.id,
2446 ErrorCode::InvalidParams,
2447 format!(
2448 "body too large: {} bytes > {MAX_INJECT_BODY_BYTES}",
2449 body.len()
2450 ),
2451 );
2452 }
2453
2454 let registry = load_registry_offloaded(ctx.home.registry_json()).await;
2455 let entry = match registry.find(&name) {
2456 Some(e) => e.clone(),
2457 None => {
2458 return Response::err(
2459 req.id,
2460 ErrorCode::AgentNotFound,
2461 format!("agent '{name}' not found"),
2462 )
2463 }
2464 };
2465
2466 if entry.provider == "claude" {
2468 return Response::ok(
2469 req.id,
2470 json!({"delivered": false, "reason": "claude-routes-via-socket"}),
2471 );
2472 }
2473
2474 match injection_gate_with_home(&entry.provider, &ctx.home) {
2476 InjectionGate::Passed => { }
2477 InjectionGate::Failed => {
2478 let _ = ctx.emitter.emit(
2479 "agent_deliver_demoted",
2480 &json!({
2481 "name": name,
2482 "from_name": from_name,
2483 "provider": entry.provider,
2484 "reason": "injection-gate-failed",
2485 }),
2486 );
2487 return Response::ok(
2488 req.id,
2489 json!({"delivered": false, "reason": "injection-gate-failed"}),
2490 );
2491 }
2492 InjectionGate::Unverified => {
2493 let _ = ctx.emitter.emit(
2494 "agent_deliver_demoted",
2495 &json!({
2496 "name": name,
2497 "from_name": from_name,
2498 "provider": entry.provider,
2499 "reason": "injection-gate-unverified",
2500 }),
2501 );
2502 return Response::ok(
2503 req.id,
2504 json!({"delivered": false, "reason": "injection-gate-unverified"}),
2505 );
2506 }
2507 }
2508
2509 let sock = ctx.home.worker_sock(&entry.short_id);
2511 match inject_into_pty(&sock, &body, &from_name).await {
2512 Ok(()) => {
2513 let _ = ctx.emitter.emit(
2514 "agent_deliver_injected",
2515 &json!({"name": name, "from_name": from_name, "provider": entry.provider}),
2516 );
2517 Response::ok(req.id, json!({"delivered": true, "transport": "pty"}))
2518 }
2519 Err(reason) => {
2520 let _ = ctx.emitter.emit(
2521 "agent_deliver_demoted",
2522 &json!({
2523 "name": name,
2524 "from_name": from_name,
2525 "provider": entry.provider,
2526 "reason": reason,
2527 }),
2528 );
2529 Response::ok(req.id, json!({"delivered": false, "reason": reason}))
2533 }
2534 }
2535}
2536
2537const SWITCHBOARD_TURN_TIMEOUT_MS: u64 = 120_000;
2552const SWITCHBOARD_POLL_MS: u64 = 50;
2554const STREAM_PROBE_TIMEOUT_S: u64 = 2;
2557const SWITCHBOARD_MIRROR_TIMEOUT_S: u64 = 5;
2559const SWITCHBOARD_DRIVE_GRACE_S: u64 = 5;
2563
2564struct SwitchboardTurn {
2566 reply: String,
2568 is_error: bool,
2570 saw_receipt: bool,
2573}
2574
2575async fn is_live_stream_thread(sock: &std::path::Path) -> bool {
2582 let probe = async {
2585 let mut conn = UnixStream::connect(sock).await.ok()?;
2586 write_request(&mut conn, &Request::new(1, "stream.ping", json!({})))
2587 .await
2588 .ok()?;
2589 let resp = crate::protocol::read_response(&mut conn).await.ok()?;
2590 Some(!resp.is_err())
2591 };
2592 matches!(
2593 tokio::time::timeout(Duration::from_secs(STREAM_PROBE_TIMEOUT_S), probe).await,
2594 Ok(Some(true))
2595 )
2596}
2597
2598async fn drive_stream_turn(
2604 worker_sock: &std::path::Path,
2605 text: &str,
2606 deadline: Duration,
2607) -> Result<SwitchboardTurn, String> {
2608 let mut conn = UnixStream::connect(worker_sock)
2609 .await
2610 .map_err(|e| format!("target not live (worker unreachable): {e}"))?;
2611
2612 write_request(
2619 &mut conn,
2620 &Request::new(0, "stream.read_frames", json!({ "cursor": u64::MAX })),
2621 )
2622 .await
2623 .map_err(|e| format!("cursor probe send failed: {e}"))?;
2624 let probe = crate::protocol::read_response(&mut conn)
2625 .await
2626 .map_err(|e| format!("cursor probe recv failed: {e}"))?;
2627 let mut cursor = probe
2628 .result()
2629 .and_then(|r| r.get("next"))
2630 .and_then(|v| v.as_u64())
2631 .ok_or_else(|| "cursor probe returned no result".to_string())?;
2632
2633 write_request(
2635 &mut conn,
2636 &Request::new(1, "stream.write_turn", json!({ "text": text })),
2637 )
2638 .await
2639 .map_err(|e| format!("write_turn send failed: {e}"))?;
2640 match crate::protocol::read_response(&mut conn).await {
2641 Ok(ack) if ack.is_err() => {
2642 return Err(format!(
2643 "write_turn rejected: {}",
2644 ack.error().map(|e| e.message.as_str()).unwrap_or("?")
2645 ))
2646 }
2647 Ok(_) => {}
2648 Err(e) => return Err(format!("no write_turn ack: {e}")),
2649 }
2650
2651 let start = Instant::now();
2653 let mut reply = String::new();
2654 let mut saw_receipt = false;
2655 let mut req_id = 100u64;
2656 loop {
2657 if start.elapsed() > deadline {
2658 return Err("turn timed out before result".into());
2659 }
2660 write_request(
2661 &mut conn,
2662 &Request::new(req_id, "stream.read_frames", json!({ "cursor": cursor })),
2663 )
2664 .await
2665 .map_err(|e| format!("read_frames send failed: {e}"))?;
2666 req_id += 1;
2667 let resp = crate::protocol::read_response(&mut conn)
2668 .await
2669 .map_err(|e| format!("read_frames recv failed: {e}"))?;
2670 let res = resp
2671 .result()
2672 .ok_or_else(|| "read_frames returned no result".to_string())?;
2673 if let Some(next) = res.get("next").and_then(|v| v.as_u64()) {
2674 cursor = next;
2675 }
2676 let child_alive = res
2677 .get("child_alive")
2678 .and_then(|v| v.as_bool())
2679 .unwrap_or(true);
2680 if let Some(frames) = res.get("frames").and_then(|v| v.as_array()) {
2681 for fr in frames {
2682 match fr.get("kind").and_then(|k| k.as_str()) {
2683 Some("user_echo") => saw_receipt = true,
2684 Some("assistant") => {
2685 if let Some(t) = fr.get("text").and_then(|t| t.as_str()) {
2686 reply.push_str(t);
2687 }
2688 }
2689 Some("result") => {
2690 let is_error = fr
2691 .get("is_error")
2692 .and_then(|e| e.as_bool())
2693 .unwrap_or(false);
2694 if reply.is_empty() {
2697 if let Some(r) = fr.get("result").and_then(|r| r.as_str()) {
2698 reply.push_str(r);
2699 }
2700 }
2701 return Ok(SwitchboardTurn {
2702 reply,
2703 is_error,
2704 saw_receipt,
2705 });
2706 }
2707 _ => {}
2709 }
2710 }
2711 }
2712 if !child_alive {
2713 return Err("target child exited before result (orphaned)".into());
2714 }
2715 tokio::time::sleep(Duration::from_millis(SWITCHBOARD_POLL_MS)).await;
2716 }
2717}
2718
2719async fn mirror_into(worker_sock: &std::path::Path, text: &str) -> Result<(), String> {
2724 let inner = async {
2725 let mut conn = UnixStream::connect(worker_sock)
2726 .await
2727 .map_err(|e| format!("mirror target unreachable: {e}"))?;
2728 write_request(
2729 &mut conn,
2730 &Request::new(1, "stream.write_turn", json!({ "text": text })),
2731 )
2732 .await
2733 .map_err(|e| format!("mirror write failed: {e}"))?;
2734 match crate::protocol::read_response(&mut conn).await {
2735 Ok(ack) if ack.is_err() => Err(format!(
2736 "mirror rejected: {}",
2737 ack.error().map(|e| e.message.as_str()).unwrap_or("?")
2738 )),
2739 Ok(_) => Ok(()),
2740 Err(e) => Err(format!("no mirror ack: {e}")),
2741 }
2742 };
2743 match tokio::time::timeout(Duration::from_secs(SWITCHBOARD_MIRROR_TIMEOUT_S), inner).await {
2745 Ok(r) => r,
2746 Err(_) => Err("mirror timed out".into()),
2747 }
2748}
2749
2750async fn stamp_orphaned(home: &AgentsHome, short_id: &str) {
2755 let reg = home.registry_json();
2756 let sid = short_id.to_string();
2757 let _ = tokio::task::spawn_blocking(move || {
2758 let _ = state::update_registry(®, |r| {
2759 if let Some(e) = r.entries.iter_mut().find(|e| e.short_id == sid) {
2760 if e.status == AgentStatus::Live {
2766 e.status = AgentStatus::Orphaned;
2767 }
2768 }
2769 });
2770 })
2771 .await;
2772}
2773
2774async fn handle_switchboard(ctx: &Ctx, req: &Request) -> Response {
2791 let to = match req.params.get("to").and_then(|v| v.as_str()) {
2792 Some(s) => s.to_string(),
2793 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `to`"),
2794 };
2795 let from = req
2796 .params
2797 .get("from")
2798 .and_then(|v| v.as_str())
2799 .unwrap_or("unknown")
2800 .to_string();
2801 let body = match req.params.get("body").and_then(|v| v.as_str()) {
2802 Some(b) => b.to_string(),
2803 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `body`"),
2804 };
2805 let mirror = req
2806 .params
2807 .get("mirror")
2808 .and_then(|v| v.as_bool())
2809 .unwrap_or(true);
2810 let timeout_ms = req
2811 .params
2812 .get("timeout_ms")
2813 .and_then(|v| v.as_u64())
2814 .unwrap_or(SWITCHBOARD_TURN_TIMEOUT_MS);
2815
2816 if body.len() > MAX_INJECT_BODY_BYTES {
2817 return Response::err(
2818 req.id,
2819 ErrorCode::InvalidParams,
2820 format!(
2821 "body too large: {} bytes > {MAX_INJECT_BODY_BYTES}",
2822 body.len()
2823 ),
2824 );
2825 }
2826
2827 let registry = load_registry_offloaded(ctx.home.registry_json()).await;
2828 let to_entry = match registry.find(&to) {
2829 Some(e) => e.clone(),
2830 None => {
2831 return Response::err(
2832 req.id,
2833 ErrorCode::AgentNotFound,
2834 format!("agent '{to}' not found"),
2835 )
2836 }
2837 };
2838
2839 let to_sock = ctx.home.worker_sock(&to_entry.short_id);
2842 if to_entry.provider != "claude" || !is_live_stream_thread(&to_sock).await {
2843 return Response::ok(
2844 req.id,
2845 json!({"delivered": false, "reason": "not-a-live-stream-thread"}),
2846 );
2847 }
2848
2849 let drive_deadline = Duration::from_millis(timeout_ms);
2854 let outer = drive_deadline + Duration::from_secs(SWITCHBOARD_DRIVE_GRACE_S);
2855 let drive_result =
2856 match tokio::time::timeout(outer, drive_stream_turn(&to_sock, &body, drive_deadline)).await
2857 {
2858 Ok(inner) => inner,
2859 Err(_) => Err("drive hung past the turn budget (timed out)".to_string()),
2860 };
2861 let outcome = match drive_result {
2862 Ok(o) => o,
2863 Err(reason) => {
2864 stamp_orphaned(&ctx.home, &to_entry.short_id).await;
2868 let _ = ctx.emitter.emit(
2869 "agent_deliver_demoted",
2870 &json!({
2871 "name": to,
2872 "from_name": from,
2873 "provider": "claude",
2874 "transport": "switchboard",
2875 "reason": reason,
2876 }),
2877 );
2878 return Response::ok(req.id, json!({"delivered": false, "reason": reason}));
2879 }
2880 };
2881
2882 let mut mirrored = false;
2887 if mirror && from != to {
2888 let fresh = load_registry_offloaded(ctx.home.registry_json()).await;
2892 if let Some(from_entry) = fresh.find(&from) {
2893 let from_sock = ctx.home.worker_sock(&from_entry.short_id);
2894 if from_entry.provider == "claude" && is_live_stream_thread(&from_sock).await {
2895 match mirror_into(&from_sock, &outcome.reply).await {
2896 Ok(()) => mirrored = true,
2897 Err(e) => {
2898 let _ = ctx.emitter.emit(
2902 "agent_deliver_demoted",
2903 &json!({
2904 "name": from,
2905 "from_name": to,
2906 "provider": "claude",
2907 "transport": "switchboard-mirror",
2908 "reason": e,
2909 }),
2910 );
2911 }
2912 }
2913 }
2914 }
2915 }
2916
2917 let _ = ctx.emitter.emit(
2918 "agent_deliver_injected",
2919 &json!({
2920 "name": to,
2921 "from_name": from,
2922 "provider": "claude",
2923 "transport": "switchboard",
2924 "mirrored": mirrored,
2925 "is_error": outcome.is_error,
2926 }),
2927 );
2928
2929 Response::ok(
2930 req.id,
2931 json!({
2932 "delivered": true,
2933 "transport": "switchboard",
2934 "reply": outcome.reply,
2935 "is_error": outcome.is_error,
2936 "mirrored": mirrored,
2937 "receipt": outcome.saw_receipt,
2938 }),
2939 )
2940}
2941
2942async fn handle_gate_check(ctx: &Ctx, req: &Request) -> Response {
2963 let provider = match req.params.get("provider").and_then(|v| v.as_str()) {
2964 Some(p) => p.to_string(),
2965 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `provider`"),
2966 };
2967 let record_mode = match req.params.get("record").and_then(|v| v.as_str()) {
2968 Some(m) => m.to_string(),
2969 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `record`"),
2970 };
2971
2972 match record_mode.as_str() {
2973 "manual" => {
2974 let status = match req.params.get("status").and_then(|v| v.as_str()) {
2975 Some(s) if s == "passed" || s == "failed" => s.to_string(),
2976 Some(other) => {
2977 return Response::err(
2978 req.id,
2979 ErrorCode::InvalidParams,
2980 format!("`status` must be 'passed' or 'failed', got '{other}'"),
2981 )
2982 }
2983 None => {
2984 return Response::err(
2985 req.id,
2986 ErrorCode::InvalidParams,
2987 "missing `status` for manual mode",
2988 )
2989 }
2990 };
2991 let notes = req
2992 .params
2993 .get("notes")
2994 .and_then(|v| v.as_str())
2995 .unwrap_or("")
2996 .to_string();
2997 let checked_at = {
2998 use std::time::{SystemTime, UNIX_EPOCH};
2999 let secs = SystemTime::now()
3001 .duration_since(UNIX_EPOCH)
3002 .unwrap_or_default()
3003 .as_secs();
3004 let s = secs;
3006 let sec = s % 60;
3007 let min = (s / 60) % 60;
3008 let hour = (s / 3600) % 24;
3009 let days = s / 86400;
3010 let (y, mo, d) = days_to_ymd(days);
3012 format!("{y:04}-{mo:02}-{d:02}T{hour:02}:{min:02}:{sec:02}Z")
3013 };
3014
3015 let gate_path = ctx.home.injection_gate_json();
3017 let mut existing: serde_json::Value = if gate_path.exists() {
3018 match std::fs::read_to_string(&gate_path) {
3022 Ok(t) => match serde_json::from_str(&t) {
3023 Ok(v) => v,
3024 Err(e) => {
3025 return Response::err(
3026 req.id,
3027 ErrorCode::InvalidParams,
3028 format!("failed to parse existing gate record: {e}"),
3029 );
3030 }
3031 },
3032 Err(e) => {
3033 return Response::err(
3034 req.id,
3035 ErrorCode::InvalidParams,
3036 format!("failed to read existing gate record: {e}"),
3037 );
3038 }
3039 }
3040 } else {
3041 json!({"v": 1, "providers": {}})
3042 };
3043 existing["providers"][&provider] = json!({
3044 "status": status,
3045 "checked_at": checked_at,
3046 "method": "manual",
3047 "notes": notes,
3048 });
3049
3050 let tmp_path = gate_path.with_extension("json.tmp");
3052 let serialized = serde_json::to_string(&existing).unwrap_or_default();
3053 if let Err(e) = std::fs::write(&tmp_path, &serialized) {
3054 return Response::err(
3055 req.id,
3056 ErrorCode::InvalidParams,
3057 format!("failed to write gate record: {e}"),
3058 );
3059 }
3060 if let Err(e) = std::fs::rename(&tmp_path, &gate_path) {
3061 let _ = std::fs::remove_file(&tmp_path);
3062 return Response::err(
3063 req.id,
3064 ErrorCode::InvalidParams,
3065 format!("failed to rename gate record: {e}"),
3066 );
3067 }
3068 Response::ok(req.id, json!({"status": status, "method": "manual"}))
3069 }
3070 "probe" => {
3071 Response::ok(req.id, json!({"status": "inconclusive"}))
3079 }
3080 other => Response::err(
3081 req.id,
3082 ErrorCode::InvalidParams,
3083 format!("`record` must be 'manual' or 'probe', got '{other}'"),
3084 ),
3085 }
3086}
3087
3088fn days_to_ymd(d: u64) -> (u64, u64, u64) {
3091 let z = d + 719468;
3094 let era = z / 146097;
3095 let doe = z % 146097;
3096 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
3097 let y = yoe + era * 400;
3098 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3099 let mp = (5 * doy + 2) / 153;
3100 let day = doy - (153 * mp + 2) / 5 + 1;
3101 let month = if mp < 10 { mp + 3 } else { mp - 9 };
3102 let year = if month <= 2 { y + 1 } else { y };
3103 (year, month, day)
3104}
3105
3106async fn read_worker_snapshot(sock: &std::path::Path) -> Option<String> {
3107 let mut conn = UnixStream::connect(sock).await.ok()?;
3108 write_request(&mut conn, &Request::new(2, "worker.snapshot", json!({})))
3109 .await
3110 .ok()?;
3111 let resp = crate::protocol::read_response(&mut conn).await.ok()?;
3112 resp.result()
3113 .and_then(|r| r.get("text").and_then(|t| t.as_str()).map(String::from))
3114}
3115
3116async fn worker_reports_child_alive(sock: &std::path::Path) -> bool {
3128 const ATTEMPTS: u32 = 10;
3129 const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(300);
3130 for attempt in 0..ATTEMPTS {
3131 if attempt > 0 {
3132 tokio::time::sleep(Duration::from_millis(50)).await;
3133 }
3134 let Ok(mut conn) = UnixStream::connect(sock).await else {
3135 continue; };
3137 if write_request(&mut conn, &Request::new(3, "worker.status", json!({})))
3138 .await
3139 .is_err()
3140 {
3141 continue;
3142 }
3143 match tokio::time::timeout(
3144 PROBE_READ_TIMEOUT,
3145 crate::protocol::read_response(&mut conn),
3146 )
3147 .await
3148 {
3149 Ok(Ok(resp)) => {
3150 if resp
3151 .result()
3152 .and_then(|r| r.get("child_alive").and_then(|v| v.as_bool()))
3153 .unwrap_or(false)
3154 {
3155 return true;
3156 }
3157 }
3160 Ok(Err(_)) | Err(_) => continue,
3162 }
3163 }
3164 false
3165}
3166
3167#[derive(Debug, PartialEq, Eq)]
3171enum ReadinessStep {
3172 Ready,
3174 Failed,
3176 Poll,
3178}
3179
3180fn interactive_readiness_step(
3181 alive: bool,
3182 painted: bool,
3183 dwell_satisfied: bool,
3184 deadline_reached: bool,
3185) -> ReadinessStep {
3186 if !alive {
3187 ReadinessStep::Failed
3193 } else if painted && dwell_satisfied {
3194 ReadinessStep::Ready
3197 } else if deadline_reached {
3198 ReadinessStep::Ready
3202 } else {
3203 ReadinessStep::Poll
3204 }
3205}
3206
3207async fn probe_worker_snapshot(sock: &std::path::Path) -> Option<(String, bool)> {
3210 let mut conn = UnixStream::connect(sock).await.ok()?;
3211 write_request(&mut conn, &Request::new(4, "worker.snapshot", json!({})))
3212 .await
3213 .ok()?;
3214 let resp = tokio::time::timeout(
3215 Duration::from_millis(300),
3216 crate::protocol::read_response(&mut conn),
3217 )
3218 .await
3219 .ok()?
3220 .ok()?;
3221 let r = resp.result()?;
3222 let text = r
3223 .get("text")
3224 .and_then(|v| v.as_str())
3225 .unwrap_or("")
3226 .to_string();
3227 let alive = r
3228 .get("child_alive")
3229 .and_then(|v| v.as_bool())
3230 .unwrap_or(false);
3231 Some((text, alive))
3232}
3233
3234async fn await_interactive_readiness(sock: &std::path::Path) -> Result<(), String> {
3241 const SETTLE: Duration = Duration::from_millis(4000);
3242 const MIN_DWELL: Duration = Duration::from_millis(1000);
3246 const POLL: Duration = Duration::from_millis(150);
3247 let start = Instant::now();
3248 let deadline = start + SETTLE;
3249 let mut last_text = String::new();
3250 let mut first_paint_at: Option<Instant> = None;
3255 loop {
3256 let now = Instant::now();
3257 let deadline_reached = now >= deadline;
3258 if let Some((text, alive)) = probe_worker_snapshot(sock).await {
3259 if !text.is_empty() {
3260 last_text = text;
3261 if first_paint_at.is_none() {
3262 first_paint_at = Some(now);
3263 }
3264 }
3265 let dwell_satisfied =
3266 first_paint_at.is_some_and(|t| now.duration_since(t) >= MIN_DWELL);
3267 match interactive_readiness_step(
3268 alive,
3269 first_paint_at.is_some(),
3270 dwell_satisfied,
3271 deadline_reached,
3272 ) {
3273 ReadinessStep::Ready => return Ok(()),
3274 ReadinessStep::Failed => return Err(last_text),
3275 ReadinessStep::Poll => {}
3276 }
3277 } else if deadline_reached {
3278 return Ok(());
3281 }
3282 tokio::time::sleep(POLL).await;
3283 }
3284}
3285
3286fn reap_zombies() {
3290 loop {
3291 let mut status: libc::c_int = 0;
3292 let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
3295 if pid <= 0 {
3296 break;
3297 }
3298 }
3299}
3300
3301fn handle_list(ctx: &Ctx, req: &Request) -> Response {
3302 let all = req
3303 .params
3304 .get("all")
3305 .and_then(|v| v.as_bool())
3306 .unwrap_or(false);
3307
3308 let filter_cwd = req
3311 .params
3312 .get("cwd")
3313 .and_then(|v| v.as_str())
3314 .map(String::from);
3315 let filter_provider = req
3316 .params
3317 .get("provider")
3318 .and_then(|v| v.as_str())
3319 .map(String::from);
3320 let filter_status = req
3321 .params
3322 .get("status")
3323 .and_then(|v| v.as_str())
3324 .map(String::from);
3325 let cwd_project = req
3326 .params
3327 .get("project_root")
3328 .and_then(|v| v.as_str())
3329 .map(String::from);
3330
3331 if let Some(ref st) = filter_status {
3336 if st != "live" && st != "orphaned" {
3337 return Response::err(
3338 req.id,
3339 ErrorCode::InvalidStatus,
3340 format!("invalid --status '{st}' (expected: live | orphaned)"),
3341 );
3342 }
3343 }
3344 let norm_path = |p: &str| -> String {
3350 std::fs::canonicalize(p)
3351 .ok()
3352 .and_then(|pb| pb.to_str().map(String::from))
3353 .unwrap_or_else(|| p.to_string())
3354 };
3355 let filter_cwd_norm = filter_cwd.as_deref().map(&norm_path);
3356
3357 let registry = state::load_registry(&ctx.home.registry_json()).unwrap_or_default();
3358 let entries: Vec<Value> = registry
3359 .entries
3360 .iter()
3361 .filter(|e| {
3362 if !all {
3364 if let Some(ref p) = cwd_project {
3365 if &e.project_root != p {
3366 return false;
3367 }
3368 }
3369 }
3370 if let Some(ref cwd) = filter_cwd_norm {
3372 if &norm_path(&e.cwd) != cwd {
3373 return false;
3374 }
3375 }
3376 if let Some(ref prov) = filter_provider {
3377 if &e.provider != prov {
3378 return false;
3379 }
3380 }
3381 if let Some(ref st) = filter_status {
3382 if format!("{:?}", e.status).to_lowercase() != st.to_lowercase() {
3383 return false;
3384 }
3385 }
3386 true
3387 })
3388 .map(|e| {
3389 let resume_id: Option<String> = match e.provider.as_str() {
3413 "claude" => e.claude_short_id.clone().or_else(|| e.session_id.clone()),
3414 "codex" => e.codex_session_id.clone().or_else(|| e.session_id.clone()),
3415 "gemini" => e.gemini_session_id.clone().or_else(|| e.session_id.clone()),
3416 _ => e.session_id.clone(),
3417 };
3418 let session_id: Value = resume_id.map(Value::String).unwrap_or(Value::Null);
3419 let short_id: Value = e
3420 .claude_short_id
3421 .as_deref()
3422 .map(|s| Value::String(s.to_string()))
3423 .unwrap_or(Value::Null);
3424 let log_path: Value = e
3425 .log_path
3426 .as_deref()
3427 .map(|s| Value::String(s.to_string()))
3428 .unwrap_or(Value::Null);
3429 json!({
3430 "name": e.name,
3431 "provider": e.provider,
3432 "short_id": short_id,
3433 "session_id": session_id,
3434 "cwd": e.cwd,
3435 "created_at": e.created_at,
3436 "last_message_at": e.last_message_at,
3437 "status": format!("{:?}", e.status).to_lowercase(),
3438 "live_status": null, "pid": e.pid,
3447 "last_reconciled_at": e.last_reconciled_at,
3448 "log_path": log_path,
3449 "project_root": e.project_root,
3454 })
3455 })
3456 .collect();
3457 let filters_applied = json!({
3463 "cwd": filter_cwd_norm,
3464 "provider": filter_provider,
3465 "status": filter_status,
3466 });
3467 Response::ok(
3468 req.id,
3469 json!({"agents": entries, "filters_applied": filters_applied}),
3470 )
3471}
3472
3473async fn handle_status(ctx: &Ctx, req: &Request) -> Response {
3491 let reg_path = ctx.home.registry_json();
3494 let registry = tokio::task::spawn_blocking(move || state::load_registry(®_path))
3495 .await
3496 .ok()
3497 .and_then(|r| r.ok())
3498 .unwrap_or_default();
3499 let mut by_status: Map<String, Value> = Map::new();
3500 let mut restarting: u64 = 0;
3501 let mut channels_registered: u64 = 0;
3502 for e in ®istry.entries {
3503 let key = format!("{:?}", e.status).to_lowercase();
3504 let n = by_status.get(&key).and_then(|v| v.as_u64()).unwrap_or(0) + 1;
3505 by_status.insert(key, Value::Number(n.into()));
3506 if e.status == AgentStatus::Restarting {
3507 restarting += 1;
3508 }
3509 if e.mcp_channel_id.is_some() {
3510 channels_registered += 1;
3511 }
3512 }
3513 let active_drives = crate::drive::active_drive_count(&ctx.drives).await;
3514 Response::ok(
3515 req.id,
3516 json!({
3517 "schema_version": 1,
3518 "daemon": {
3519 "state": DaemonState::Serving.as_str(),
3520 "pid": std::process::id(),
3521 "uptime_secs": ctx.started_at.elapsed().as_secs(),
3522 "version": env!("CARGO_PKG_VERSION"),
3523 "exe_path": ctx
3526 .exe_fingerprint
3527 .as_ref()
3528 .map(|f| f.path.to_string_lossy().into_owned()),
3529 "exe_mtime": ctx.exe_fingerprint.as_ref().map(|f| f.mtime_nanos),
3530 "exe_size": ctx.exe_fingerprint.as_ref().map(|f| f.size),
3531 "pid_start_time": ctx.pid_start_time,
3534 },
3535 "agents": {
3536 "total": registry.entries.len(),
3537 "by_status": by_status,
3538 },
3539 "drives": { "active": active_drives },
3540 "restarts": {
3541 "queue_depth": restarting,
3546 "consecutive_failures_max_seen": 0,
3547 },
3548 "channels": { "registered": channels_registered },
3549 }),
3550 )
3551}
3552
3553async fn handle_stop(ctx: &Ctx, req: &Request) -> Response {
3554 let name = match req.params.get("name").and_then(|v| v.as_str()) {
3555 Some(n) => n.to_string(),
3556 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
3557 };
3558 let registry = load_registry_offloaded(ctx.home.registry_json()).await;
3559 let entry = match registry.find(&name) {
3560 Some(e) => e.clone(),
3561 None => {
3562 return Response::err(
3563 req.id,
3564 ErrorCode::AgentNotFound,
3565 format!("agent {name} not found"),
3566 )
3567 }
3568 };
3569 let force = req
3570 .params
3571 .get("force")
3572 .and_then(|v| v.as_bool())
3573 .unwrap_or(false);
3574 if entry.status == AgentStatus::Exited {
3575 if force
3581 && crate::drive::force_close_controlling(&ctx.drives, &entry.short_id, "stop_force")
3582 .await
3583 {
3584 crate::drive::await_driver_cleared(
3585 &ctx.drives,
3586 &entry.short_id,
3587 Duration::from_secs(2),
3588 )
3589 .await;
3590 }
3591 return Response::ok(
3592 req.id,
3593 json!({"already_exited": true, "short_id": entry.short_id}),
3594 );
3595 }
3596 if let Some((sid, _mode)) = crate::drive::controlling_driver(&ctx.drives, &entry.short_id).await
3600 {
3601 if !force {
3602 return Response::err(
3603 req.id,
3604 ErrorCode::Busy,
3605 format!(
3606 "agent {name} has an active driver (session {sid}); detach first or pass --force"
3607 ),
3608 );
3609 }
3610 crate::drive::force_close_controlling(&ctx.drives, &entry.short_id, "stop_force").await;
3615 if !crate::drive::await_driver_cleared(&ctx.drives, &entry.short_id, Duration::from_secs(2))
3616 .await
3617 {
3618 let _ = ctx.emitter.emit(
3623 "drive_force_close_timeout",
3624 &json!({"name": name, "short_id": entry.short_id}),
3625 );
3626 }
3627 }
3628 if entry.provider == "claude" {
3631 return stop_claude(ctx, req, &name, &entry).await;
3632 }
3633 if entry.short_id.is_empty() {
3642 let _ = ctx.emitter.emit(
3643 "agent_stopped",
3644 &json!({"name": name, "provider": entry.provider, "claude_exit": Value::Null}),
3645 );
3646 return Response::ok(
3647 req.id,
3648 json!({"stopped": true, "provider": entry.provider, "no_op": true}),
3649 );
3650 }
3651 if !stop_worker_confirmed(ctx, &entry).await {
3656 return Response::err(
3657 req.id,
3658 ErrorCode::Internal,
3659 format!("agent {name}: worker did not confirm shutdown; it may still be running"),
3660 );
3661 }
3662 let stop_name = name.clone();
3667 if let Err(e) = update_registry_offloaded(ctx.home.registry_json(), move |r| {
3668 if let Some(e) = r.find_mut(&stop_name) {
3669 e.status = AgentStatus::Exited;
3670 }
3671 })
3672 .await
3673 {
3674 let _ = ctx.emitter.emit(
3675 "agent_stop_error",
3676 &json!({"name": name, "error": e.to_string()}),
3677 );
3678 return Response::err(
3679 req.id,
3680 ErrorCode::Internal,
3681 format!("agent {name}: worker stopped but registry write failed: {e}"),
3682 );
3683 }
3684 let _ = ctx.emitter.emit("agent_stopped", &json!({"name": name}));
3685 Response::ok(req.id, json!({"stopped": true, "short_id": entry.short_id}))
3686}
3687
3688async fn best_effort_worker_shutdown(sock: &std::path::Path) {
3692 if let Ok(mut conn) = UnixStream::connect(sock).await {
3693 let _ = write_request(&mut conn, &Request::new(1, "worker.shutdown", json!({}))).await;
3694 let _ = crate::protocol::read_response(&mut conn).await;
3695 }
3696}
3697
3698async fn stop_worker_confirmed(ctx: &Ctx, entry: &RegistryEntry) -> bool {
3704 let sock = ctx.home.worker_sock(&entry.short_id);
3705 if let Ok(mut conn) = UnixStream::connect(&sock).await {
3707 let _ = write_request(&mut conn, &Request::new(1, "worker.shutdown", json!({}))).await;
3708 let _ = crate::protocol::read_response(&mut conn).await;
3709 }
3710 let mut down = worker_down_within(&sock, Duration::from_secs(5)).await;
3715 if !down {
3725 if let Some(pid) = entry.pid {
3726 if pid_is_ours(pid, entry.pid_start_time) {
3727 unsafe {
3728 libc::kill(pid as libc::pid_t, libc::SIGTERM);
3729 }
3730 down = worker_down_within(&sock, Duration::from_secs(5)).await;
3731 if !down && pid_is_ours(pid, entry.pid_start_time) {
3732 unsafe {
3733 libc::kill(pid as libc::pid_t, libc::SIGKILL);
3734 }
3735 down = worker_down_within(&sock, Duration::from_secs(2)).await;
3736 }
3737 }
3738 }
3739 }
3740 if down {
3745 let _ = std::fs::remove_file(&sock);
3746 }
3747 down
3748}
3749
3750async fn worker_socket_reachable(sock: &std::path::Path) -> bool {
3754 UnixStream::connect(sock).await.is_ok()
3755}
3756
3757async fn worker_down_within(sock: &std::path::Path, budget: Duration) -> bool {
3762 let start = Instant::now();
3763 loop {
3764 if !worker_socket_reachable(sock).await {
3765 return true;
3766 }
3767 if start.elapsed() >= budget {
3768 return false;
3769 }
3770 tokio::time::sleep(Duration::from_millis(50)).await;
3771 }
3772}
3773
3774async fn stop_claude(ctx: &Ctx, req: &Request, name: &str, entry: &RegistryEntry) -> Response {
3778 let short = match entry
3779 .claude_short_id
3780 .as_deref()
3781 .or(entry.session_id.as_deref())
3782 .filter(|s| !s.is_empty())
3783 {
3784 Some(s) => s.to_string(),
3785 None => {
3786 return Response::err(
3787 req.id,
3788 ErrorCode::InvalidStatus,
3789 format!("agent {name} is claude but has no short id to stop"),
3790 )
3791 }
3792 };
3793 match tokio::process::Command::new("claude")
3794 .arg("stop")
3795 .arg(&short)
3796 .output()
3797 .await
3798 {
3799 Ok(o) if o.status.success() => {
3800 let claude_name = name.to_string();
3803 if let Err(e) = update_registry_offloaded(ctx.home.registry_json(), move |r| {
3804 if let Some(e) = r.find_mut(&claude_name) {
3805 e.status = AgentStatus::Exited;
3806 }
3807 })
3808 .await
3809 {
3810 return Response::err(
3811 req.id,
3812 ErrorCode::Internal,
3813 format!("claude {name} stopped but registry write failed: {e}"),
3814 );
3815 }
3816 let _ = ctx
3817 .emitter
3818 .emit("agent_stopped", &json!({"name": name, "backend": "claude"}));
3819 Response::ok(
3825 req.id,
3826 json!({"stopped": true, "backend": "claude", "short_id": short}),
3827 )
3828 }
3829 Ok(o) => Response::err(
3830 req.id,
3831 ErrorCode::Internal,
3832 format!(
3833 "claude stop {short} failed: {}",
3834 String::from_utf8_lossy(&o.stderr).trim()
3835 ),
3836 ),
3837 Err(e) => Response::err(
3838 req.id,
3839 ErrorCode::Internal,
3840 format!("could not exec `claude stop`: {e}"),
3841 ),
3842 }
3843}
3844
3845async fn handle_rm(ctx: &Ctx, req: &Request) -> Response {
3846 let name = match req.params.get("name").and_then(|v| v.as_str()) {
3847 Some(n) => n.to_string(),
3848 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
3849 };
3850 let force = req
3851 .params
3852 .get("force")
3853 .and_then(|v| v.as_bool())
3854 .unwrap_or(false);
3855 let registry = load_registry_offloaded(ctx.home.registry_json()).await;
3856 let entry = match registry.find(&name) {
3857 Some(e) => e.clone(),
3858 None => {
3859 return Response::err(
3860 req.id,
3861 ErrorCode::AgentNotFound,
3862 format!("agent {name} not found"),
3863 )
3864 }
3865 };
3866 if let Some((sid, _mode)) = crate::drive::controlling_driver(&ctx.drives, &entry.short_id).await
3870 {
3871 return Response::err(
3872 req.id,
3873 ErrorCode::Busy,
3874 format!(
3875 "agent {name} has an active driver (session {sid}); detach, stop, and wait for exited before rm (--force does NOT override this)"
3876 ),
3877 );
3878 }
3879 if entry.status == AgentStatus::Live && !force {
3880 return Response::err(
3881 req.id,
3882 ErrorCode::Busy,
3883 format!("agent {name} is still live; use `stop` first or pass --force"),
3884 );
3885 }
3886 if entry.status == AgentStatus::Live && force && !stop_worker_confirmed(ctx, &entry).await {
3889 return Response::err(
3890 req.id,
3891 ErrorCode::Internal,
3892 format!("agent {name}: could not stop the worker before force-remove; refusing to orphan a live PTY"),
3893 );
3894 }
3895 let was_orphaned = entry.status == AgentStatus::Orphaned;
3898 let rm_name = name.clone();
3903 if let Err(e) = update_registry_offloaded(ctx.home.registry_json(), move |r| {
3904 r.entries.retain(|e| e.name != rm_name);
3905 })
3906 .await
3907 {
3908 return Response::err(
3909 req.id,
3910 ErrorCode::Internal,
3911 format!("agent {name}: removal did not persist: {e}"),
3912 );
3913 }
3914 let _ = ctx.emitter.emit(
3915 "agent_removed",
3916 &json!({"name": name, "was_orphaned": was_orphaned}),
3917 );
3918 Response::ok(
3919 req.id,
3920 json!({"removed": true, "was_orphaned": was_orphaned}),
3921 )
3922}
3923
3924const RECONCILE_PROBE_TIMEOUT: Duration = Duration::from_millis(250);
3926const RECONCILE_SWEEP_BUDGET: Duration = Duration::from_secs(5);
3929
3930struct ReconcileChange {
3934 name: String,
3935 new_status: Option<AgentStatus>,
3936}
3937
3938#[derive(Default, PartialEq, Debug)]
3940struct ReconcileOutcome {
3941 updated: Vec<String>,
3942 orphans: Vec<String>,
3943 recovered: Vec<String>,
3944 inconsistent: Vec<(String, String)>,
3947 deferred: usize,
3949}
3950
3951fn plan_reconcile<P, D, L>(
3966 entries: &[RegistryEntry],
3967 mut probe: P,
3968 mut budget_exhausted: D,
3969 mut pid_live: L,
3970) -> (Vec<ReconcileChange>, ReconcileOutcome)
3971where
3972 P: FnMut(&RegistryEntry) -> Result<bool, crate::provider::ReachabilityProbeError>,
3973 D: FnMut() -> bool,
3974 L: FnMut(&RegistryEntry) -> bool,
3975{
3976 let mut changes = Vec::new();
3977 let mut out = ReconcileOutcome::default();
3978 for (i, entry) in entries.iter().enumerate() {
3979 if budget_exhausted() {
3980 out.deferred = entries.len() - i;
3981 break;
3982 }
3983 if entry.is_one_shot_ask() {
3992 let new_status = if is_non_terminal(entry.status) {
3993 out.updated.push(entry.name.clone());
3994 Some(AgentStatus::Exited)
3995 } else {
3996 None
3997 };
3998 changes.push(ReconcileChange {
3999 name: entry.name.clone(),
4000 new_status,
4001 });
4002 continue;
4003 }
4004 let new_status = match probe(entry) {
4005 Ok(true) => {
4006 if entry.status == AgentStatus::Orphaned {
4007 out.recovered.push(entry.name.clone());
4008 out.updated.push(entry.name.clone());
4009 Some(AgentStatus::Live)
4010 } else {
4011 None
4012 }
4013 }
4014 Ok(false) if entry.is_interactive() => {
4015 if pid_live(entry) {
4025 None
4026 } else {
4027 out.updated.push(entry.name.clone());
4028 Some(AgentStatus::Exited)
4029 }
4030 }
4031 Ok(false) => {
4032 let live_ish = matches!(
4039 entry.status,
4040 AgentStatus::Live
4041 | AgentStatus::Ready
4042 | AgentStatus::Idle
4043 | AgentStatus::Busy
4044 | AgentStatus::Spawning
4045 );
4046 if live_ish {
4047 out.orphans.push(entry.name.clone());
4048 out.updated.push(entry.name.clone());
4049 Some(AgentStatus::Orphaned)
4050 } else {
4051 None
4052 }
4053 }
4054 Err(e) => {
4055 out.inconsistent
4056 .push((entry.name.clone(), e.reason.clone()));
4057 None
4058 }
4059 };
4060 changes.push(ReconcileChange {
4061 name: entry.name.clone(),
4062 new_status,
4063 });
4064 }
4065 (changes, out)
4066}
4067
4068fn apply_reconcile_change(e: &mut RegistryEntry, new_status: Option<AgentStatus>, now: &str) {
4079 e.last_reconciled_at = Some(now.to_string());
4080 if let Some(s) = new_status {
4081 e.status = s;
4082 if matches!(s, AgentStatus::Exited) {
4083 e.pid = None;
4084 e.pid_start_time = None;
4085 }
4086 }
4087}
4088
4089fn to_agent_entry(e: &RegistryEntry) -> crate::provider::AgentEntry {
4092 let session_id = match e.provider.as_str() {
4093 "codex" => e.codex_session_id.clone().or_else(|| e.session_id.clone()),
4094 "gemini" => e.gemini_session_id.clone().or_else(|| e.session_id.clone()),
4095 "claude" => e.claude_short_id.clone().or_else(|| e.session_id.clone()),
4096 _ => e.session_id.clone(),
4097 };
4098 crate::provider::AgentEntry {
4099 name: e.name.clone(),
4100 provider: e.provider.clone(),
4101 session_id,
4102 cwd: PathBuf::from(&e.cwd),
4103 }
4104}
4105
4106struct ReconcileSweepResult {
4110 registry: crate::state::Registry,
4112 entries: Vec<RegistryEntry>,
4114 outcome: ReconcileOutcome,
4115}
4116
4117fn run_reconcile_sweep(
4127 home: &AgentsHome,
4128 emitter: &EventEmitter,
4129) -> Result<ReconcileSweepResult, String> {
4130 use crate::provider::ReachabilityProbeError;
4131 let registry = state::load_registry(&home.registry_json()).unwrap_or_default();
4132
4133 let mut entries = registry.entries.clone();
4136 entries.sort_by(|a, b| a.last_reconciled_at.cmp(&b.last_reconciled_at));
4137
4138 let start = Instant::now();
4139 let probe = |e: &RegistryEntry| -> Result<bool, ReachabilityProbeError> {
4140 if std::os::unix::net::UnixStream::connect(home.worker_sock(&e.short_id)).is_ok() {
4145 return Ok(true);
4146 }
4147 match crate::provider::for_name(&e.provider) {
4149 Some(p) => p.reachability(&to_agent_entry(e), RECONCILE_PROBE_TIMEOUT),
4150 None => Err(ReachabilityProbeError::new(
4151 &e.provider,
4152 "unknown provider; cannot probe reachability",
4153 )),
4154 }
4155 };
4156 let pid_live = |e: &RegistryEntry| -> bool {
4161 e.pid.map_or(true, |pid| pid_is_ours(pid, e.pid_start_time))
4162 };
4163 let (changes, outcome) = plan_reconcile(
4164 &entries,
4165 probe,
4166 || start.elapsed() >= RECONCILE_SWEEP_BUDGET,
4167 pid_live,
4168 );
4169
4170 let now = now_rfc3339_like();
4173 if let Err(err) = state::update_registry(&home.registry_json(), |r| {
4178 for ch in &changes {
4179 if let Some(e) = r.find_mut(&ch.name) {
4180 apply_reconcile_change(e, ch.new_status, &now);
4181 }
4182 }
4183 }) {
4184 let _ = emitter.emit("reconcile_error", &json!({"error": err.to_string()}));
4185 return Err(format!(
4186 "reconcile computed {} change(s) but the registry write failed: {err}",
4187 changes.len()
4188 ));
4189 }
4190
4191 for (name, reason) in &outcome.inconsistent {
4192 let _ = emitter.emit(
4193 "agent_inconsistent",
4194 &json!({"name": name, "reason": reason}),
4195 );
4196 }
4197 if outcome.deferred > 0 {
4198 let _ = emitter.emit(
4199 "reconcile_deferred",
4200 &json!({"remaining_count": outcome.deferred}),
4201 );
4202 }
4203 let _ = emitter.emit(
4204 "reconcile_done",
4205 &json!({
4206 "updated": outcome.updated.len(),
4207 "orphans": outcome.orphans.len(),
4208 "recovered": outcome.recovered.len(),
4209 }),
4210 );
4211 Ok(ReconcileSweepResult {
4212 registry,
4213 entries,
4214 outcome,
4215 })
4216}
4217
4218fn handle_reconcile(ctx: &Ctx, req: &Request) -> Response {
4219 let ReconcileSweepResult {
4220 registry,
4221 entries,
4222 outcome,
4223 } = match run_reconcile_sweep(&ctx.home, &ctx.emitter) {
4224 Ok(r) => r,
4225 Err(msg) => return Response::err(req.id, ErrorCode::Internal, msg),
4226 };
4227 let scanned = entries.len();
4246 let probed = entries.len() - outcome.deferred;
4252 let skipped_py: Vec<Value> = entries
4253 .iter()
4254 .skip(probed)
4255 .map(|e| json!({"name": e.name, "provider": e.provider}))
4256 .collect();
4257 let orphaned_py: Vec<Value> = outcome
4258 .orphans
4259 .iter()
4260 .map(|n| {
4261 let prov = registry
4262 .entries
4263 .iter()
4264 .find(|e| &e.name == n)
4265 .map(|e| e.provider.as_str())
4266 .unwrap_or("unknown");
4267 json!({"name": n, "provider": prov})
4268 })
4269 .collect();
4270 let recovered_py: Vec<Value> = outcome
4271 .recovered
4272 .iter()
4273 .map(|n| {
4274 let prov = registry
4275 .entries
4276 .iter()
4277 .find(|e| &e.name == n)
4278 .map(|e| e.provider.as_str())
4279 .unwrap_or("unknown");
4280 json!({"name": n, "provider": prov})
4281 })
4282 .collect();
4283 let errors_py: Vec<Value> = outcome
4284 .inconsistent
4285 .iter()
4286 .map(|(n, reason)| json!({"name": n, "reason": reason}))
4287 .collect();
4288 Response::ok(
4289 req.id,
4290 json!({
4291 "scanned": scanned,
4293 "orphaned": orphaned_py,
4294 "recovered": recovered_py,
4295 "skipped": skipped_py,
4296 "errors": errors_py,
4297 "updated": outcome.updated,
4299 "orphans": outcome.orphans,
4300 "inconsistent": outcome.inconsistent.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
4301 "deferred": outcome.deferred,
4302 }),
4303 )
4304}
4305
4306async fn dispatch_channel(ctx: &Arc<Ctx>, req: &Request) -> Response {
4311 match Namespace::verb(&req.method) {
4313 Some("register_channel") => run_blocking(ctx, req, handle_register_channel).await,
4314 Some("unregister_channel") => run_blocking(ctx, req, handle_unregister_channel).await,
4315 Some("push_to_channel") => run_blocking(ctx, req, handle_push_to_channel).await,
4316 _ => Response::err(
4317 req.id,
4318 ErrorCode::UnknownMethod,
4319 format!("unknown channel verb in `{}`", req.method),
4320 ),
4321 }
4322}
4323
4324fn handle_register_channel(ctx: &Ctx, req: &Request) -> Response {
4325 let cc_session_id = match req.params.get("cc_session_id").and_then(|v| v.as_str()) {
4326 Some(s) => s.to_string(),
4327 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `cc_session_id`"),
4328 };
4329 let name = req
4331 .params
4332 .get("name")
4333 .and_then(|v| v.as_str())
4334 .map(String::from);
4335 let channel_id = uuid_v4();
4336 let mut matched = false;
4337 if let Err(e) = state::update_registry(&ctx.home.registry_json(), |r| {
4342 let target = match &name {
4343 Some(n) => r.find_mut(n),
4344 None => r
4345 .entries
4346 .iter_mut()
4347 .find(|e| e.cc_session_id.as_deref() == Some(&cc_session_id)),
4348 };
4349 if let Some(e) = target {
4350 e.cc_session_id = Some(cc_session_id.clone());
4351 e.mcp_channel_id = Some(channel_id.clone());
4352 matched = true;
4353 }
4354 }) {
4355 return Response::err(
4356 req.id,
4357 ErrorCode::Internal,
4358 format!("registry write failed during channel registration: {e}"),
4359 );
4360 }
4361 if !matched {
4362 return Response::err(
4363 req.id,
4364 ErrorCode::ChannelUnknown,
4365 "no agent matched cc_session_id/name for registration",
4366 );
4367 }
4368 let _ = ctx
4369 .emitter
4370 .emit("channel_registered", &json!({"mcp_channel_id": channel_id}));
4371 Response::ok(req.id, json!({"mcp_channel_id": channel_id}))
4372}
4373
4374fn handle_unregister_channel(ctx: &Ctx, req: &Request) -> Response {
4375 let channel_id = match req.params.get("mcp_channel_id").and_then(|v| v.as_str()) {
4376 Some(s) => s.to_string(),
4377 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `mcp_channel_id`"),
4378 };
4379 let mut cleared = false;
4380 let _ = state::update_registry(&ctx.home.registry_json(), |r| {
4381 for e in r.entries.iter_mut() {
4382 if e.mcp_channel_id.as_deref() == Some(&channel_id) {
4383 e.mcp_channel_id = None;
4384 cleared = true;
4385 }
4386 }
4387 });
4388 if !cleared {
4389 return Response::err(req.id, ErrorCode::ChannelUnknown, "unknown channel id");
4390 }
4391 Response::ok(req.id, json!({"unregistered": true}))
4392}
4393
4394fn handle_push_to_channel(ctx: &Ctx, req: &Request) -> Response {
4395 let channel_id = match req.params.get("mcp_channel_id").and_then(|v| v.as_str()) {
4396 Some(s) => s.to_string(),
4397 None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `mcp_channel_id`"),
4398 };
4399 let registry = state::load_registry(&ctx.home.registry_json()).unwrap_or_default();
4400 let found = registry
4401 .entries
4402 .iter()
4403 .any(|e| e.mcp_channel_id.as_deref() == Some(&channel_id));
4404 if !found {
4405 return Response::err(
4406 req.id,
4407 ErrorCode::ChannelUnknown,
4408 "channel id not registered (channel server should re-register)",
4409 );
4410 }
4411 Response::ok(req.id, json!({"routed": true}))
4414}
4415
4416fn json_obj(pairs: &[(&str, Value)]) -> Map<String, Value> {
4421 let mut m = Map::new();
4422 for (k, v) in pairs {
4423 m.insert((*k).to_string(), v.clone());
4424 }
4425 m
4426}
4427
4428fn now_compact() -> String {
4430 let secs = std::time::SystemTime::now()
4431 .duration_since(std::time::UNIX_EPOCH)
4432 .unwrap_or_default()
4433 .as_secs();
4434 let (y, mo, d, h, mi, s) = civil(secs);
4435 format!("{y:04}{mo:02}{d:02}T{h:02}{mi:02}{s:02}Z")
4436}
4437
4438fn now_rfc3339_like() -> String {
4440 let secs = std::time::SystemTime::now()
4441 .duration_since(std::time::UNIX_EPOCH)
4442 .unwrap_or_default()
4443 .as_secs();
4444 let (y, mo, d, h, mi, s) = civil(secs);
4445 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
4446}
4447
4448fn civil(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
4449 let days = (secs / 86_400) as i64;
4450 let rem = secs % 86_400;
4451 let (hh, mm, ss) = (
4452 (rem / 3600) as u32,
4453 ((rem % 3600) / 60) as u32,
4454 (rem % 60) as u32,
4455 );
4456 let z = days + 719_468;
4457 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
4458 let doe = z - era * 146_097;
4459 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
4460 let y = yoe + era * 400;
4461 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
4462 let mp = (5 * doy + 2) / 153;
4463 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
4464 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
4465 (if m <= 2 { y + 1 } else { y }, m, d, hh, mm, ss)
4466}
4467
4468fn uuid_v4() -> String {
4471 let mut b = [0u8; 16];
4472 fill_random(&mut b);
4473 b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; format!(
4476 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
4477 b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13],
4478 b[14], b[15]
4479 )
4480}
4481
4482fn fill_random(buf: &mut [u8]) {
4483 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
4487 use std::io::Read;
4488 if f.read_exact(buf).is_ok() {
4489 return;
4490 }
4491 }
4492 let seed = std::time::SystemTime::now()
4493 .duration_since(std::time::UNIX_EPOCH)
4494 .unwrap_or_default()
4495 .as_nanos() as u64
4496 ^ (std::process::id() as u64).rotate_left(17);
4497 let mut x = seed | 1;
4498 for byte in buf.iter_mut() {
4499 x ^= x << 13;
4501 x ^= x >> 7;
4502 x ^= x << 17;
4503 *byte = (x & 0xff) as u8;
4504 }
4505}
4506
4507#[cfg(test)]
4508mod tests {
4509 use super::*;
4510 use crate::state::{AgentState, DriveWindow, PtyState};
4511 use crate::worker::{run as run_worker, WorkerConfig};
4512
4513 fn with_injection_gate_allow(providers: &str) -> impl Drop {
4516 INJECTION_GATE_ALLOW.with(|cell| {
4517 *cell.borrow_mut() = Some(providers.to_string());
4518 });
4519 struct ClearGuard;
4520 impl Drop for ClearGuard {
4521 fn drop(&mut self) {
4522 INJECTION_GATE_ALLOW.with(|cell| {
4523 *cell.borrow_mut() = None;
4524 });
4525 }
4526 }
4527 ClearGuard
4528 }
4529
4530 fn tmp_home(tag: &str) -> AgentsHome {
4531 let mut p = std::env::temp_dir();
4532 p.push(format!(
4533 "fno-agents-daemon-{}-{}-{}",
4534 tag,
4535 std::process::id(),
4536 std::time::SystemTime::now()
4537 .duration_since(std::time::UNIX_EPOCH)
4538 .unwrap()
4539 .as_nanos()
4540 ));
4541 let home = AgentsHome::at(&p);
4542 home.ensure_root().unwrap();
4543 home
4544 }
4545
4546 fn read_events(home: &AgentsHome) -> Vec<Value> {
4547 std::fs::read_to_string(home.events_jsonl())
4548 .unwrap_or_default()
4549 .lines()
4550 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
4551 .collect()
4552 }
4553
4554 #[test]
4555 fn recovery_emits_drive_crashed_before_clearing_window() {
4556 let home = tmp_home("recover-drive");
4557 let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4558
4559 state::update_registry(&home.registry_json(), |r| {
4561 r.entries.push(RegistryEntry {
4562 name: "worker-A".into(),
4563 short_id: "wkA".into(),
4564 provider: "codex".into(),
4565 cwd: "/tmp".into(),
4566 project_root: "/tmp".into(),
4567 session_id: None,
4568 claude_short_id: None,
4569 claude_session_uuid: None,
4570 messaging_socket_path: None,
4571 codex_session_id: None,
4572 gemini_session_id: None,
4573 mcp_channel_id: None,
4574 cc_session_id: None,
4575 host_mode: None,
4576 status: AgentStatus::Live,
4577 last_message_at: None,
4578 created_at: "2026-05-24T00:00:00Z".into(),
4579 pid: Some(std::process::id()), pid_start_time: None,
4581 log_path: None,
4582 last_reconciled_at: None,
4583 });
4584 })
4585 .unwrap();
4586 let mut st = AgentState::new_pty("wkA");
4587 st.status = AgentStatus::Live;
4588 st.pty = Some(PtyState {
4589 active: true,
4590 drive: Some(DriveWindow {
4591 session_id: Some("drive-xyz".into()),
4592 mode: Some("interactive".into()),
4593 last_heartbeat_at_monotonic_ns: Some(123),
4594 }),
4595 });
4596 state::write_state_atomic(&home.state_json("wkA"), &st).unwrap();
4597
4598 let report = recover(&home, &emitter);
4599 assert_eq!(report.recovered_drives, vec!["wkA".to_string()]);
4600
4601 let events = read_events(&home);
4603 let crashed = events
4604 .iter()
4605 .find(|e| e["kind"] == "drive_crashed")
4606 .expect("drive_crashed emitted");
4607 assert_eq!(crashed["session_id"], "drive-xyz");
4608 assert_eq!(crashed["reason"], "daemon_restart");
4609
4610 let after = state::load_state(&home.state_json("wkA")).unwrap().unwrap();
4612 let pty = after.pty.unwrap();
4613 assert!(pty.drive.is_none());
4614 std::fs::remove_dir_all(home.root()).ok();
4615 }
4616
4617 #[test]
4618 fn recovery_marks_missing_state_inconsistent() {
4619 let home = tmp_home("recover-missing");
4620 let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4621 state::update_registry(&home.registry_json(), |r| {
4622 r.entries.push(RegistryEntry {
4623 name: "ghost".into(),
4624 short_id: "ghost".into(),
4625 provider: "codex".into(),
4626 cwd: "/tmp".into(),
4627 project_root: "/tmp".into(),
4628 session_id: None,
4629 claude_short_id: None,
4630 claude_session_uuid: None,
4631 messaging_socket_path: None,
4632 codex_session_id: None,
4633 gemini_session_id: None,
4634 mcp_channel_id: None,
4635 cc_session_id: None,
4636 host_mode: None,
4637 status: AgentStatus::Live,
4638 last_message_at: None,
4639 created_at: "2026-05-24T00:00:00Z".into(),
4640 pid: None,
4641 pid_start_time: None,
4642 log_path: None,
4643 last_reconciled_at: None,
4644 });
4645 })
4646 .unwrap();
4647 let report = recover(&home, &emitter);
4649 assert_eq!(
4650 report.inconsistent,
4651 vec![("ghost".to_string(), InconsistencyReason::MissingStateJson)]
4652 );
4653 let events = read_events(&home);
4654 assert!(events
4655 .iter()
4656 .any(|e| e["kind"] == "agent_inconsistent" && e["reason"] == "missing_state_json"));
4657 std::fs::remove_dir_all(home.root()).ok();
4658 }
4659
4660 #[test]
4661 fn recovery_reaps_dead_pid() {
4662 let home = tmp_home("recover-reap");
4663 let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4664 state::update_registry(&home.registry_json(), |r| {
4665 r.entries.push(RegistryEntry {
4666 name: "dead".into(),
4667 short_id: "dead".into(),
4668 provider: "codex".into(),
4669 cwd: "/tmp".into(),
4670 project_root: "/tmp".into(),
4671 session_id: None,
4672 claude_short_id: None,
4673 claude_session_uuid: None,
4674 messaging_socket_path: None,
4675 codex_session_id: None,
4676 gemini_session_id: None,
4677 mcp_channel_id: None,
4678 cc_session_id: None,
4679 host_mode: None,
4680 status: AgentStatus::Live,
4681 last_message_at: None,
4682 created_at: "2026-05-24T00:00:00Z".into(),
4683 pid: Some(0x7fff_fff0),
4685 pid_start_time: None,
4686 log_path: None,
4687 last_reconciled_at: None,
4688 });
4689 })
4690 .unwrap();
4691 let mut st = AgentState::new_pty("dead");
4693 st.status = AgentStatus::Live;
4694 state::write_state_atomic(&home.state_json("dead"), &st).unwrap();
4695
4696 let report = recover(&home, &emitter);
4697 assert_eq!(report.reaped_pids, vec![0x7fff_fff0]);
4698 let reg = state::load_registry(&home.registry_json()).unwrap();
4699 assert_eq!(reg.find("dead").unwrap().status, AgentStatus::Exited);
4700 std::fs::remove_dir_all(home.root()).ok();
4701 }
4702
4703 #[test]
4704 fn recovery_marks_dead_interactive_exited_and_preserves_host_mode() {
4705 let home = tmp_home("recover-interactive");
4710 let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4711 state::update_registry(&home.registry_json(), |r| {
4712 let mut e = rentry("hosted", AgentStatus::Live, None);
4713 e.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
4714 e.pid = Some(0x7fff_fff0); r.entries.push(e);
4716 })
4717 .unwrap();
4718 let mut st = AgentState::new_pty("hosted");
4719 st.status = AgentStatus::Live;
4720 state::write_state_atomic(&home.state_json("hosted"), &st).unwrap();
4721
4722 let _ = recover(&home, &emitter);
4723 let reg = state::load_registry(&home.registry_json()).unwrap();
4724 let row = reg.find("hosted").unwrap();
4725 assert_eq!(
4726 row.status,
4727 AgentStatus::Exited,
4728 "a dead interactive worker is exited, never orphaned"
4729 );
4730 assert_eq!(
4731 row.host_mode_or_default(),
4732 crate::state::HOST_MODE_INTERACTIVE,
4733 "host_mode must survive recovery"
4734 );
4735 std::fs::remove_dir_all(home.root()).ok();
4736 }
4737
4738 #[test]
4739 fn pid_is_ours_distinguishes_recycled_pid() {
4740 let me = std::process::id();
4743 let Some(st) = process_start_time(me) else {
4744 return; };
4746 assert!(pid_is_ours(me, Some(st)), "correct start time -> ours");
4747 assert!(
4748 !pid_is_ours(me, Some(st.wrapping_add(1))),
4749 "alive but mismatched start time -> recycled, not ours"
4750 );
4751 assert!(
4752 !pid_is_ours(0x7fff_fff0, Some(st)),
4753 "dead pid is never ours"
4754 );
4755 assert!(
4756 pid_is_ours(me, None),
4757 "no recorded start time -> fall back to bare liveness (legacy)"
4758 );
4759 }
4760
4761 #[test]
4762 fn recovery_reaps_recycled_pid() {
4763 let home = tmp_home("recover-recycled");
4768 let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4769 let me = std::process::id();
4770 if process_start_time(me).is_none() {
4771 std::fs::remove_dir_all(home.root()).ok();
4772 return; }
4774 state::update_registry(&home.registry_json(), |r| {
4775 r.entries.push(RegistryEntry {
4776 name: "recycled".into(),
4777 short_id: "recycled".into(),
4778 provider: "codex".into(),
4779 cwd: "/tmp".into(),
4780 project_root: "/tmp".into(),
4781 session_id: None,
4782 claude_short_id: None,
4783 claude_session_uuid: None,
4784 messaging_socket_path: None,
4785 codex_session_id: None,
4786 gemini_session_id: None,
4787 mcp_channel_id: None,
4788 cc_session_id: None,
4789 host_mode: None,
4790 status: AgentStatus::Live,
4791 last_message_at: None,
4792 created_at: "2026-05-24T00:00:00Z".into(),
4793 pid: Some(me),
4794 pid_start_time: Some(1),
4796 log_path: None,
4797 last_reconciled_at: None,
4798 });
4799 })
4800 .unwrap();
4801 let mut st = AgentState::new_pty("recycled");
4802 st.status = AgentStatus::Live;
4803 state::write_state_atomic(&home.state_json("recycled"), &st).unwrap();
4804
4805 let report = recover(&home, &emitter);
4806 assert_eq!(report.reaped_pids, vec![me]);
4807 let reg = state::load_registry(&home.registry_json()).unwrap();
4808 assert_eq!(reg.find("recycled").unwrap().status, AgentStatus::Exited);
4809 std::fs::remove_dir_all(home.root()).ok();
4810 }
4811
4812 #[test]
4813 fn recovery_archives_orphan_state_dir() {
4814 let home = tmp_home("recover-orphan");
4815 let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4816 let mut st = AgentState::new_pty("loner");
4818 st.status = AgentStatus::Live;
4819 state::write_state_atomic(&home.state_json("loner"), &st).unwrap();
4820
4821 let report = recover(&home, &emitter);
4822 assert_eq!(report.archived_orphans, vec!["loner".to_string()]);
4823 assert!(!home.agent_dir("loner").exists(), "orphan dir moved aside");
4824 assert!(home.orphaned_dir().exists());
4825 std::fs::remove_dir_all(home.root()).ok();
4826 }
4827
4828 #[test]
4829 fn agent_name_validation() {
4830 assert!(valid_agent_name("worker-A_1"));
4831 assert!(!valid_agent_name(""));
4832 assert!(!valid_agent_name(&"x".repeat(65)));
4833 assert!(!valid_agent_name("has space"));
4834 assert!(!valid_agent_name("inject;rm"));
4835 }
4836
4837 #[test]
4838 fn uuid_v4_shape_and_uniqueness() {
4839 let a = uuid_v4();
4840 let b = uuid_v4();
4841 assert_ne!(a, b);
4842 assert_eq!(a.len(), 36);
4843 let parts: Vec<&str> = a.split('-').collect();
4844 assert_eq!(
4845 parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
4846 vec![8, 4, 4, 4, 12]
4847 );
4848 assert_eq!(&a[14..15], "4");
4850 assert!(matches!(&a[19..20], "8" | "9" | "a" | "b"));
4851 }
4852
4853 #[test]
4854 fn short_id_derivation_dedups() {
4855 let mut reg = state::Registry::default();
4856 assert_eq!(derive_short_id("worker-A", ®), "workerA");
4857 reg.entries.push(RegistryEntry {
4858 name: "x".into(),
4859 short_id: "workerA".into(),
4860 provider: "codex".into(),
4861 cwd: "/".into(),
4862 project_root: "/".into(),
4863 session_id: None,
4864 claude_short_id: None,
4865 claude_session_uuid: None,
4866 messaging_socket_path: None,
4867 codex_session_id: None,
4868 gemini_session_id: None,
4869 mcp_channel_id: None,
4870 cc_session_id: None,
4871 host_mode: None,
4872 status: AgentStatus::Live,
4873 last_message_at: None,
4874 created_at: "t".into(),
4875 pid: None,
4876 pid_start_time: None,
4877 log_path: None,
4878 last_reconciled_at: None,
4879 });
4880 assert_eq!(derive_short_id("worker-A", ®), "workerA1");
4881 }
4882
4883 fn rentry(name: &str, status: AgentStatus, last_reconciled: Option<&str>) -> RegistryEntry {
4886 RegistryEntry {
4887 name: name.into(),
4888 short_id: name.into(),
4889 provider: "codex".into(),
4890 cwd: "/tmp".into(),
4891 project_root: "/tmp".into(),
4892 session_id: Some("sid".into()),
4893 claude_short_id: None,
4894 claude_session_uuid: None,
4895 messaging_socket_path: None,
4896 codex_session_id: None,
4897 gemini_session_id: None,
4898 mcp_channel_id: None,
4899 host_mode: None,
4900 cc_session_id: None,
4901 status,
4902 last_message_at: None,
4903 created_at: "t".into(),
4904 pid: None,
4905 pid_start_time: None,
4906 log_path: None,
4907 last_reconciled_at: last_reconciled.map(String::from),
4908 }
4909 }
4910
4911 fn probe_err() -> crate::provider::ReachabilityProbeError {
4912 crate::provider::ReachabilityProbeError::new("codex", "store unavailable")
4913 }
4914
4915 fn codex_source(name: &str, uuid: &str, status: AgentStatus) -> RegistryEntry {
4918 let mut e = rentry(name, status, None);
4919 e.provider = "codex".into();
4920 e.codex_session_id = Some(uuid.into());
4921 e
4922 }
4923
4924 #[test]
4925 fn admit_promote_empty_uuid_rejected() {
4926 match admit_promote(&[], " ") {
4927 PromoteAdmission::Reject(r) => assert!(r.contains("non-empty")),
4928 other => panic!("expected reject, got {other:?}"),
4929 }
4930 }
4931
4932 #[test]
4933 fn admit_promote_unknown_uuid_rejected() {
4934 let entries = vec![codex_source("other", "different-uuid", AgentStatus::Live)];
4936 match admit_promote(&entries, "missing-uuid") {
4937 PromoteAdmission::Reject(r) => assert!(r.contains("unknown session")),
4938 other => panic!("expected reject, got {other:?}"),
4939 }
4940 }
4941
4942 #[test]
4943 fn admit_promote_settled_exec_source_infers_provider() {
4944 let entries = vec![codex_source("bot", "uuid-1", AgentStatus::Live)];
4947 assert_eq!(
4948 admit_promote(&entries, "uuid-1"),
4949 PromoteAdmission::Ok {
4950 provider: "codex".into()
4951 }
4952 );
4953 let mut g = rentry("gbot", AgentStatus::Live, None);
4955 g.provider = "gemini".into();
4956 g.gemini_session_id = Some("uuid-g".into());
4957 assert_eq!(
4958 admit_promote(&[g], "uuid-g"),
4959 PromoteAdmission::Ok {
4960 provider: "gemini".into()
4961 }
4962 );
4963 }
4964
4965 #[test]
4966 fn admit_promote_in_flight_source_rejected() {
4967 let entries = vec![codex_source("bot", "uuid-1", AgentStatus::Busy)];
4969 match admit_promote(&entries, "uuid-1") {
4970 PromoteAdmission::Reject(r) => assert!(r.contains("still running")),
4971 other => panic!("expected reject, got {other:?}"),
4972 }
4973 }
4974
4975 #[test]
4976 fn admit_promote_already_hosted_rejected() {
4977 let source = codex_source("bot", "uuid-1", AgentStatus::Exited);
4980 let mut host = codex_source("bot2", "uuid-1", AgentStatus::Live);
4981 host.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
4982 match admit_promote(&[source, host], "uuid-1") {
4983 PromoteAdmission::Reject(r) => assert!(r.contains("already hosted")),
4984 other => panic!("expected reject, got {other:?}"),
4985 }
4986 }
4987
4988 #[test]
4989 fn admit_promote_exited_source_is_promotable() {
4990 let entries = vec![codex_source("bot", "uuid-1", AgentStatus::Exited)];
4992 assert_eq!(
4993 admit_promote(&entries, "uuid-1"),
4994 PromoteAdmission::Ok {
4995 provider: "codex".into()
4996 }
4997 );
4998 }
4999
5000 #[test]
5003 fn interactive_readiness_child_dead_is_failed() {
5004 assert_eq!(
5007 interactive_readiness_step(false, false, false, false),
5008 ReadinessStep::Failed
5009 );
5010 assert_eq!(
5011 interactive_readiness_step(false, true, true, true),
5012 ReadinessStep::Failed
5013 );
5014 }
5015
5016 #[test]
5017 fn interactive_readiness_painted_alive_and_dwelled_is_ready() {
5018 assert_eq!(
5019 interactive_readiness_step(true, true, true, false),
5020 ReadinessStep::Ready
5021 );
5022 }
5023
5024 #[test]
5025 fn interactive_readiness_painted_but_not_dwelled_keeps_polling() {
5026 assert_eq!(
5030 interactive_readiness_step(true, true, false, false),
5031 ReadinessStep::Poll
5032 );
5033 }
5034
5035 #[test]
5036 fn interactive_readiness_alive_unpainted_polls_then_ready_at_deadline() {
5037 assert_eq!(
5039 interactive_readiness_step(true, false, false, false),
5040 ReadinessStep::Poll
5041 );
5042 assert_eq!(
5045 interactive_readiness_step(true, false, false, true),
5046 ReadinessStep::Ready
5047 );
5048 }
5049
5050 #[test]
5051 fn concurrent_spawn_name_reservation_inserts_once() {
5052 let home = tmp_home("spawn-reserve");
5059 let path = home.registry_json();
5060 let reserve = |entry: RegistryEntry| -> bool {
5061 state::update_registry(&path, move |r| {
5062 if r.entries.iter().any(|e| e.name == entry.name) {
5063 return false;
5064 }
5065 r.entries.push(entry);
5066 true
5067 })
5068 .unwrap()
5069 };
5070 assert!(
5071 reserve(rentry("dup", AgentStatus::Live, None)),
5072 "first wins"
5073 );
5074 assert!(
5075 !reserve(rentry("dup", AgentStatus::Live, None)),
5076 "second loses the race -> no insert"
5077 );
5078 let reg = state::load_registry(&path).unwrap();
5079 assert_eq!(
5080 reg.entries.iter().filter(|e| e.name == "dup").count(),
5081 1,
5082 "exactly one row for the contended name"
5083 );
5084 std::fs::remove_dir_all(home.root()).ok();
5085 }
5086
5087 #[test]
5088 fn reconcile_flips_unreachable_live_to_orphaned_and_recovers_orphaned() {
5089 let entries = vec![
5090 rentry("live-but-gone", AgentStatus::Live, None),
5091 rentry("back-from-dead", AgentStatus::Orphaned, None),
5092 ];
5093 let (changes, out) = plan_reconcile(
5094 &entries,
5095 |e| match e.name.as_str() {
5096 "live-but-gone" => Ok(false), _ => Ok(true), },
5099 || false,
5100 |_| true,
5101 );
5102 assert_eq!(out.orphans, vec!["live-but-gone".to_string()]);
5103 assert_eq!(out.recovered, vec!["back-from-dead".to_string()]);
5104 assert_eq!(out.updated.len(), 2);
5105 assert_eq!(
5107 changes[0].new_status,
5108 Some(AgentStatus::Orphaned),
5109 "unreachable live agent should orphan"
5110 );
5111 assert_eq!(changes[1].new_status, Some(AgentStatus::Live));
5112 }
5113
5114 #[test]
5115 fn reconcile_does_not_orphan_a_live_interactive_host_on_store_miss() {
5116 let mut interactive = rentry("hosted-tui", AgentStatus::Live, None);
5122 interactive.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
5123 let exec = rentry("one-shot", AgentStatus::Live, None);
5124 let entries = vec![interactive, exec];
5125 let (changes, out) = plan_reconcile(&entries, |_| Ok(false), || false, |_| true);
5126 assert_eq!(
5127 changes[0].new_status, None,
5128 "a live interactive host must not be orphaned on a session-store miss"
5129 );
5130 assert_eq!(
5131 changes[1].new_status,
5132 Some(AgentStatus::Orphaned),
5133 "an exec sibling with the same probe result is still orphaned"
5134 );
5135 assert_eq!(out.orphans, vec!["one-shot".to_string()]);
5136 }
5137
5138 #[test]
5139 fn reconcile_reaps_a_dead_interactive_host_to_exited() {
5140 let mut dead = rentry("dead-tui", AgentStatus::Live, None);
5145 dead.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
5146 let mut live = rentry("live-tui", AgentStatus::Live, None);
5147 live.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
5148 let entries = vec![dead, live];
5149 let (changes, out) = plan_reconcile(
5150 &entries,
5151 |_| Ok(false), || false,
5153 |e| e.name == "live-tui", );
5155 assert_eq!(
5156 changes[0].new_status,
5157 Some(AgentStatus::Exited),
5158 "a dead interactive host is reaped to Exited during reconcile"
5159 );
5160 assert_eq!(
5161 changes[1].new_status, None,
5162 "a live interactive host is left untouched"
5163 );
5164 assert!(out.orphans.is_empty());
5166 assert_eq!(out.updated, vec!["dead-tui".to_string()]);
5167 }
5168
5169 #[test]
5170 fn reconcile_inconclusive_preserves_status() {
5171 let entries = vec![rentry("flaky", AgentStatus::Live, None)];
5172 let (changes, out) = plan_reconcile(&entries, |_| Err(probe_err()), || false, |_| true);
5173 assert_eq!(changes[0].new_status, None, "must NOT flip on inconclusive");
5174 assert!(out.orphans.is_empty());
5175 assert_eq!(out.inconsistent.len(), 1);
5176 assert_eq!(out.inconsistent[0].0, "flaky");
5177 }
5178
5179 #[test]
5180 fn reconcile_leaves_terminal_states_untouched() {
5181 let entries = vec![
5184 rentry("done", AgentStatus::Exited, None),
5185 rentry("dead", AgentStatus::PermanentDead, None),
5186 ];
5187 let (changes, out) = plan_reconcile(&entries, |_| Ok(false), || false, |_| true);
5188 assert!(changes.iter().all(|c| c.new_status.is_none()));
5189 assert!(out.orphans.is_empty() && out.updated.is_empty());
5190 }
5191
5192 fn ask_entry(name: &str, status: AgentStatus) -> RegistryEntry {
5195 let mut e = rentry(name, status, None);
5196 e.short_id = String::new();
5197 e.pid = None;
5198 e.codex_session_id = Some("resume-uuid".into());
5199 e.session_id = None;
5200 e
5201 }
5202
5203 #[test]
5204 fn reconcile_one_shot_ask_settles_to_exited_even_when_reachable() {
5205 let entries = vec![ask_entry("codex-ask", AgentStatus::Live)];
5211 let (changes, out) = plan_reconcile(
5212 &entries,
5213 |_| Ok(true), || false,
5215 |_| true,
5216 );
5217 assert_eq!(
5218 changes[0].new_status,
5219 Some(AgentStatus::Exited),
5220 "a finished ask settles to exited even when its session file is reachable"
5221 );
5222 assert_eq!(out.updated, vec!["codex-ask".to_string()]);
5223 assert!(out.orphans.is_empty(), "an ask is exited, never orphaned");
5224 assert_eq!(entries[0].codex_session_id.as_deref(), Some("resume-uuid"));
5227 }
5228
5229 #[test]
5230 fn reconcile_one_shot_ask_already_terminal_is_untouched() {
5231 let entries = vec![ask_entry("done-ask", AgentStatus::Exited)];
5233 let (changes, out) = plan_reconcile(&entries, |_| Ok(true), || false, |_| true);
5234 assert_eq!(changes[0].new_status, None);
5235 assert!(out.updated.is_empty());
5236 }
5237
5238 #[test]
5239 fn apply_reconcile_change_clears_pid_only_on_exited() {
5240 let mut to_exited = rentry("x", AgentStatus::Live, None);
5243 to_exited.pid = Some(4242);
5244 to_exited.pid_start_time = Some(99);
5245 apply_reconcile_change(&mut to_exited, Some(AgentStatus::Exited), "T1");
5246 assert_eq!(to_exited.status, AgentStatus::Exited);
5247 assert_eq!(to_exited.pid, None, "exited row must drop its pid");
5248 assert_eq!(to_exited.pid_start_time, None);
5249 assert_eq!(to_exited.last_reconciled_at.as_deref(), Some("T1"));
5250
5251 let mut to_orphaned = rentry("y", AgentStatus::Live, None);
5252 to_orphaned.pid = Some(4242);
5253 apply_reconcile_change(&mut to_orphaned, Some(AgentStatus::Orphaned), "T2");
5254 assert_eq!(to_orphaned.status, AgentStatus::Orphaned);
5255 assert_eq!(
5256 to_orphaned.pid,
5257 Some(4242),
5258 "non-exited transition keeps pid"
5259 );
5260
5261 let mut no_change = rentry("z", AgentStatus::Live, Some("OLD"));
5263 no_change.pid = Some(4242);
5264 apply_reconcile_change(&mut no_change, None, "T3");
5265 assert_eq!(no_change.status, AgentStatus::Live);
5266 assert_eq!(no_change.pid, Some(4242));
5267 assert_eq!(no_change.last_reconciled_at.as_deref(), Some("T3"));
5268 }
5269
5270 #[test]
5271 fn reconcile_defers_remaining_when_budget_exhausted() {
5272 let entries = vec![
5273 rentry("a", AgentStatus::Live, None),
5274 rentry("b", AgentStatus::Live, None),
5275 rentry("c", AgentStatus::Live, None),
5276 ];
5277 let mut probes = 0;
5279 let (changes, out) = plan_reconcile(
5280 &entries,
5281 |_| {
5282 probes += 1;
5283 Ok(true)
5284 },
5285 {
5286 let mut checked = 0;
5287 move || {
5288 let exhausted = checked >= 1;
5289 checked += 1;
5290 exhausted
5291 }
5292 },
5293 |_| true,
5294 );
5295 assert_eq!(out.deferred, 2, "two trailing entries should defer");
5296 assert_eq!(changes.len(), 1, "only one entry probed before budget");
5297 }
5298
5299 #[test]
5300 fn run_reconcile_sweep_empty_registry_is_noop() {
5301 let home = tmp_home("sweep-empty");
5305 let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
5306 let result = run_reconcile_sweep(&home, &emitter).expect("empty sweep ok");
5307 assert!(result.entries.is_empty());
5308 assert_eq!(result.outcome, ReconcileOutcome::default());
5309 std::fs::remove_dir_all(home.root()).ok();
5310 }
5311
5312 struct PromptDetector;
5318 impl crate::readiness::ReadinessDetector for PromptDetector {
5319 fn provider_name(&self) -> &str {
5320 "test-cli"
5321 }
5322 fn is_ready(
5323 &self,
5324 screen: &crate::readiness::ScreenView,
5325 ) -> Result<bool, crate::readiness::ReadinessError> {
5326 Ok(screen.visible_text.trim_end().ends_with('\u{276f}'))
5327 }
5328 }
5329
5330 struct NeverReadyDetector;
5332 impl crate::readiness::ReadinessDetector for NeverReadyDetector {
5333 fn provider_name(&self) -> &str {
5334 "never"
5335 }
5336 fn is_ready(
5337 &self,
5338 _screen: &crate::readiness::ScreenView,
5339 ) -> Result<bool, crate::readiness::ReadinessError> {
5340 Ok(false)
5341 }
5342 }
5343
5344 #[tokio::test(flavor = "current_thread")]
5348 async fn poll_until_ready_returns_settled_reply_on_ready_prompt() {
5349 let snapshots: &[&str] = &["loading...", "still loading...", "done \u{276f}"];
5351 let idx = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5352 let idx2 = idx.clone();
5353 let fetcher = move || {
5354 let i = idx2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5355 let text = snapshots[i.min(snapshots.len() - 1)].to_string();
5356 std::future::ready(Some(text))
5357 };
5358 let result = poll_until_ready(
5359 fetcher,
5360 Box::new(PromptDetector),
5361 Duration::from_millis(1),
5362 Duration::from_secs(5),
5363 )
5364 .await;
5365 assert!(result.is_ok(), "expected Ok, got {result:?}");
5366 let reply = result.unwrap();
5367 assert_eq!(
5368 reply, "done \u{276f}",
5369 "reply must be the settled snapshot text, got {reply:?}"
5370 );
5371 }
5372
5373 #[tokio::test(flavor = "current_thread")]
5377 async fn poll_until_ready_returns_error_on_timeout() {
5378 let fetcher = || std::future::ready(Some("still thinking...".to_string()));
5379 let result = poll_until_ready(
5380 fetcher,
5381 Box::new(NeverReadyDetector),
5382 Duration::from_millis(10),
5383 Duration::from_millis(40), )
5385 .await;
5386 assert!(
5387 result.is_err(),
5388 "expected Err on timeout, got Ok({:?})",
5389 result.ok()
5390 );
5391 }
5392
5393 #[tokio::test(flavor = "current_thread")]
5396 async fn poll_until_ready_empty_settled_screen_returns_empty_string() {
5397 let fetcher = || std::future::ready(Some("\u{276f}".to_string()));
5399 let result = poll_until_ready(
5400 fetcher,
5401 Box::new(PromptDetector),
5402 Duration::from_millis(1),
5403 Duration::from_secs(5),
5404 )
5405 .await;
5406 assert!(result.is_ok(), "expected Ok, got {result:?}");
5407 let reply = result.unwrap();
5410 assert!(!reply.contains("fabricated"), "must not fabricate content");
5411 }
5412
5413 #[test]
5420 fn provider_for_pty_known_providers() {
5421 assert!(
5422 provider_for_pty("codex").is_some(),
5423 "codex must have a PTY provider"
5424 );
5425 assert!(
5426 provider_for_pty("gemini").is_some(),
5427 "gemini must have a PTY provider"
5428 );
5429 assert!(
5431 provider_for_pty("claude").is_none(),
5432 "claude is not PTY-managed; provider_for_pty must return None"
5433 );
5434 assert!(
5436 provider_for_pty("nonexistent").is_none(),
5437 "unknown provider must return None"
5438 );
5439 }
5440
5441 fn test_ctx(home: AgentsHome, worker_bin: PathBuf) -> Ctx {
5442 Ctx {
5443 home,
5444 emitter: EventEmitter::new(std::path::PathBuf::from("/dev/null"), "daemon"),
5445 opts: DaemonOptions {
5446 idle_exit: Duration::from_secs(1800),
5447 worker_bin,
5448 reconcile_on_start: true,
5449 },
5450 started_at: std::time::Instant::now(),
5451 exe_fingerprint: crate::drift::ExeFingerprint::current(),
5452 pid_start_time: process_start_time(std::process::id()),
5453 drives: crate::drive::new_table(),
5454 }
5455 }
5456
5457 fn test_ctx_with_events(home: AgentsHome, worker_bin: PathBuf) -> Ctx {
5460 let events_path = home.events_jsonl();
5461 Ctx {
5462 home,
5463 emitter: EventEmitter::new(events_path, "daemon"),
5464 opts: DaemonOptions {
5465 idle_exit: Duration::from_secs(1800),
5466 worker_bin,
5467 reconcile_on_start: true,
5468 },
5469 started_at: std::time::Instant::now(),
5470 exe_fingerprint: crate::drift::ExeFingerprint::current(),
5471 pid_start_time: process_start_time(std::process::id()),
5472 drives: crate::drive::new_table(),
5473 }
5474 }
5475
5476 const FAKE_STREAM_EMITTER: &str = r#"
5483printf '%s\n' '{"type":"system","subtype":"init","session_id":"s1"}'
5484while IFS= read -r line; do
5485 printf '%s\n' '{"type":"user","message":{"role":"user"}}'
5486 printf '%s\n' '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"par"}}}'
5487 printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"reply-text"}]}}'
5488 printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"result":"reply-text"}'
5489done
5490"#;
5491
5492 fn short_home(tag: &str) -> AgentsHome {
5497 use std::sync::atomic::{AtomicU32, Ordering};
5498 static C: AtomicU32 = AtomicU32::new(0);
5499 let n = C.fetch_add(1, Ordering::Relaxed);
5500 let p = PathBuf::from(format!("/tmp/abisb{tag}{}_{n}", std::process::id()));
5501 let home = AgentsHome::at(&p);
5502 home.ensure_root().unwrap();
5503 home
5504 }
5505
5506 fn seed_stream_row(home: &AgentsHome, name: &str, short_id: &str) {
5508 state::update_registry(&home.registry_json(), |r| {
5509 r.entries.push(RegistryEntry {
5510 name: name.into(),
5511 short_id: short_id.into(),
5512 provider: "claude".into(),
5513 cwd: "/tmp".into(),
5514 project_root: "/tmp".into(),
5515 session_id: None,
5516 claude_short_id: None,
5517 claude_session_uuid: Some(format!("uuid-{short_id}")),
5518 messaging_socket_path: None,
5519 codex_session_id: None,
5520 gemini_session_id: None,
5521 mcp_channel_id: None,
5522 cc_session_id: None,
5523 host_mode: None,
5524 status: AgentStatus::Live,
5525 last_message_at: None,
5526 created_at: "2026-06-09T00:00:00Z".into(),
5527 pid: None,
5528 pid_start_time: None,
5529 log_path: None,
5530 last_reconciled_at: None,
5531 });
5532 })
5533 .unwrap();
5534 }
5535
5536 async fn start_stream_worker(home: &AgentsHome, short_id: &str, script: &str) -> PathBuf {
5539 let cfg = crate::stream_worker::StreamWorkerConfig::new(
5540 short_id,
5541 home.root().to_path_buf(),
5542 std::env::temp_dir(),
5543 vec!["bash".into(), "-c".into(), script.into()],
5544 );
5545 let short_id_dbg = short_id.to_string();
5546 std::thread::spawn(move || {
5547 let rt = tokio::runtime::Builder::new_current_thread()
5548 .enable_all()
5549 .build()
5550 .unwrap();
5551 rt.block_on(async {
5552 if let Err(e) = crate::stream_worker::run(cfg).await {
5553 eprintln!("STREAM WORKER RUN ERROR ({short_id_dbg}): {e}");
5554 }
5555 });
5556 });
5557 let sock = home.worker_sock(short_id);
5558 let start = std::time::Instant::now();
5559 while !sock.exists() && start.elapsed() < Duration::from_secs(20) {
5560 tokio::time::sleep(Duration::from_millis(50)).await;
5561 }
5562 assert!(
5563 sock.exists(),
5564 "stream worker socket never appeared for {short_id}"
5565 );
5566 sock
5567 }
5568
5569 fn built_worker_bin() -> Option<PathBuf> {
5574 let exe = std::env::current_exe().ok()?;
5575 let dir = exe.parent()?.parent()?; let cand = dir.join("fno-agents-worker");
5577 cand.exists().then_some(cand)
5578 }
5579
5580 #[test]
5583 fn stream_claim_holder_is_short_id_scoped() {
5584 assert_eq!(stream_claim_holder("sw7"), "stream:sw7");
5585 }
5586
5587 #[test]
5588 fn is_live_writer_excludes_orphaned_and_terminal() {
5589 for s in [
5591 AgentStatus::Live,
5592 AgentStatus::Ready,
5593 AgentStatus::Idle,
5594 AgentStatus::Busy,
5595 AgentStatus::Spawning,
5596 AgentStatus::Restarting,
5597 ] {
5598 assert!(is_live_writer(s), "{s:?} should count as a live writer");
5599 }
5600 for s in [
5602 AgentStatus::Orphaned,
5603 AgentStatus::Failed,
5604 AgentStatus::Exited,
5605 AgentStatus::PermanentDead,
5606 ] {
5607 assert!(!is_live_writer(s), "{s:?} must NOT block re-adoption");
5608 }
5609 }
5610
5611 #[test]
5612 fn claim_acquire_argv_targets_session_key() {
5613 assert_eq!(
5614 claim_acquire_argv("U-1", "stream:sw1"),
5615 vec![
5616 "claim",
5617 "acquire",
5618 "session:U-1",
5619 "--holder",
5620 "stream:sw1",
5621 "-J"
5622 ]
5623 );
5624 }
5625
5626 #[test]
5627 fn claude_stream_worker_args_carry_stream_flags_and_child_argv() {
5628 let child = crate::provider::claude_stream_json_resume_argv("U-9");
5629 let args = claude_stream_worker_args(
5630 "sw9",
5631 std::path::Path::new("/home/agents"),
5632 std::path::Path::new("/work"),
5633 "U-9",
5634 "stream:sw9",
5635 &child,
5636 );
5637 assert!(args.contains(&"--stream".to_string()));
5640 assert_eq!(
5641 args.iter()
5642 .position(|a| a == "--session-uuid")
5643 .map(|i| &args[i + 1]),
5644 Some(&"U-9".to_string())
5645 );
5646 assert_eq!(
5647 args.iter()
5648 .position(|a| a == "--holder")
5649 .map(|i| &args[i + 1]),
5650 Some(&"stream:sw9".to_string())
5651 );
5652 let sep = args
5653 .iter()
5654 .position(|a| a == "--")
5655 .expect("missing -- separator");
5656 assert_eq!(&args[sep + 1..], child.as_slice());
5657 assert_eq!(child[0], "claude");
5658 assert!(child.contains(&"--resume".to_string()) && child.contains(&"U-9".to_string()));
5659 }
5660
5661 #[test]
5662 fn build_claude_stream_entry_marks_interactive_claude_with_full_uuid() {
5663 let e = build_claude_stream_entry(
5664 "adopted",
5665 "sw3",
5666 std::path::Path::new("/proj"),
5667 "FULL-UUID-3",
5668 4242,
5669 Some(99),
5670 PathBuf::from("/proj/.fno/agents/sw3/timeline.jsonl"),
5671 );
5672 assert_eq!(e.provider, "claude");
5673 assert_eq!(
5674 e.host_mode.as_deref(),
5675 Some(crate::state::HOST_MODE_INTERACTIVE)
5676 );
5677 assert!(
5678 e.is_interactive(),
5679 "stream thread must read as interactive for reconcile"
5680 );
5681 assert_eq!(e.claude_session_uuid.as_deref(), Some("FULL-UUID-3"));
5682 assert_eq!(e.status, AgentStatus::Live);
5683 assert_eq!(e.pid, Some(4242));
5684 assert_eq!(e.claude_short_id, None);
5686 }
5687
5688 #[tokio::test(flavor = "current_thread")]
5693 async fn host_claude_without_from_rejected_with_adopt_pointer() {
5694 let home = short_home("clnofrom");
5695 let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5696 let req = Request::new(
5697 1,
5698 "agent.spawn",
5699 json!({"name": "cl", "provider": "claude", "host_mode": "interactive"}),
5700 );
5701 let resp = handle_spawn(&ctx, &req).await;
5702 match &resp.payload {
5703 crate::protocol::ResponsePayload::Err(e) => {
5704 assert_eq!(e.code, ErrorCode::InvalidParams);
5705 assert!(
5706 e.message.contains("promote") && e.message.contains("--from"),
5707 "claude host without --from must point at the adopt verb; got: {}",
5708 e.message
5709 );
5710 }
5711 _ => panic!("expected error for claude host without --from"),
5712 }
5713 std::fs::remove_dir_all(home.root()).ok();
5714 }
5715
5716 #[tokio::test(flavor = "current_thread")]
5720 async fn promote_claude_duplicate_session_refused() {
5721 let home = short_home("cldup");
5722 seed_stream_row(&home, "first", "swDup"); let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5724 let req = Request::new(
5725 1,
5726 "agent.spawn",
5727 json!({
5728 "name": "second", "provider": "claude", "host_mode": "interactive",
5729 "resume_id": "uuid-swDup"
5730 }),
5731 );
5732 let resp = handle_spawn(&ctx, &req).await;
5733 match &resp.payload {
5734 crate::protocol::ResponsePayload::Err(e) => {
5735 assert_eq!(e.code, ErrorCode::InvalidParams);
5736 assert!(
5737 e.message.contains("already hosted") && e.message.contains("first"),
5738 "duplicate adopt must name the existing host; got: {}",
5739 e.message
5740 );
5741 }
5742 _ => panic!("expected single-writer refusal for duplicate adopt"),
5743 }
5744 std::fs::remove_dir_all(home.root()).ok();
5745 }
5746
5747 #[tokio::test(flavor = "current_thread")]
5753 async fn promote_claude_spawns_live_stream_thread() {
5754 let Some(worker_bin) = built_worker_bin() else {
5755 eprintln!("skip promote_claude_spawns_live_stream_thread: worker bin not built");
5756 return;
5757 };
5758 let home = short_home("cle2e");
5759 std::env::set_var("FNO_CLAIMS_ROOT", home.root());
5766 let ctx = test_ctx(home.clone(), worker_bin);
5767 let req = Request::new(
5768 1,
5769 "agent.spawn",
5770 json!({
5771 "name": "cl", "provider": "claude", "host_mode": "interactive",
5772 "resume_id": "uuid-e2e", "cwd": "/tmp",
5773 "argv": ["bash", "-c", FAKE_STREAM_EMITTER]
5775 }),
5776 );
5777 let resp = handle_spawn(&ctx, &req).await;
5778 let res = resp.result().expect("claude adopt errored");
5779 assert_eq!(res["provider"], "claude");
5780 assert_eq!(res["status"], "live");
5781 assert_eq!(res["lane"], "stream");
5782
5783 let reg = load_registry_offloaded(home.registry_json()).await;
5784 let row = reg.find("cl").expect("adopted row missing");
5785 assert_eq!(row.provider, "claude");
5786 assert_eq!(row.host_mode.as_deref(), Some("interactive"));
5787 assert_eq!(row.claude_session_uuid.as_deref(), Some("uuid-e2e"));
5788 assert_eq!(row.status, AgentStatus::Live);
5789
5790 let sock = home.worker_sock(&row.short_id);
5791 assert!(
5792 is_live_stream_thread(&sock).await,
5793 "adopted thread must serve the stream protocol"
5794 );
5795
5796 best_effort_worker_shutdown(&sock).await;
5800 std::fs::remove_dir_all(home.root()).ok();
5801 std::env::remove_var("FNO_CLAIMS_ROOT");
5802 }
5803
5804 #[tokio::test(flavor = "current_thread")]
5809 async fn promote_claude_dead_on_arrival_resume_rejected() {
5810 let Some(worker_bin) = built_worker_bin() else {
5811 eprintln!("skip promote_claude_dead_on_arrival_resume_rejected: worker bin not built");
5812 return;
5813 };
5814 let home = short_home("cldoa");
5815 std::env::set_var("FNO_CLAIMS_ROOT", home.root());
5816 let ctx = test_ctx(home.clone(), worker_bin);
5817 let req = Request::new(
5818 1,
5819 "agent.spawn",
5820 json!({
5821 "name": "cl", "provider": "claude", "host_mode": "interactive",
5822 "resume_id": "uuid-doa", "cwd": "/tmp",
5823 "argv": ["bash", "-c", "exit 1"]
5825 }),
5826 );
5827 let resp = handle_spawn(&ctx, &req).await;
5828 assert!(
5829 resp.is_err(),
5830 "DOA resume child must be rejected, not registered"
5831 );
5832 assert_eq!(resp.error().unwrap().code, ErrorCode::SpawnFailed);
5833 let reg = load_registry_offloaded(home.registry_json()).await;
5834 assert!(
5835 reg.find("cl").is_none(),
5836 "no row may be registered for a DOA adopt"
5837 );
5838 std::fs::remove_dir_all(home.root()).ok();
5839 std::env::remove_var("FNO_CLAIMS_ROOT");
5840 }
5841
5842 #[tokio::test(flavor = "current_thread")]
5845 async fn switchboard_drives_b_and_mirrors_into_a() {
5846 let home = short_home("hp");
5847 seed_stream_row(&home, "A", "swA");
5848 seed_stream_row(&home, "B", "swB");
5849 let _a = start_stream_worker(&home, "swA", FAKE_STREAM_EMITTER).await;
5850 let _b = start_stream_worker(&home, "swB", FAKE_STREAM_EMITTER).await;
5851 let ctx = test_ctx_with_events(home.clone(), PathBuf::from("/nonexistent-worker"));
5852
5853 let req = Request::new(
5854 1,
5855 "agent.switchboard",
5856 json!({"to": "B", "from": "A", "body": "hello"}),
5857 );
5858 let resp = handle_switchboard(&ctx, &req).await;
5859 let res = resp.result().expect("switchboard errored");
5860 assert_eq!(res["delivered"], true, "not delivered: {res:?}");
5861 assert_eq!(res["reply"], "reply-text");
5862 assert_eq!(res["is_error"], false);
5863 assert_eq!(res["receipt"], true, "user-echo receipt not observed");
5864 assert_eq!(res["mirrored"], true, "B's reply was not mirrored into A");
5865
5866 let events = read_events(&home);
5868 assert!(
5869 events.iter().any(|e| e["kind"] == "agent_deliver_injected"
5870 && e["transport"] == "switchboard"
5871 && e["mirrored"] == true),
5872 "switchboard injected event missing: {events:?}"
5873 );
5874 std::fs::remove_dir_all(home.root()).ok();
5875 }
5876
5877 #[tokio::test(flavor = "current_thread")]
5882 async fn switchboard_second_turn_returns_fresh_reply() {
5883 const COUNTING_EMITTER: &str = r#"
5884printf '%s\n' '{"type":"system","subtype":"init","session_id":"s1"}'
5885n=0
5886while IFS= read -r line; do
5887 n=$((n+1))
5888 printf '%s\n' '{"type":"user","message":{"role":"user"}}'
5889 printf '%s\n' "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"reply-$n\"}]}}"
5890 printf '%s\n' "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"reply-$n\"}"
5891done
5892"#;
5893 let home = short_home("fresh");
5894 seed_stream_row(&home, "B", "swB");
5895 let _b = start_stream_worker(&home, "swB", COUNTING_EMITTER).await;
5896 let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5897
5898 let r1 = handle_switchboard(
5899 &ctx,
5900 &Request::new(
5901 1,
5902 "agent.switchboard",
5903 json!({"to": "B", "from": "ghost", "body": "first"}),
5904 ),
5905 )
5906 .await;
5907 assert_eq!(r1.result().expect("hop1")["reply"], "reply-1");
5908
5909 let r2 = handle_switchboard(
5910 &ctx,
5911 &Request::new(
5912 2,
5913 "agent.switchboard",
5914 json!({"to": "B", "from": "ghost", "body": "second"}),
5915 ),
5916 )
5917 .await;
5918 assert_eq!(
5919 r2.result().expect("hop2")["reply"],
5920 "reply-2",
5921 "second drive returned a STALE reply (cursor not advanced past the prior turn)"
5922 );
5923 std::fs::remove_dir_all(home.root()).ok();
5924 }
5925
5926 #[tokio::test(flavor = "current_thread")]
5929 async fn switchboard_demotes_when_b_not_a_live_stream_thread() {
5930 let home = short_home("demote");
5931 seed_stream_row(&home, "B", "swB"); let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5933
5934 let req = Request::new(
5935 1,
5936 "agent.switchboard",
5937 json!({"to": "B", "from": "A", "body": "hi"}),
5938 );
5939 let resp = handle_switchboard(&ctx, &req).await;
5940 let res = resp
5941 .result()
5942 .expect("should be Ok-demote, not an RPC error");
5943 assert_eq!(res["delivered"], false);
5944 assert_eq!(res["reason"], "not-a-live-stream-thread");
5945 std::fs::remove_dir_all(home.root()).ok();
5946 }
5947
5948 #[tokio::test(flavor = "current_thread")]
5951 async fn switchboard_one_way_when_peer_absent() {
5952 let home = short_home("oneway");
5953 seed_stream_row(&home, "B", "swB");
5954 let _b = start_stream_worker(&home, "swB", FAKE_STREAM_EMITTER).await;
5955 let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5956
5957 let req = Request::new(
5958 1,
5959 "agent.switchboard",
5960 json!({"to": "B", "from": "ghost", "body": "hi"}),
5961 );
5962 let resp = handle_switchboard(&ctx, &req).await;
5963 let res = resp.result().expect("switchboard errored");
5964 assert_eq!(res["delivered"], true);
5965 assert_eq!(res["reply"], "reply-text");
5966 assert_eq!(res["mirrored"], false, "no peer to mirror into");
5967 std::fs::remove_dir_all(home.root()).ok();
5968 }
5969
5970 #[tokio::test(flavor = "current_thread")]
5972 async fn switchboard_unknown_target_is_not_found() {
5973 let home = short_home("404");
5974 let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5975 let req = Request::new(
5976 1,
5977 "agent.switchboard",
5978 json!({"to": "nope", "from": "A", "body": "hi"}),
5979 );
5980 let resp = handle_switchboard(&ctx, &req).await;
5981 if let crate::protocol::ResponsePayload::Err(ref e) = resp.payload {
5982 assert_eq!(e.code, ErrorCode::AgentNotFound);
5983 } else {
5984 panic!("expected AgentNotFound, got {resp:?}");
5985 }
5986 std::fs::remove_dir_all(home.root()).ok();
5987 }
5988
5989 #[tokio::test(flavor = "current_thread")]
5992 async fn handle_spawn_resolves_argv_from_provider_when_absent() {
5993 let home = tmp_home("spawn-provider-argv");
5999 let ctx = test_ctx(
6000 home.clone(),
6001 PathBuf::from("/nonexistent/fno-agents-worker"),
6002 );
6003 let req = Request::new(
6005 1,
6006 "agent.spawn",
6007 json!({"name": "test-agent", "provider": "codex"}),
6008 );
6009 let resp = handle_spawn(&ctx, &req).await;
6010 if let crate::protocol::ResponsePayload::Err(ref e) = resp.payload {
6012 assert_ne!(
6013 e.code,
6014 ErrorCode::InvalidParams,
6015 "handle_spawn must not return InvalidParams when provider is known; got: {}",
6016 e.message
6017 );
6018 }
6020 std::fs::remove_dir_all(home.root()).ok();
6021 }
6022
6023 #[tokio::test(flavor = "current_thread")]
6025 async fn handle_spawn_unknown_provider_no_argv_returns_invalid_params() {
6026 let home = tmp_home("spawn-unknown-provider");
6027 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6028 let req = Request::new(
6029 1,
6030 "agent.spawn",
6031 json!({"name": "test-agent", "provider": "nonexistent-provider"}),
6032 );
6033 let resp = handle_spawn(&ctx, &req).await;
6034 match &resp.payload {
6035 crate::protocol::ResponsePayload::Err(e) => {
6036 assert_eq!(
6037 e.code,
6038 ErrorCode::InvalidParams,
6039 "unknown provider without argv must return InvalidParams"
6040 );
6041 }
6042 _ => panic!("expected error response for unknown provider"),
6043 }
6044 std::fs::remove_dir_all(home.root()).ok();
6045 }
6046
6047 #[tokio::test(flavor = "current_thread")]
6050 async fn handle_ask_first_contact_with_provider_does_not_return_agent_not_found() {
6051 let home = tmp_home("ask-first-contact");
6052 let ctx = test_ctx(
6053 home.clone(),
6054 PathBuf::from("/nonexistent/fno-agents-worker"),
6055 );
6056 let req = Request::new(
6058 1,
6059 "agent.ask",
6060 json!({"name": "new-agent", "message": "hello", "provider": "codex"}),
6061 );
6062 let resp = handle_ask(&ctx, &req).await;
6063 match &resp.payload {
6071 crate::protocol::ResponsePayload::Err(e) => {
6072 assert_ne!(
6073 e.code,
6074 ErrorCode::AgentNotFound,
6075 "first-contact ask with --provider must attempt a spawn, not short-circuit AgentNotFound; got: {}",
6076 e.message
6077 );
6078 assert_ne!(
6079 e.code,
6080 ErrorCode::InvalidParams,
6081 "provider was supplied, so this must not be the missing-provider error; got: {}",
6082 e.message
6083 );
6084 }
6085 crate::protocol::ResponsePayload::Ok(v) => {
6086 panic!("spawn must fail with a missing worker binary, got Ok: {v}")
6087 }
6088 }
6089 std::fs::remove_dir_all(home.root()).ok();
6090 }
6091
6092 #[tokio::test(flavor = "current_thread")]
6095 async fn handle_ask_first_contact_without_provider_returns_invalid_params() {
6096 let home = tmp_home("ask-no-provider");
6097 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6098 let req = Request::new(
6100 1,
6101 "agent.ask",
6102 json!({"name": "ghost-agent", "message": "hello"}),
6103 );
6104 let resp = handle_ask(&ctx, &req).await;
6105 match &resp.payload {
6106 crate::protocol::ResponsePayload::Err(e) => {
6107 assert_eq!(
6108 e.code,
6109 ErrorCode::InvalidParams,
6110 "first-contact ask without --provider must return InvalidParams; got: {}",
6111 e.message
6112 );
6113 assert!(
6114 e.message.contains("provider"),
6115 "error message must mention 'provider', got: {}",
6116 e.message
6117 );
6118 }
6119 _ => panic!("expected error for first-contact ask without provider"),
6120 }
6121 std::fs::remove_dir_all(home.root()).ok();
6122 }
6123
6124 #[tokio::test(flavor = "current_thread")]
6129 async fn handle_deliver_gate_failed_returns_failed_reason() {
6130 let home = tmp_home("deliver-gate-failed");
6131 state::update_registry(&home.registry_json(), |r| {
6133 r.entries.push(RegistryEntry {
6134 name: "failed-gate-agent".into(),
6135 short_id: "fgA".into(),
6136 provider: "codex".into(),
6137 cwd: "/tmp".into(),
6138 project_root: "/tmp".into(),
6139 session_id: None,
6140 claude_short_id: None,
6141 claude_session_uuid: None,
6142 messaging_socket_path: None,
6143 codex_session_id: Some("some-uuid".into()),
6144 gemini_session_id: None,
6145 mcp_channel_id: None,
6146 cc_session_id: None,
6147 host_mode: Some("interactive".into()),
6148 status: AgentStatus::Live,
6149 last_message_at: None,
6150 created_at: "2026-06-07T00:00:00Z".into(),
6151 pid: None,
6152 pid_start_time: None,
6153 log_path: None,
6154 last_reconciled_at: None,
6155 });
6156 })
6157 .unwrap();
6158 let gate_path = home.injection_gate_json();
6160 std::fs::write(
6161 &gate_path,
6162 r#"{"v":1,"providers":{"codex":{"status":"failed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6163 )
6164 .unwrap();
6165 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6166 let req = Request::new(
6167 1,
6168 "agent.deliver",
6169 json!({"name": "failed-gate-agent", "body": "hello", "from_name": "sender"}),
6170 );
6171 let resp = handle_deliver(&ctx, &req).await;
6172 assert!(
6173 !resp.is_err(),
6174 "failed-gate deliver must return Ok, not RPC error"
6175 );
6176 let result = resp.result().unwrap();
6177 assert_eq!(result["delivered"], false);
6178 assert_eq!(
6179 result["reason"], "injection-gate-failed",
6180 "expected injection-gate-failed for status=failed, got {:?}",
6181 result["reason"]
6182 );
6183 std::fs::remove_dir_all(home.root()).ok();
6184 }
6185
6186 #[test]
6188 fn injection_gate_thread_local_beats_file_record() {
6189 let home = tmp_home("gate-override-beats-file");
6190 let gate_path = home.injection_gate_json();
6192 std::fs::write(
6193 &gate_path,
6194 r#"{"v":1,"providers":{"codex":{"status":"failed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6195 )
6196 .unwrap();
6197 let _guard = with_injection_gate_allow("codex");
6200 assert_eq!(
6201 injection_gate_with_home("codex", &home),
6202 InjectionGate::Passed,
6203 "thread-local override must beat file record"
6204 );
6205 std::fs::remove_dir_all(home.root()).ok();
6206 }
6207
6208 #[test]
6210 fn injection_gate_malformed_file_returns_unverified() {
6211 let home = tmp_home("gate-malformed");
6212 let gate_path = home.injection_gate_json();
6213 std::fs::write(&gate_path, b"not-json!!!").unwrap();
6214 assert_eq!(
6215 injection_gate_with_home("codex", &home),
6216 InjectionGate::Unverified,
6217 "malformed gate file must return Unverified"
6218 );
6219 std::fs::remove_dir_all(home.root()).ok();
6220 }
6221
6222 #[test]
6224 fn injection_gate_absent_provider_returns_unverified() {
6225 let home = tmp_home("gate-absent-provider");
6226 let gate_path = home.injection_gate_json();
6227 std::fs::write(
6229 &gate_path,
6230 r#"{"v":1,"providers":{"gemini":{"status":"passed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6231 )
6232 .unwrap();
6233 assert_eq!(
6234 injection_gate_with_home("codex", &home),
6235 InjectionGate::Unverified,
6236 "absent provider must return Unverified"
6237 );
6238 std::fs::remove_dir_all(home.root()).ok();
6239 }
6240
6241 #[test]
6243 fn injection_gate_passed_status_returns_passed() {
6244 let home = tmp_home("gate-status-passed");
6245 let gate_path = home.injection_gate_json();
6246 std::fs::write(
6247 &gate_path,
6248 r#"{"v":1,"providers":{"codex":{"status":"passed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6249 )
6250 .unwrap();
6251 assert_eq!(
6252 injection_gate_with_home("codex", &home),
6253 InjectionGate::Passed,
6254 );
6255 std::fs::remove_dir_all(home.root()).ok();
6256 }
6257
6258 #[tokio::test(flavor = "current_thread")]
6260 async fn handle_gate_check_manual_writes_record() {
6261 let home = tmp_home("gate-check-manual");
6262 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6263 let req = Request::new(
6264 1,
6265 "agent.gate_check",
6266 json!({"provider": "codex", "record": "manual", "status": "passed", "notes": "verified by operator"}),
6267 );
6268 let resp = handle_gate_check(&ctx, &req).await;
6269 assert!(
6270 !resp.is_err(),
6271 "gate_check manual must return Ok: {:?}",
6272 resp
6273 );
6274 let result = resp.result().unwrap();
6275 assert_eq!(result["status"], "passed");
6276 let gate_path = home.injection_gate_json();
6278 assert!(gate_path.exists(), "injection-gate.json must be written");
6279 let content: serde_json::Value =
6280 serde_json::from_str(&std::fs::read_to_string(&gate_path).unwrap()).unwrap();
6281 assert_eq!(content["v"], 1);
6282 assert_eq!(content["providers"]["codex"]["status"], "passed");
6283 assert_eq!(content["providers"]["codex"]["method"], "manual");
6284 assert_eq!(
6285 content["providers"]["codex"]["notes"],
6286 "verified by operator"
6287 );
6288 std::fs::remove_dir_all(home.root()).ok();
6289 }
6290
6291 #[tokio::test]
6295 async fn handle_gate_check_manual_malformed_existing_file_errors_no_wipe() {
6296 let home = tmp_home("gate-check-malformed");
6297 let gate_path = home.injection_gate_json();
6298 std::fs::create_dir_all(gate_path.parent().unwrap()).unwrap();
6299 std::fs::write(&gate_path, "{not json").unwrap();
6300 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6301 let req = Request::new(
6302 1,
6303 "agent.gate_check",
6304 json!({"provider": "codex", "record": "manual", "status": "passed", "notes": ""}),
6305 );
6306 let resp = handle_gate_check(&ctx, &req).await;
6307 assert!(
6308 resp.is_err(),
6309 "malformed existing gate file must error, got: {:?}",
6310 resp
6311 );
6312 assert_eq!(
6314 std::fs::read_to_string(&gate_path).unwrap(),
6315 "{not json",
6316 "gate file must not be rewritten on parse failure"
6317 );
6318 std::fs::remove_dir_all(home.root()).ok();
6319 }
6320
6321 #[tokio::test(flavor = "current_thread")]
6324 async fn handle_gate_check_probe_inconclusive_no_worker() {
6325 let home = tmp_home("gate-probe-inconclusive");
6326 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6327 let req = Request::new(
6328 1,
6329 "agent.gate_check",
6330 json!({"provider": "codex", "record": "probe"}),
6331 );
6332 let resp = handle_gate_check(&ctx, &req).await;
6333 assert!(
6334 !resp.is_err(),
6335 "gate_check probe must return Ok: {:?}",
6336 resp
6337 );
6338 let result = resp.result().unwrap();
6339 assert_eq!(
6340 result["status"], "inconclusive",
6341 "probe without live worker must return inconclusive"
6342 );
6343 assert!(
6345 !home.injection_gate_json().exists(),
6346 "probe inconclusive must NOT write gate record"
6347 );
6348 std::fs::remove_dir_all(home.root()).ok();
6349 }
6350
6351 #[tokio::test(flavor = "current_thread")]
6354 async fn handle_deliver_failed_gate_emits_demoted_event() {
6355 let home = tmp_home("deliver-gate-failed-event");
6356 state::update_registry(&home.registry_json(), |r| {
6357 r.entries.push(RegistryEntry {
6358 name: "demote-agent".into(),
6359 short_id: "dmA".into(),
6360 provider: "codex".into(),
6361 cwd: "/tmp".into(),
6362 project_root: "/tmp".into(),
6363 session_id: None,
6364 claude_short_id: None,
6365 claude_session_uuid: None,
6366 messaging_socket_path: None,
6367 codex_session_id: Some("some-uuid".into()),
6368 gemini_session_id: None,
6369 mcp_channel_id: None,
6370 cc_session_id: None,
6371 host_mode: Some("interactive".into()),
6372 status: AgentStatus::Live,
6373 last_message_at: None,
6374 created_at: "2026-06-07T00:00:00Z".into(),
6375 pid: None,
6376 pid_start_time: None,
6377 log_path: None,
6378 last_reconciled_at: None,
6379 });
6380 })
6381 .unwrap();
6382 let gate_path = home.injection_gate_json();
6383 std::fs::write(
6384 &gate_path,
6385 r#"{"v":1,"providers":{"codex":{"status":"failed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6386 )
6387 .unwrap();
6388 let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6389 let req = Request::new(
6390 1,
6391 "agent.deliver",
6392 json!({"name": "demote-agent", "body": "hi", "from_name": "tester"}),
6393 );
6394 let resp = handle_deliver(&ctx, &req).await;
6395 assert!(!resp.is_err());
6396 assert_eq!(resp.result().unwrap()["delivered"], false);
6397 let events = read_events(&home);
6399 assert!(
6400 events.iter().any(|e| e["kind"] == "agent_deliver_demoted"),
6401 "agent_deliver_demoted must be emitted when gate status=failed"
6402 );
6403 std::fs::remove_dir_all(home.root()).ok();
6404 }
6405
6406 #[tokio::test(flavor = "current_thread")]
6411 async fn handle_deliver_gate_default_returns_unverified() {
6412 let home = tmp_home("deliver-gate-default");
6413 state::update_registry(&home.registry_json(), |r| {
6415 r.entries.push(RegistryEntry {
6416 name: "gate-agent".into(),
6417 short_id: "gtA".into(),
6418 provider: "codex".into(),
6419 cwd: "/tmp".into(),
6420 project_root: "/tmp".into(),
6421 session_id: None,
6422 claude_short_id: None,
6423 claude_session_uuid: None,
6424 messaging_socket_path: None,
6425 codex_session_id: Some("some-uuid".into()),
6426 gemini_session_id: None,
6427 mcp_channel_id: None,
6428 cc_session_id: None,
6429 host_mode: Some("interactive".into()),
6430 status: AgentStatus::Live,
6431 last_message_at: None,
6432 created_at: "2026-06-07T00:00:00Z".into(),
6433 pid: None,
6434 pid_start_time: None,
6435 log_path: None,
6436 last_reconciled_at: None,
6437 });
6438 })
6439 .unwrap();
6440 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6443 let req = Request::new(
6444 1,
6445 "agent.deliver",
6446 json!({"name": "gate-agent", "body": "hello", "from_name": "sender"}),
6447 );
6448 let resp = handle_deliver(&ctx, &req).await;
6449 assert!(
6451 !resp.is_err(),
6452 "deliver gate-default must return Ok result, not RPC error"
6453 );
6454 let result = resp.result().unwrap();
6455 assert_eq!(result["delivered"], false);
6456 assert_eq!(result["reason"], "injection-gate-unverified");
6457 std::fs::remove_dir_all(home.root()).ok();
6458 }
6459
6460 #[tokio::test(flavor = "current_thread")]
6462 async fn handle_deliver_claude_returns_socket_route_result() {
6463 let home = tmp_home("deliver-claude");
6464 state::update_registry(&home.registry_json(), |r| {
6465 r.entries.push(RegistryEntry {
6466 name: "claude-agent".into(),
6467 short_id: "clA".into(),
6468 provider: "claude".into(),
6469 cwd: "/tmp".into(),
6470 project_root: "/tmp".into(),
6471 session_id: Some("abc123".into()),
6472 claude_short_id: Some("abc123".into()),
6473 claude_session_uuid: None,
6474 messaging_socket_path: None,
6475 codex_session_id: None,
6476 gemini_session_id: None,
6477 mcp_channel_id: None,
6478 cc_session_id: None,
6479 host_mode: None,
6480 status: AgentStatus::Live,
6481 last_message_at: None,
6482 created_at: "2026-06-07T00:00:00Z".into(),
6483 pid: None,
6484 pid_start_time: None,
6485 log_path: None,
6486 last_reconciled_at: None,
6487 });
6488 })
6489 .unwrap();
6490 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6491 let req = Request::new(
6492 1,
6493 "agent.deliver",
6494 json!({"name": "claude-agent", "body": "ping", "from_name": "sender"}),
6495 );
6496 let resp = handle_deliver(&ctx, &req).await;
6497 assert!(!resp.is_err(), "claude deliver must return Ok result");
6498 let result = resp.result().unwrap();
6499 assert_eq!(result["delivered"], false);
6500 assert_eq!(result["reason"], "claude-routes-via-socket");
6501 std::fs::remove_dir_all(home.root()).ok();
6502 }
6503
6504 #[tokio::test(flavor = "current_thread")]
6506 async fn handle_deliver_unknown_agent_returns_not_found() {
6507 let home = tmp_home("deliver-not-found");
6508 let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6509 let req = Request::new(
6510 1,
6511 "agent.deliver",
6512 json!({"name": "ghost", "body": "hello", "from_name": "sender"}),
6513 );
6514 let resp = handle_deliver(&ctx, &req).await;
6515 assert!(resp.is_err());
6516 assert_eq!(resp.error().unwrap().code, ErrorCode::AgentNotFound);
6517 std::fs::remove_dir_all(home.root()).ok();
6518 }
6519
6520 #[tokio::test(flavor = "current_thread")]
6522 async fn handle_deliver_dead_worker_returns_unreachable() {
6523 let home = tmp_home("deliver-dead-worker");
6524 state::update_registry(&home.registry_json(), |r| {
6525 r.entries.push(RegistryEntry {
6526 name: "dead-agent".into(),
6527 short_id: "dA".into(),
6528 provider: "codex".into(),
6529 cwd: "/tmp".into(),
6530 project_root: "/tmp".into(),
6531 session_id: None,
6532 claude_short_id: None,
6533 claude_session_uuid: None,
6534 messaging_socket_path: None,
6535 codex_session_id: Some("some-uuid".into()),
6536 gemini_session_id: None,
6537 mcp_channel_id: None,
6538 cc_session_id: None,
6539 host_mode: Some("interactive".into()),
6540 status: AgentStatus::Live,
6541 last_message_at: None,
6542 created_at: "2026-06-07T00:00:00Z".into(),
6543 pid: None,
6544 pid_start_time: None,
6545 log_path: None,
6546 last_reconciled_at: None,
6547 });
6548 })
6549 .unwrap();
6550 let _gate = with_injection_gate_allow("codex");
6552 let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6553 let req = Request::new(
6554 1,
6555 "agent.deliver",
6556 json!({"name": "dead-agent", "body": "hello", "from_name": "sender"}),
6557 );
6558 let resp = handle_deliver(&ctx, &req).await;
6559 drop(_gate);
6560 assert!(!resp.is_err(), "dead-worker must return Ok, not RPC error");
6561 let result = resp.result().unwrap();
6562 assert_eq!(result["delivered"], false);
6563 let result_reason = result["reason"].as_str().unwrap_or("");
6565 assert!(
6566 !result_reason.is_empty(),
6567 "reason must not be empty on worker failure"
6568 );
6569 let events = read_events(&home);
6571 let demoted: Vec<_> = events
6572 .iter()
6573 .filter(|e| e["kind"] == "agent_deliver_demoted")
6574 .collect();
6575 assert!(
6576 !demoted.is_empty(),
6577 "agent_deliver_demoted event must be emitted on worker-unreachable"
6578 );
6579 let event_reason = demoted[0]["reason"].as_str().unwrap_or("");
6580 assert_eq!(
6581 result_reason, event_reason,
6582 "result reason must match event reason (F3 passthrough invariant)"
6583 );
6584 std::fs::remove_dir_all(home.root()).ok();
6585 }
6586
6587 fn short_tmp_home(tag: &str) -> AgentsHome {
6591 use std::sync::atomic::{AtomicU32, Ordering};
6592 static CTR: AtomicU32 = AtomicU32::new(0);
6593 let n = CTR.fetch_add(1, Ordering::Relaxed);
6594 let p = PathBuf::from(format!("/tmp/abid{tag}{}{n}", std::process::id()));
6595 let home = AgentsHome::at(&p);
6596 home.ensure_root().unwrap();
6597 home
6598 }
6599
6600 #[tokio::test(flavor = "current_thread")]
6603 async fn handle_deliver_gated_pass_injects_into_pty() {
6604 let home = short_tmp_home("inj");
6605 let short_id = "injW";
6606
6607 let worker_home = home.root().to_path_buf();
6609 let cfg = WorkerConfig::new(
6610 short_id,
6611 worker_home.clone(),
6612 std::env::temp_dir(),
6613 vec!["cat".to_string()],
6614 );
6615 std::thread::spawn(move || {
6616 let rt = tokio::runtime::Builder::new_current_thread()
6617 .enable_all()
6618 .build()
6619 .unwrap();
6620 rt.block_on(async {
6621 if let Err(e) = run_worker(cfg).await {
6622 eprintln!("WORKER RUN ERROR: {e}");
6623 }
6624 });
6625 });
6626 let sock = home.worker_sock(short_id);
6628 let start = std::time::Instant::now();
6629 while !sock.exists() && start.elapsed() < Duration::from_secs(5) {
6630 tokio::time::sleep(Duration::from_millis(20)).await;
6631 }
6632 assert!(sock.exists(), "worker socket never appeared");
6633
6634 state::update_registry(&home.registry_json(), |r| {
6636 r.entries.push(RegistryEntry {
6637 name: "inject-agent".into(),
6638 short_id: short_id.into(),
6639 provider: "codex".into(),
6640 cwd: "/tmp".into(),
6641 project_root: "/tmp".into(),
6642 session_id: None,
6643 claude_short_id: None,
6644 claude_session_uuid: None,
6645 messaging_socket_path: None,
6646 codex_session_id: Some("test-uuid".into()),
6647 gemini_session_id: None,
6648 mcp_channel_id: None,
6649 cc_session_id: None,
6650 host_mode: Some("interactive".into()),
6651 status: AgentStatus::Live,
6652 last_message_at: None,
6653 created_at: "2026-06-07T00:00:00Z".into(),
6654 pid: None,
6655 pid_start_time: None,
6656 log_path: None,
6657 last_reconciled_at: None,
6658 });
6659 })
6660 .unwrap();
6661
6662 let _gate = with_injection_gate_allow("codex");
6664 let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6665 let req = Request::new(
6666 1,
6667 "agent.deliver",
6668 json!({"name": "inject-agent", "body": "test-payload", "from_name": "alice"}),
6669 );
6670 let resp = handle_deliver(&ctx, &req).await;
6671 drop(_gate);
6672
6673 assert!(
6674 !resp.is_err(),
6675 "inject must return Ok result, got: {:?}",
6676 resp.error()
6677 );
6678 let result = resp.result().unwrap();
6679 assert_eq!(result["delivered"], true);
6680 assert_eq!(result["transport"], "pty");
6681
6682 let events = read_events(&home);
6684 assert!(
6685 events.iter().any(|e| e["kind"] == "agent_deliver_injected"),
6686 "agent_deliver_injected event must be emitted"
6687 );
6688
6689 let mut conn = {
6692 let start = std::time::Instant::now();
6693 loop {
6694 match tokio::net::UnixStream::connect(&sock).await {
6695 Ok(c) => break c,
6696 Err(_) if start.elapsed() < Duration::from_secs(3) => {
6697 tokio::time::sleep(Duration::from_millis(20)).await;
6698 }
6699 Err(e) => panic!("connect failed: {e}"),
6700 }
6701 }
6702 };
6703 let mut seen = false;
6704 for i in 0..50 {
6705 write_request(
6706 &mut conn,
6707 &Request::new(100 + i, "worker.snapshot", json!({})),
6708 )
6709 .await
6710 .unwrap();
6711 let r = crate::protocol::read_response(&mut conn).await.unwrap();
6712 let text = r.result().unwrap()["text"]
6713 .as_str()
6714 .unwrap_or("")
6715 .to_owned();
6716 if text.contains("cross-session-message") && text.contains("alice") {
6717 seen = true;
6718 break;
6719 }
6720 tokio::time::sleep(Duration::from_millis(20)).await;
6721 }
6722 assert!(
6723 seen,
6724 "PTY snapshot must contain the provenance container with from_name"
6725 );
6726
6727 write_request(&mut conn, &Request::new(99, "worker.shutdown", json!({})))
6729 .await
6730 .unwrap();
6731 let _ = crate::protocol::read_response(&mut conn).await;
6732 std::fs::remove_dir_all(home.root()).ok();
6733 }
6734
6735 #[tokio::test(flavor = "current_thread")]
6742 async fn handle_gate_check_then_deliver_e2e_without_thread_local() {
6743 let home = short_tmp_home("gw2d");
6744 let short_id = "gw2W";
6745
6746 let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6748 let gate_req = Request::new(
6749 1,
6750 "agent.gate_check",
6751 json!({"provider": "codex", "record": "manual", "status": "passed", "notes": "e2e test"}),
6752 );
6753 let gate_resp = handle_gate_check(&ctx, &gate_req).await;
6754 assert!(
6755 !gate_resp.is_err(),
6756 "gate_check must succeed: {:?}",
6757 gate_resp
6758 );
6759 assert_eq!(gate_resp.result().unwrap()["status"], "passed");
6760
6761 assert_eq!(
6763 injection_gate_with_home("codex", &home),
6764 InjectionGate::Passed,
6765 "gate must read back as Passed after handle_gate_check"
6766 );
6767
6768 let worker_home = home.root().to_path_buf();
6770 let cfg = WorkerConfig::new(
6771 short_id,
6772 worker_home.clone(),
6773 std::env::temp_dir(),
6774 vec!["cat".to_string()],
6775 );
6776 std::thread::spawn(move || {
6777 let rt = tokio::runtime::Builder::new_current_thread()
6778 .enable_all()
6779 .build()
6780 .unwrap();
6781 rt.block_on(async {
6782 if let Err(e) = run_worker(cfg).await {
6783 eprintln!("WORKER RUN ERROR: {e}");
6784 }
6785 });
6786 });
6787 let sock = home.worker_sock(short_id);
6788 let start = std::time::Instant::now();
6789 while !sock.exists() && start.elapsed() < Duration::from_secs(5) {
6790 tokio::time::sleep(Duration::from_millis(20)).await;
6791 }
6792 assert!(sock.exists(), "worker socket never appeared");
6793
6794 state::update_registry(&home.registry_json(), |r| {
6796 r.entries.push(RegistryEntry {
6797 name: "gated-agent".into(),
6798 short_id: short_id.into(),
6799 provider: "codex".into(),
6800 cwd: "/tmp".into(),
6801 project_root: "/tmp".into(),
6802 session_id: None,
6803 claude_short_id: None,
6804 claude_session_uuid: None,
6805 messaging_socket_path: None,
6806 codex_session_id: Some("test-uuid".into()),
6807 gemini_session_id: None,
6808 mcp_channel_id: None,
6809 cc_session_id: None,
6810 host_mode: Some("interactive".into()),
6811 status: AgentStatus::Live,
6812 last_message_at: None,
6813 created_at: "2026-06-07T00:00:00Z".into(),
6814 pid: None,
6815 pid_start_time: None,
6816 log_path: None,
6817 last_reconciled_at: None,
6818 });
6819 })
6820 .unwrap();
6821
6822 let deliver_req = Request::new(
6824 2,
6825 "agent.deliver",
6826 json!({"name": "gated-agent", "body": "gate-e2e-payload", "from_name": "bob"}),
6827 );
6828 let resp = handle_deliver(&ctx, &deliver_req).await;
6829 assert!(!resp.is_err(), "deliver must return Ok");
6830 let result = resp.result().unwrap();
6831 assert_eq!(
6832 result["delivered"], true,
6833 "file-backed gate must allow delivery"
6834 );
6835 assert_eq!(result["transport"], "pty");
6836
6837 let mut conn = {
6839 let start = std::time::Instant::now();
6840 loop {
6841 match tokio::net::UnixStream::connect(&sock).await {
6842 Ok(c) => break c,
6843 Err(_) if start.elapsed() < Duration::from_secs(3) => {
6844 tokio::time::sleep(Duration::from_millis(20)).await;
6845 }
6846 Err(e) => panic!("connect failed: {e}"),
6847 }
6848 }
6849 };
6850 let mut seen = false;
6851 for i in 0..50 {
6852 write_request(
6853 &mut conn,
6854 &Request::new(100 + i, "worker.snapshot", json!({})),
6855 )
6856 .await
6857 .unwrap();
6858 let r = crate::protocol::read_response(&mut conn).await.unwrap();
6859 let text = r.result().unwrap()["text"]
6860 .as_str()
6861 .unwrap_or("")
6862 .to_owned();
6863 if text.contains("cross-session-message") && text.contains("bob") {
6864 seen = true;
6865 break;
6866 }
6867 tokio::time::sleep(Duration::from_millis(20)).await;
6868 }
6869 assert!(
6870 seen,
6871 "PTY snapshot must contain the provenance container (gate e2e)"
6872 );
6873
6874 write_request(&mut conn, &Request::new(99, "worker.shutdown", json!({})))
6875 .await
6876 .unwrap();
6877 let _ = crate::protocol::read_response(&mut conn).await;
6878 std::fs::remove_dir_all(home.root()).ok();
6879 }
6880
6881 #[tokio::test(flavor = "current_thread")]
6883 async fn handle_deliver_multiline_body_single_paste_block() {
6884 let home = short_tmp_home("ml");
6885 let short_id = "mlW";
6886
6887 let worker_home = home.root().to_path_buf();
6888 let cfg = WorkerConfig::new(
6889 short_id,
6890 worker_home.clone(),
6891 std::env::temp_dir(),
6892 vec!["cat".to_string()],
6893 );
6894 std::thread::spawn(move || {
6895 let rt = tokio::runtime::Builder::new_current_thread()
6896 .enable_all()
6897 .build()
6898 .unwrap();
6899 rt.block_on(async {
6900 if let Err(e) = run_worker(cfg).await {
6901 eprintln!("WORKER RUN ERROR: {e}");
6902 }
6903 });
6904 });
6905 let sock = home.worker_sock(short_id);
6906 let start = std::time::Instant::now();
6907 while !sock.exists() && start.elapsed() < Duration::from_secs(5) {
6908 tokio::time::sleep(Duration::from_millis(20)).await;
6909 }
6910 assert!(sock.exists(), "worker socket never appeared");
6911
6912 state::update_registry(&home.registry_json(), |r| {
6913 r.entries.push(RegistryEntry {
6914 name: "ml-agent".into(),
6915 short_id: short_id.into(),
6916 provider: "gemini".into(),
6917 cwd: "/tmp".into(),
6918 project_root: "/tmp".into(),
6919 session_id: None,
6920 claude_short_id: None,
6921 claude_session_uuid: None,
6922 messaging_socket_path: None,
6923 codex_session_id: None,
6924 gemini_session_id: Some("g-uuid".into()),
6925 mcp_channel_id: None,
6926 cc_session_id: None,
6927 host_mode: Some("interactive".into()),
6928 status: AgentStatus::Live,
6929 last_message_at: None,
6930 created_at: "2026-06-07T00:00:00Z".into(),
6931 pid: None,
6932 pid_start_time: None,
6933 log_path: None,
6934 last_reconciled_at: None,
6935 });
6936 })
6937 .unwrap();
6938
6939 let _gate = with_injection_gate_allow("gemini");
6940 let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6941 let req = Request::new(
6942 1,
6943 "agent.deliver",
6944 json!({"name": "ml-agent", "body": "line one\nline two\nline three\n\n", "from_name": "bob"}),
6945 );
6946 let resp = handle_deliver(&ctx, &req).await;
6947 drop(_gate);
6948
6949 assert!(!resp.is_err(), "multiline deliver must succeed");
6950 let result = resp.result().unwrap();
6951 assert_eq!(result["delivered"], true);
6952
6953 let mut conn = {
6956 let start = std::time::Instant::now();
6957 loop {
6958 match tokio::net::UnixStream::connect(&sock).await {
6959 Ok(c) => break c,
6960 Err(_) if start.elapsed() < Duration::from_secs(3) => {
6961 tokio::time::sleep(Duration::from_millis(20)).await;
6962 }
6963 Err(e) => panic!("connect failed: {e}"),
6964 }
6965 }
6966 };
6967 let mut cursor = 0u64;
6969 let mut raw: Vec<u8> = Vec::new();
6970 for i in 0..50 {
6971 write_request(
6972 &mut conn,
6973 &Request::new(100 + i, "worker.read_since", json!({"cursor": cursor})),
6974 )
6975 .await
6976 .unwrap();
6977 let r = crate::protocol::read_response(&mut conn).await.unwrap();
6978 let res = r.result().unwrap();
6979 cursor = res["next_offset"].as_u64().unwrap();
6980 let chunk = base64::engine::general_purpose::STANDARD
6981 .decode(res["bytes_b64"].as_str().unwrap())
6982 .unwrap();
6983 raw.extend_from_slice(&chunk);
6984 let paste_end = b"\x1b[201~";
6986 let count = raw
6987 .windows(paste_end.len())
6988 .filter(|w| *w == paste_end)
6989 .count();
6990 if count >= 1 {
6991 assert_eq!(
6992 count, 1,
6993 "must be exactly one bracketed-paste block, found {count}"
6994 );
6995 break;
6996 }
6997 if i == 49 {
6998 panic!("bracketed-paste end marker never appeared in PTY output");
6999 }
7000 tokio::time::sleep(Duration::from_millis(20)).await;
7001 }
7002
7003 write_request(&mut conn, &Request::new(99, "worker.shutdown", json!({})))
7004 .await
7005 .unwrap();
7006 let _ = crate::protocol::read_response(&mut conn).await;
7007 std::fs::remove_dir_all(home.root()).ok();
7008 }
7009
7010 #[tokio::test(flavor = "current_thread")]
7015 async fn handle_deliver_err_reason_matches_event_reason() {
7016 let home = tmp_home("deliver-err-passthrough");
7019 let short_id = "errPass01";
7020 state::update_registry(&home.registry_json(), |r| {
7021 r.entries.push(RegistryEntry {
7022 name: "err-agent".into(),
7023 short_id: short_id.into(),
7024 provider: "codex".into(),
7025 cwd: "/tmp".into(),
7026 project_root: "/tmp".into(),
7027 session_id: None,
7028 claude_short_id: None,
7029 claude_session_uuid: None,
7030 messaging_socket_path: None,
7031 codex_session_id: Some("some-uuid".into()),
7032 gemini_session_id: None,
7033 mcp_channel_id: None,
7034 cc_session_id: None,
7035 host_mode: Some("interactive".into()),
7036 status: AgentStatus::Live,
7037 last_message_at: None,
7038 created_at: "2026-06-07T00:00:00Z".into(),
7039 pid: None,
7040 pid_start_time: None,
7041 log_path: None,
7042 last_reconciled_at: None,
7043 });
7044 })
7045 .unwrap();
7046 let _guard = with_injection_gate_allow("codex");
7048
7049 let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
7050 let req = Request::new(
7051 1,
7052 "agent.deliver",
7053 json!({"name": "err-agent", "body": "hello", "from_name": "sender"}),
7054 );
7055 let resp = handle_deliver(&ctx, &req).await;
7056 assert!(
7057 !resp.is_err(),
7058 "deliver must return Ok even on inject failure"
7059 );
7060
7061 let result = resp.result().unwrap();
7062 assert_eq!(
7063 result["delivered"], false,
7064 "inject failure must produce delivered=false"
7065 );
7066
7067 let result_reason = result["reason"].as_str().unwrap_or("").to_string();
7072
7073 let events = read_events(&home);
7075 let demoted: Vec<_> = events
7076 .iter()
7077 .filter(|e| e["kind"] == "agent_deliver_demoted")
7078 .collect();
7079 assert!(
7080 !demoted.is_empty(),
7081 "agent_deliver_demoted must be emitted on inject failure"
7082 );
7083 let event_reason = demoted[0]["reason"].as_str().unwrap_or("");
7084
7085 assert_eq!(
7087 result_reason, event_reason,
7088 "result reason {result_reason:?} must match event reason {event_reason:?}"
7089 );
7090
7091 std::fs::remove_dir_all(home.root()).ok();
7092 }
7093
7094 #[test]
7099 fn xml_attr_escape_single_quote() {
7100 assert_eq!(xml_attr_escape("O'Brien"), "O'Brien");
7101 assert_eq!(
7103 xml_attr_escape("a&b<c>\"d'e"),
7104 "a&b<c>"d'e"
7105 );
7106 assert_eq!(xml_attr_escape("hello"), "hello");
7108 }
7109
7110 #[test]
7113 fn xml_attr_escape_used_in_container_from_name() {
7114 let escaped = xml_attr_escape("O'Brien");
7117 let container_prefix = format!("<cross-session-message from-name=\"{escaped}\">");
7118 assert!(
7119 container_prefix.contains("'"),
7120 "container must use ' for single-quote: {container_prefix:?}"
7121 );
7122 }
7123
7124 #[test]
7129 fn injection_gate_unknown_version_returns_unverified() {
7130 let home = tmp_home("gate-v2-unverified");
7131 let gate_path = home.injection_gate_json();
7132 std::fs::write(
7134 &gate_path,
7135 r#"{"v":2,"providers":{"codex":{"status":"passed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
7136 )
7137 .unwrap();
7138 assert_eq!(
7139 injection_gate_with_home("codex", &home),
7140 InjectionGate::Unverified,
7141 "unknown gate file version (v:2) must return Unverified"
7142 );
7143 std::fs::remove_dir_all(home.root()).ok();
7144 }
7145
7146 #[test]
7149 fn injection_gate_v1_passed_still_returns_passed() {
7150 let home = tmp_home("gate-v1-still-passed");
7151 let gate_path = home.injection_gate_json();
7152 std::fs::write(
7153 &gate_path,
7154 r#"{"v":1,"providers":{"codex":{"status":"passed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
7155 )
7156 .unwrap();
7157 assert_eq!(
7158 injection_gate_with_home("codex", &home),
7159 InjectionGate::Passed,
7160 "v:1 gate with status=passed must return Passed"
7161 );
7162 std::fs::remove_dir_all(home.root()).ok();
7163 }
7164}