Skip to main content

fno_agents/
client.rs

1//! Client side of the daemon protocol (Wave 3): lazy-start the daemon, connect,
2//! and forward one request. Kept in the library so it is exercised by the
3//! integration tests without shelling out to the compiled binary.
4
5use crate::drift::{classify, DriftState, ExeFingerprint};
6use crate::paths::AgentsHome;
7use crate::protocol::{read_response, write_request, ProtocolError, Request, Response};
8use serde_json::{json, Value};
9use std::path::{Path, PathBuf};
10use std::time::{Duration, Instant};
11use tokio::net::UnixStream;
12
13#[derive(Debug, thiserror::Error)]
14pub enum ClientError {
15    #[error("protocol: {0}")]
16    Protocol(#[from] ProtocolError),
17    #[error("io: {0}")]
18    Io(#[from] std::io::Error),
19    #[error("daemon did not come up within {0:?}")]
20    DaemonStartTimeout(Duration),
21    #[error(
22        "daemon binary not found: {0} - the fno-agents triad (client/daemon/worker) \
23         is split here. Run `fno update` to redeploy the pair, or set \
24         FNO_AGENTS_DAEMON_BIN to a coherent same-build daemon."
25    )]
26    DaemonBinMissing(PathBuf),
27    #[error("daemon is not running")]
28    DaemonNotRunning,
29}
30
31/// Resolve the daemon binary: `FNO_AGENTS_DAEMON_BIN` or a sibling of the
32/// current executable named `fno-agents-daemon`.
33pub fn resolve_daemon_bin() -> PathBuf {
34    if let Some(v) = std::env::var_os("FNO_AGENTS_DAEMON_BIN") {
35        return PathBuf::from(v);
36    }
37    std::env::current_exe()
38        .ok()
39        .and_then(|p| p.parent().map(|d| d.join("fno-agents-daemon")))
40        .unwrap_or_else(|| PathBuf::from("fno-agents-daemon"))
41}
42
43/// Ensure a daemon is serving on `home`'s socket, lazy-starting one if not.
44/// Returns once a connect succeeds. The lazy-start race is resolved daemon-side
45/// (socket-bind exclusivity); a redundant fork simply loses and exits.
46pub async fn ensure_daemon(
47    home: &AgentsHome,
48    daemon_bin: &std::path::Path,
49) -> Result<(), ClientError> {
50    let sock = home.supervisor_sock();
51    if UnixStream::connect(&sock).await.is_ok() {
52        return Ok(());
53    }
54    if !daemon_bin.exists() {
55        return Err(ClientError::DaemonBinMissing(daemon_bin.to_path_buf()));
56    }
57
58    eprintln!("(lazy-starting daemon)");
59    // Detached, own process group: the daemon must outlive this client.
60    let mut cmd = tokio::process::Command::new(daemon_bin);
61    cmd.process_group(0);
62    cmd.env("FNO_AGENTS_HOME", home.root());
63    // Inherit the worker/daemon bin overrides so a test harness's binaries are used.
64    if let Some(v) = std::env::var_os("FNO_AGENTS_WORKER_BIN") {
65        cmd.env("FNO_AGENTS_WORKER_BIN", v);
66    }
67    let child = cmd.spawn()?;
68    drop(child); // do not await; it is detached
69
70    let start = Instant::now();
71    let budget = Duration::from_secs(10);
72    while start.elapsed() < budget {
73        if UnixStream::connect(&sock).await.is_ok() {
74            return Ok(());
75        }
76        tokio::time::sleep(Duration::from_millis(25)).await;
77    }
78    Err(ClientError::DaemonStartTimeout(budget))
79}
80
81/// Send one request to the daemon (lazy-starting it first) and return the
82/// response.
83pub async fn call(
84    home: &AgentsHome,
85    daemon_bin: &std::path::Path,
86    req: &Request,
87) -> Result<Response, ClientError> {
88    ensure_daemon(home, daemon_bin).await?;
89    let mut conn = UnixStream::connect(home.supervisor_sock()).await?;
90    write_request(&mut conn, req).await?;
91    Ok(read_response(&mut conn).await?)
92}
93
94/// Send one request to an ALREADY-RUNNING daemon, WITHOUT lazy-starting one.
95/// `status` uses this so it reports a down daemon (exit 13) rather than booting
96/// one just to describe it as up (AC10-ERR).
97pub async fn call_if_running(home: &AgentsHome, req: &Request) -> Result<Response, ClientError> {
98    let mut conn = match UnixStream::connect(home.supervisor_sock()).await {
99        Ok(c) => c,
100        // Only "nothing is listening" means the daemon is down. A permission
101        // error or a non-socket at the path is a real fault that must surface
102        // rather than masquerade as exit-13 "daemon down" (Codex P2).
103        Err(e)
104            if matches!(
105                e.kind(),
106                std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
107            ) =>
108        {
109            return Err(ClientError::DaemonNotRunning)
110        }
111        Err(e) => return Err(ClientError::Io(e)),
112    };
113    write_request(&mut conn, req).await?;
114    Ok(read_response(&mut conn).await?)
115}
116
117// ---------------------------------------------------------------------------
118// Binary-version drift detection + restart (ab-1891cdff).
119// ---------------------------------------------------------------------------
120
121/// Parse the daemon's reported running-exe fingerprint out of an `agent.status`
122/// result. `None` when the daemon reported no fingerprint (a pre-drift daemon,
123/// or a startup stat failure) -> the caller reads `Unknown`.
124fn running_fingerprint(status: &Value) -> Option<ExeFingerprint> {
125    let d = status.get("daemon")?;
126    let path = d.get("exe_path")?.as_str()?;
127    let mtime_nanos = d.get("exe_mtime")?.as_i64()?;
128    let size = d.get("exe_size")?.as_u64()?;
129    Some(ExeFingerprint {
130        path: PathBuf::from(path),
131        mtime_nanos,
132        size,
133    })
134}
135
136/// Classify drift from an already-fetched `agent.status` result: compare the
137/// daemon's reported running fingerprint to a fresh stat of the binary this
138/// client would launch now ([`resolve_daemon_bin`]). Synchronous and never
139/// lazy-starts. Used by `status`, which already holds the status payload, to
140/// avoid a second RPC.
141pub fn drift_from_status(status: &Value) -> DriftState {
142    let running = running_fingerprint(status);
143    let on_disk = ExeFingerprint::of(&resolve_daemon_bin());
144    classify(running.as_ref(), on_disk.as_ref())
145}
146
147/// Probe an already-running daemon for binary drift. Issues one `agent.status`
148/// RPC and never lazy-starts: a down daemon is [`DriftState::DaemonDown`] (no
149/// warning), not a reason to boot one. Any transport/parse error fails safe to
150/// [`DriftState::Unknown`]. Used by `list`, which does not otherwise fetch the
151/// daemon status.
152pub async fn check_daemon_drift(home: &AgentsHome) -> DriftState {
153    let req = Request::new(1, "agent.status", json!({}));
154    match call_if_running(home, &req).await {
155        Ok(resp) => match resp.result() {
156            Some(result) => drift_from_status(result),
157            None => DriftState::Unknown,
158        },
159        Err(ClientError::DaemonNotRunning) => DriftState::DaemonDown,
160        Err(_) => DriftState::Unknown,
161    }
162}
163
164/// The result of a successful daemon restart.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct RestartOutcome {
167    /// The pid of the daemon that was replaced, or `None` if none was running
168    /// (the restart degraded to a fresh start).
169    pub old_pid: Option<u32>,
170    /// The pid of the freshly-started daemon now serving.
171    pub new_pid: u32,
172}
173
174/// Why a restart could not complete. Each variant names the pid where relevant
175/// so the `restart` verb can fail *loud* (Locked Decision: a failed restart must
176/// never read as success).
177#[derive(Debug, thiserror::Error)]
178pub enum RestartError {
179    #[error("SIGTERM to daemon pid {pid} failed: {reason}")]
180    SigtermFailed { pid: u32, reason: String },
181    #[error("daemon pid {pid} did not exit after SIGTERM within {secs}s; check it manually")]
182    DidNotExit { pid: u32, secs: u64 },
183    #[error("daemon status response missing daemon.pid")]
184    StatusMissingPid,
185    #[error(transparent)]
186    Client(#[from] ClientError),
187}
188
189/// Bounded wait for the old daemon to release the supervisor socket. On a clean
190/// SIGTERM the daemon unlinks its own socket, so a failed connect means cleared.
191const RESTART_SOCKET_TIMEOUT: Duration = Duration::from_secs(5);
192
193enum SigtermResult {
194    /// SIGTERM delivered.
195    Sent,
196    /// The pid was already gone (ESRCH) -- treat as a successful "it exited".
197    AlreadyGone,
198    /// The signal could not be delivered (e.g. EPERM); a loud, named failure.
199    Failed(String),
200}
201
202/// Send SIGTERM to `pid`, mapping the syscall result to a typed verdict. ESRCH
203/// (no such process) is `AlreadyGone`, not a failure -- the daemon exiting on
204/// its own between the status read and here is the success we wanted. Any other
205/// errno is a `Failed` the caller surfaces loudly.
206fn send_sigterm(pid: u32) -> SigtermResult {
207    // SAFETY: kill(pid, SIGTERM) has no memory effects; errno is read only on the
208    // error return.
209    let rc = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
210    if rc == 0 {
211        return SigtermResult::Sent;
212    }
213    let err = std::io::Error::last_os_error();
214    match err.raw_os_error() {
215        Some(e) if e == libc::ESRCH => SigtermResult::AlreadyGone,
216        _ => SigtermResult::Failed(err.to_string()),
217    }
218}
219
220/// Read the serving daemon's pid via `agent.status` (already-running path).
221async fn read_daemon_pid(home: &AgentsHome) -> Result<u32, RestartError> {
222    let req = Request::new(1, "agent.status", json!({}));
223    let resp = call_if_running(home, &req).await?;
224    resp.result()
225        .and_then(|r| r.get("daemon"))
226        .and_then(|d| d.get("pid"))
227        .and_then(Value::as_u64)
228        .map(|p| p as u32)
229        .ok_or(RestartError::StatusMissingPid)
230}
231
232/// True once nothing is listening on the supervisor socket (the old daemon
233/// released it), bounded by `RESTART_SOCKET_TIMEOUT`.
234async fn await_socket_clear(home: &AgentsHome) -> bool {
235    let sock = home.supervisor_sock();
236    let start = Instant::now();
237    while start.elapsed() < RESTART_SOCKET_TIMEOUT {
238        if UnixStream::connect(&sock).await.is_err() {
239            return true;
240        }
241        tokio::time::sleep(Duration::from_millis(25)).await;
242    }
243    UnixStream::connect(&sock).await.is_err()
244}
245
246/// Lazy-start a fresh daemon and return its pid. Shared by every restart exit.
247async fn start_fresh(home: &AgentsHome, daemon_bin: &Path) -> Result<u32, RestartError> {
248    ensure_daemon(home, daemon_bin).await?;
249    read_daemon_pid(home).await
250}
251
252/// Restart the daemon: SIGTERM the running one (graceful drain; PTY workers
253/// survive -- Outcome B -- and are re-adopted by the fresh daemon's startup
254/// recovery), wait for the socket to clear, then lazy-start a fresh daemon built
255/// from the current binary. Reports `OLD -> NEW`.
256///
257/// - No daemon running -> fresh start, `old_pid = None` (idempotent).
258/// - A SIGTERM failure (e.g. EPERM) or a daemon that will not exit within the
259///   bound is a loud [`RestartError`], never a silent "restarted".
260/// - The SIGTERM targets the daemon pid ONLY; it is pid-reuse-guarded by the
261///   daemon's own start-time check before signalling, so a recycled pid is never
262///   hit.
263pub async fn restart_daemon(
264    home: &AgentsHome,
265    daemon_bin: &Path,
266) -> Result<RestartOutcome, RestartError> {
267    // Probe the running daemon for its pid + start time. A down daemon just
268    // starts fresh.
269    let status = Request::new(1, "agent.status", json!({}));
270    let result = match call_if_running(home, &status).await {
271        Ok(resp) => resp.result().cloned(),
272        Err(ClientError::DaemonNotRunning) => None,
273        Err(e) => return Err(RestartError::Client(e)),
274    };
275
276    let Some(result) = result else {
277        let new_pid = start_fresh(home, daemon_bin).await?;
278        return Ok(RestartOutcome {
279            old_pid: None,
280            new_pid,
281        });
282    };
283
284    let daemon = result.get("daemon");
285    let old_pid = daemon
286        .and_then(|d| d.get("pid"))
287        .and_then(Value::as_u64)
288        .ok_or(RestartError::StatusMissingPid)? as u32;
289    let recorded_start = daemon
290        .and_then(|d| d.get("pid_start_time"))
291        .and_then(Value::as_u64);
292
293    // pid-reuse guard: only SIGTERM a pid that is still THIS daemon. If it is
294    // already gone (or its pid was recycled), don't signal a stranger -- just
295    // start fresh. We do NOT unlink the socket here: a stale socket file is
296    // cleaned up race-free by the daemon's own bind_supervisor_socket
297    // (connect-probe then remove + bind), and unlinking from the client could
298    // delete a socket a concurrent client just rebound.
299    if !crate::daemon::pid_is_ours(old_pid, recorded_start) {
300        let new_pid = start_fresh(home, daemon_bin).await?;
301        return Ok(RestartOutcome {
302            old_pid: Some(old_pid),
303            new_pid,
304        });
305    }
306
307    match send_sigterm(old_pid) {
308        SigtermResult::Sent | SigtermResult::AlreadyGone => {}
309        SigtermResult::Failed(reason) => {
310            return Err(RestartError::SigtermFailed {
311                pid: old_pid,
312                reason,
313            })
314        }
315    }
316
317    if !await_socket_clear(home).await {
318        return Err(RestartError::DidNotExit {
319            pid: old_pid,
320            secs: RESTART_SOCKET_TIMEOUT.as_secs(),
321        });
322    }
323    // Do NOT unlink the socket here (codex P2, PR #472): once the clear window
324    // passes, a concurrent client could already have lazy-started and bound a
325    // fresh daemon on this path; an unconditional remove would unlink that live
326    // daemon's socket, leaving it unreachable while we start a second one. A
327    // genuinely stale socket file (e.g. from a SIGKILL'd predecessor) is removed
328    // race-free by the next daemon's bind_supervisor_socket. ensure_daemon below
329    // connects first, so if a fresh daemon is already serving we adopt it rather
330    // than starting a duplicate.
331    let new_pid = start_fresh(home, daemon_bin).await?;
332    Ok(RestartOutcome {
333        old_pid: Some(old_pid),
334        new_pid,
335    })
336}
337
338#[cfg(test)]
339mod drift_restart_tests {
340    use super::*;
341
342    #[test]
343    fn drift_from_status_unknown_when_no_fingerprint() {
344        // A status payload without exe_* fields (older daemon) -> Unknown, never
345        // a false Drifted. (resolve_daemon_bin is irrelevant: a None running
346        // fingerprint short-circuits to Unknown regardless of the on-disk side.)
347        let status = json!({"daemon": {"pid": 1, "state": "serving"}});
348        assert_eq!(drift_from_status(&status), DriftState::Unknown);
349    }
350
351    #[test]
352    fn running_fingerprint_parses_and_rejects() {
353        // The status-payload parser is the glue between the daemon's reported
354        // fingerprint and the pure `classify`; the classification matrix itself is
355        // tested in `crate::drift`. No env mutation, so this is parallel-safe.
356        let status = json!({"daemon": {
357            "exe_path": "/opt/fno-agents-daemon",
358            "exe_mtime": 1_700_000_000_000_000_000_i64,
359            "exe_size": 4242_u64,
360        }});
361        let fp = running_fingerprint(&status).expect("parses a full fingerprint");
362        assert_eq!(fp.path, PathBuf::from("/opt/fno-agents-daemon"));
363        assert_eq!(fp.mtime_nanos, 1_700_000_000_000_000_000);
364        assert_eq!(fp.size, 4242);
365
366        // A null/absent field (pre-drift daemon) -> None -> caller reads Unknown.
367        let partial = json!({"daemon": {"exe_path": "/opt/x", "exe_size": 1_u64}});
368        assert!(running_fingerprint(&partial).is_none());
369        assert!(running_fingerprint(&json!({})).is_none());
370    }
371
372    #[test]
373    fn send_sigterm_reports_already_gone_for_dead_pid() {
374        // AC2-FR support: a reaped child's pid is ESRCH -> AlreadyGone (a
375        // successful "it exited"), distinct from the loud Failed path.
376        let child = std::process::Command::new("true")
377            .spawn()
378            .expect("spawn true");
379        let pid = child.id();
380        let mut child = child;
381        let _ = child.wait(); // reap so the pid is fully gone
382        std::thread::sleep(Duration::from_millis(50));
383        match send_sigterm(pid) {
384            // pid retired -> ESRCH; not-yet-retired -> Sent. Both acceptable;
385            // a Failed (e.g. EPERM) for our own just-reaped child is the bug.
386            SigtermResult::AlreadyGone | SigtermResult::Sent => {}
387            SigtermResult::Failed(e) => panic!("unexpected Failed: {e}"),
388        }
389    }
390}