use fno_agents::client::call;
use fno_agents::paths::AgentsHome;
use fno_agents::protocol::Request;
use fno_agents::state;
use serde_json::json;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
const DAEMON_BIN: &str = env!("CARGO_BIN_EXE_fno-agents-daemon");
const WORKER_BIN: &str = env!("CARGO_BIN_EXE_fno-agents-worker");
const CLIENT_BIN: &str = env!("CARGO_BIN_EXE_fno-agents");
fn short_home() -> AgentsHome {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
AgentsHome::at(PathBuf::from(format!(
"/tmp/abie{}_{}",
std::process::id(),
n
)))
}
fn pid_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
fn start_daemon(home: &AgentsHome) -> std::process::Child {
start_daemon_env(home, &[])
}
fn start_daemon_env(home: &AgentsHome, extra: &[(&str, &str)]) -> std::process::Child {
let mut cmd = Command::new(DAEMON_BIN);
cmd.env("FNO_AGENTS_HOME", home.root())
.env("FNO_AGENTS_WORKER_BIN", WORKER_BIN)
.env("FNO_AGENTS_IDLE_EXIT_SECS", "3600");
for (k, v) in extra {
cmd.env(k, v);
}
let child = cmd.spawn().expect("daemon spawns");
wait_for(&home.supervisor_sock(), Duration::from_secs(10));
child
}
fn wait_for(path: &Path, budget: Duration) {
let start = Instant::now();
while !path.exists() && start.elapsed() < budget {
std::thread::sleep(Duration::from_millis(25));
}
assert!(path.exists(), "path never appeared: {}", path.display());
}
#[tokio::test]
async fn cold_start_reconciles_stale_ask_row_to_exited() {
let home = short_home();
home.ensure_root().unwrap();
let daemon_bin = PathBuf::from(DAEMON_BIN);
state::update_registry(&home.registry_json(), |r| {
r.entries.push(state::RegistryEntry {
name: "stale-ask".into(),
short_id: String::new(),
provider: "codex".into(),
harness: None,
harness_session_id: None,
cwd: "/tmp".into(),
project_root: String::new(),
session_id: None,
legacy_claude_short_id: None,
claude_session_uuid: None,
messaging_socket_path: None,
codex_session_id: Some("resume-uuid-xyz".into()),
gemini_session_id: None,
mcp_channel_id: None,
host_mode: None,
cc_session_id: None,
status: fno_agents::AgentStatus::Live,
last_message_at: None,
created_at: "2026-05-29T00:00:00Z".into(),
pid: None,
pid_start_time: None,
log_path: None,
last_reconciled_at: None,
inside_leg: None,
exited_at: None,
mux: None,
screen_state: None,
});
})
.unwrap();
let mut daemon = start_daemon(&home);
let resp = call(
&home,
&daemon_bin,
&Request::new(1, "agent.list", json!({"all": true})),
)
.await
.expect("list call");
assert!(!resp.is_err(), "list failed: {:?}", resp.error());
let agents = resp.result().unwrap()["agents"].as_array().unwrap().clone();
let row = agents
.iter()
.find(|a| a["name"] == "stale-ask")
.expect("stale-ask row present");
assert_eq!(
row["status"], "exited",
"a finished ask must settle to exited on cold-start reconcile"
);
assert_eq!(row["session_id"], "resume-uuid-xyz");
assert!(row["pid"].is_null(), "ask row must have null pid: {row}");
let reg = state::load_registry(&home.registry_json()).unwrap();
let entry = reg.find("stale-ask").unwrap();
assert_eq!(entry.status, fno_agents::AgentStatus::Exited);
assert!(
entry.last_reconciled_at.is_some(),
"startup sweep must stamp last_reconciled_at (CHECKED freshens, AC1-UI)"
);
assert_eq!(entry.pid, None, "ask row never carries a pid");
let events = std::fs::read_to_string(home.events_jsonl()).unwrap_or_default();
assert!(
events.contains("startup_reconcile_done"),
"startup_reconcile_done event not emitted"
);
unsafe {
libc::kill(daemon.id() as libc::pid_t, libc::SIGTERM);
}
let _ = daemon.wait();
std::fs::remove_dir_all(home.root()).ok();
}
#[tokio::test]
async fn startup_reconcile_failure_degrades_to_serving() {
let home = short_home();
home.ensure_root().unwrap();
let daemon_bin = PathBuf::from(DAEMON_BIN);
seed_codex_source(
&home,
"kept-live",
"uuid-fr-1",
fno_agents::AgentStatus::Live,
);
let mut daemon = start_daemon_env(&home, &[("FNO_AGENTS_FAIL_STARTUP_RECONCILE", "1")]);
let resp = call(
&home,
&daemon_bin,
&Request::new(1, "agent.list", json!({"all": true})),
)
.await
.expect("list served despite startup-sweep failure");
assert!(!resp.is_err(), "list failed: {:?}", resp.error());
let agents = resp.result().unwrap()["agents"].as_array().unwrap().clone();
let row = agents
.iter()
.find(|a| a["name"] == "kept-live")
.expect("row present");
assert_eq!(row["status"], "live", "failed sweep must not mutate status");
let events = std::fs::read_to_string(home.events_jsonl()).unwrap_or_default();
assert!(
events.contains("startup_reconcile_failed"),
"a failed startup sweep must emit startup_reconcile_failed"
);
unsafe {
libc::kill(daemon.id() as libc::pid_t, libc::SIGTERM);
}
let _ = daemon.wait();
std::fs::remove_dir_all(home.root()).ok();
}
#[tokio::test]
async fn status_client_exits_13_when_daemon_down() {
const CLIENT_BIN: &str = env!("CARGO_BIN_EXE_fno-agents");
let home = short_home();
home.ensure_root().unwrap();
let out = Command::new(CLIENT_BIN)
.arg("status")
.env("FNO_AGENTS_HOME", home.root())
.output()
.expect("client runs");
assert_eq!(
out.status.code(),
Some(13),
"status with no daemon must exit 13; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!home.supervisor_sock().exists(),
"status must not start a daemon when one is down"
);
std::fs::remove_dir_all(home.root()).ok();
}
fn seed_codex_source(home: &AgentsHome, name: &str, uuid: &str, status: fno_agents::AgentStatus) {
state::update_registry(&home.registry_json(), |r| {
r.entries.push(fno_agents::state::RegistryEntry {
name: name.into(),
short_id: String::new(),
provider: "codex".into(),
harness: None,
harness_session_id: None,
cwd: "/tmp".into(),
project_root: String::new(),
session_id: None,
legacy_claude_short_id: None,
claude_session_uuid: None,
messaging_socket_path: None,
codex_session_id: Some(uuid.into()),
gemini_session_id: None,
mcp_channel_id: None,
host_mode: None,
cc_session_id: None,
status,
last_message_at: None,
created_at: "2026-05-29T00:00:00Z".into(),
pid: None,
pid_start_time: None,
log_path: None,
last_reconciled_at: None,
inside_leg: None,
exited_at: None,
mux: None,
screen_state: None,
});
})
.unwrap();
}
#[tokio::test]
async fn restart_when_down_starts_fresh() {
let home = short_home();
home.ensure_root().unwrap();
let daemon_bin = PathBuf::from(DAEMON_BIN);
std::env::set_var("FNO_AGENTS_WORKER_BIN", WORKER_BIN);
let outcome = fno_agents::client::restart_daemon(&home, &daemon_bin)
.await
.expect("restart-when-down succeeds");
assert_eq!(outcome.old_pid, None, "nothing was running");
assert!(pid_alive(outcome.new_pid), "fresh daemon is alive");
unsafe {
libc::kill(outcome.new_pid as libc::pid_t, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(200));
std::fs::remove_dir_all(home.root()).ok();
}
#[tokio::test]
async fn drift_warned_on_list_stderr_only() {
let home = short_home();
home.ensure_root().unwrap();
let dcopy = PathBuf::from(format!(
"/tmp/abidcopy_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::copy(DAEMON_BIN, &dcopy).expect("copy daemon bin");
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dcopy, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut daemon = Command::new(&dcopy)
.env("FNO_AGENTS_HOME", home.root())
.env("FNO_AGENTS_WORKER_BIN", WORKER_BIN)
.env("FNO_AGENTS_IDLE_EXIT_SECS", "3600")
.spawn()
.expect("daemon spawns from copy");
wait_for(&home.supervisor_sock(), Duration::from_secs(10));
let original_size = std::fs::metadata(&dcopy).unwrap().len();
let status =
fno_agents::client::call_if_running(&home, &Request::new(7, "agent.status", json!({})))
.await
.expect("status before replace");
assert_eq!(
status.result().unwrap()["daemon"]["exe_size"].as_u64(),
Some(original_size),
"daemon recorded the original copy's fingerprint before replace"
);
std::fs::remove_file(&dcopy).unwrap();
std::fs::write(&dcopy, b"stale-stub").unwrap();
let out = Command::new(CLIENT_BIN)
.args(["list", "--json"])
.env("FNO_AGENTS_HOME", home.root())
.env("FNO_AGENTS_DAEMON_BIN", &dcopy)
.env("FNO_AGENTS_WORKER_BIN", WORKER_BIN)
.output()
.expect("client list runs");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
serde_json::from_str::<serde_json::Value>(stdout.trim())
.expect("list --json stdout is valid JSON");
assert!(
!stdout.contains("restart") && !stdout.contains("older build"),
"warning leaked into stdout: {stdout}"
);
assert!(
stderr.contains("fno agents restart") && stderr.contains("build"),
"expected drift warning on stderr, got: {stderr}"
);
unsafe {
libc::kill(daemon.id() as libc::pid_t, libc::SIGTERM);
}
let _ = daemon.wait();
std::fs::remove_file(&dcopy).ok();
std::fs::remove_dir_all(home.root()).ok();
}