Skip to main content

mur_common/
lock_file.rs

1//! Canonical `running.lock` reader + 3-state agent status classifier.
2//!
3//! Used by:
4//! - `mur agent list/status` (CLI) — see `mur-core/src/cmd/agent.rs`
5//! - `/api/v1/agents/*` (HTTP) — see `mur-core/src/server_agents/`
6//! - `mur-agent-runtime` supervisor — see `mur-agent-runtime/src/lock_file.rs`
7//!   (the runtime additionally uses `flock`; that check stays local)
8
9use crate::LockFile;
10use serde::Serialize;
11use std::path::Path;
12
13/// Three-state classification of an agent's runtime state.
14#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
15#[serde(rename_all = "snake_case")]
16pub enum AgentStatusKind {
17    /// Lock present and the recorded pid is alive.
18    Running,
19    /// Lock present but the pid is not alive (crash/kill — orphan lock).
20    Stale,
21    /// No lock file.
22    Stopped,
23}
24
25impl AgentStatusKind {
26    /// Stable visual marker for this status — the single source of truth for
27    /// the status→emoji mapping used by `mur agent list` and the agent card.
28    /// Exhaustive match: adding a variant is a compile error here by design.
29    pub fn emoji(&self) -> &'static str {
30        match self {
31            AgentStatusKind::Running => "🟢",
32            AgentStatusKind::Stale => "🟡",
33            AgentStatusKind::Stopped => "⚪",
34        }
35    }
36}
37
38/// Result of classifying an agent's lock state.
39#[derive(Debug, Clone, Copy)]
40pub struct AgentStatus {
41    pub kind: AgentStatusKind,
42    /// PID from the lock file. `None` when no lock or unparseable lock.
43    pub pid: Option<u32>,
44}
45
46/// Read and JSON-parse `<home>/running.lock`. Returns:
47/// - `Ok(None)` if the file does not exist (agent stopped).
48/// - `Ok(Some(_))` if the file exists and parses successfully.
49/// - `Err(_)` if the file exists but I/O fails or JSON is malformed.
50pub fn read(lock_path: &Path) -> std::io::Result<Option<LockFile>> {
51    let bytes = match std::fs::read(lock_path) {
52        Ok(b) => b,
53        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
54        Err(e) => return Err(e),
55    };
56    serde_json::from_slice(&bytes)
57        .map(Some)
58        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
59}
60
61/// Is the given pid currently a live process the calling user can signal?
62///
63/// On Unix uses `kill(pid, 0)` — signal 0 is a no-op probe that checks
64/// process existence and permission without delivering any signal.
65///
66/// On Windows uses `OpenProcess` with `PROCESS_QUERY_LIMITED_INFORMATION`,
67/// then `GetExitCodeProcess` — an openable handle is NOT proof of life.
68///
69/// On other platforms returns `true` (optimistically treat any present lock
70/// as live, since P0a agents are not supported there).
71#[cfg(unix)]
72pub fn pid_alive(pid: u32) -> bool {
73    // SAFETY: kill(2) with signal 0 delivers no signal; it only checks
74    // process existence and our permission to signal it. Always safe to call.
75    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
76}
77
78#[cfg(windows)]
79pub fn pid_alive(pid: u32) -> bool {
80    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
81    use windows_sys::Win32::System::Threading::{
82        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
83    };
84    // SAFETY: OpenProcess/GetExitCodeProcess/CloseHandle are safe to call with
85    // any pid; the handle is closed on every path out.
86    unsafe {
87        let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
88        if h.is_null() {
89            return false;
90        }
91        // A terminated process stays openable until the last handle to it is
92        // closed — Windows' equivalent of a zombie. Treating a non-null handle
93        // as "alive" therefore reports a just-crashed agent as running, and
94        // `clear_runtime_state` then refuses to clear its stale lock. Ask for
95        // the exit code instead: only STILL_ACTIVE means running. (Known
96        // Windows caveat: a process that exits with code 259 is
97        // indistinguishable from a running one. Our runtimes never exit 259,
98        // and the failure direction is the conservative one — a lock kept, not
99        // a live process's lock deleted.)
100        let mut code: u32 = 0;
101        let queried = GetExitCodeProcess(h, &mut code);
102        CloseHandle(h);
103        // If the query itself fails we cannot tell, so claim alive: keeping a
104        // dead process's lock costs the user one manual clear, while deleting
105        // a live one's lock lets a second supervisor run against the same agent
106        // home (#790).
107        queried == 0 || code == STILL_ACTIVE as u32
108    }
109}
110
111#[cfg(not(any(unix, windows)))]
112pub fn pid_alive(_pid: u32) -> bool {
113    true
114}
115
116/// Classify the agent's running state by inspecting `<home>/running.lock`.
117///
118/// - No lock → `Stopped`
119/// - Lock present, parses, pid alive → `Running`
120/// - Lock present but pid not alive (crash / SIGKILL / OOM) → `Stale`
121/// - Lock present but unparseable / unreadable → `Stale` with `pid: None`
122///   (treat as stale rather than running so dashboards don't paint dead
123///   agents green)
124pub fn classify(lock_path: &Path) -> AgentStatus {
125    match read(lock_path) {
126        Ok(None) => AgentStatus {
127            kind: AgentStatusKind::Stopped,
128            pid: None,
129        },
130        Err(_) => AgentStatus {
131            kind: AgentStatusKind::Stale,
132            pid: None,
133        },
134        Ok(Some(lock)) => {
135            let kind = if pid_alive(lock.pid) {
136                AgentStatusKind::Running
137            } else {
138                AgentStatusKind::Stale
139            };
140            AgentStatus {
141                kind,
142                pid: Some(lock.pid),
143            }
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::agent::LockTransports;
152
153    #[test]
154    fn status_emoji_mapping_is_stable() {
155        assert_eq!(AgentStatusKind::Running.emoji(), "🟢");
156        assert_eq!(AgentStatusKind::Stale.emoji(), "🟡");
157        assert_eq!(AgentStatusKind::Stopped.emoji(), "⚪");
158    }
159
160    fn make_lock(pid: u32) -> LockFile {
161        LockFile {
162            schema: 1,
163            uuid: "01JQX4TM8Y9K7VQH6B2N3R5DPE".into(),
164            name: "agent_a".into(),
165            pid,
166            ppid: 1,
167            started_at: "2026-04-22T08:00:00Z".into(),
168            binary_version: "mur-agent-runtime 0.1.0".into(),
169            transports: LockTransports {
170                stdio: false,
171                unix_socket: Some("/tmp/x.sock".into()),
172                tcp: None,
173                webhook: None,
174            },
175            card_digest: "sha256:abc".into(),
176            capabilities: vec!["a2a.message.send".into()],
177            build_sha: String::new(),
178            proto_version: 0,
179        }
180    }
181
182    fn write_lock_file(dir: &std::path::Path, pid: u32) -> std::path::PathBuf {
183        let path = dir.join("running.lock");
184        let lock = make_lock(pid);
185        std::fs::write(&path, serde_json::to_vec_pretty(&lock).unwrap()).unwrap();
186        path
187    }
188
189    #[test]
190    fn classify_returns_stopped_when_no_lock() {
191        let tmp = tempfile::tempdir().unwrap();
192        let lock_path = tmp.path().join("running.lock");
193        let status = classify(&lock_path);
194        assert_eq!(status.kind, AgentStatusKind::Stopped);
195        assert_eq!(status.pid, None);
196    }
197
198    #[cfg(unix)]
199    #[test]
200    fn classify_returns_running_when_pid_alive() {
201        let tmp = tempfile::tempdir().unwrap();
202        let lock_path = write_lock_file(tmp.path(), std::process::id());
203        let status = classify(&lock_path);
204        assert_eq!(status.kind, AgentStatusKind::Running);
205        assert_eq!(status.pid, Some(std::process::id()));
206    }
207
208    #[cfg(unix)]
209    #[test]
210    fn classify_returns_stale_when_pid_dead() {
211        let tmp = tempfile::tempdir().unwrap();
212        let dead_pid: u32 = 999_999;
213        let lock_path = write_lock_file(tmp.path(), dead_pid);
214        let status = classify(&lock_path);
215        assert_eq!(status.kind, AgentStatusKind::Stale);
216        assert_eq!(status.pid, Some(dead_pid));
217    }
218
219    #[test]
220    fn classify_returns_stale_when_lock_malformed() {
221        let tmp = tempfile::tempdir().unwrap();
222        let lock_path = tmp.path().join("running.lock");
223        std::fs::write(&lock_path, b"not json").unwrap();
224        let status = classify(&lock_path);
225        assert_eq!(status.kind, AgentStatusKind::Stale);
226        assert_eq!(status.pid, None);
227    }
228
229    #[test]
230    fn read_returns_none_for_missing_file() {
231        let tmp = tempfile::tempdir().unwrap();
232        let lock_path = tmp.path().join("running.lock");
233        let result = read(&lock_path).unwrap();
234        assert!(result.is_none());
235    }
236
237    #[test]
238    fn read_returns_ok_for_valid_lock() {
239        let tmp = tempfile::tempdir().unwrap();
240        let lock_path = write_lock_file(tmp.path(), 42);
241        let result = read(&lock_path).unwrap();
242        assert!(result.is_some());
243        assert_eq!(result.unwrap().pid, 42);
244    }
245
246    #[test]
247    fn read_returns_err_for_malformed_json() {
248        let tmp = tempfile::tempdir().unwrap();
249        let lock_path = tmp.path().join("running.lock");
250        std::fs::write(&lock_path, b"not json").unwrap();
251        let result = read(&lock_path);
252        assert!(result.is_err());
253    }
254
255    #[cfg(unix)]
256    #[test]
257    fn pid_alive_returns_true_for_self() {
258        assert!(pid_alive(std::process::id()));
259    }
260
261    #[cfg(unix)]
262    #[test]
263    fn pid_alive_returns_false_for_dead_pid() {
264        assert!(!pid_alive(999_999));
265    }
266}