Skip to main content

fno_agents/
state.rs

1//! Shared state files (Wave 3): `registry.json` (schema v4) and per-agent
2//! `state.json` (schema v1), plus the flock-protected, atomic read/modify/write
3//! helpers the daemon and worker share.
4//!
5//! Coupling-discipline invariants honored here:
6//!
7//! - **One writer per file via advisory lock.** Mutations take `LOCK_EX`; the
8//!   daemon-down read path takes `LOCK_SH`. std's `File::lock`/`lock_shared`
9//!   (stable since Rust 1.89) wrap `flock(2)`, the same advisory-lock family
10//!   Python's `fcntl.flock` uses, so a Python `fno` process and the Rust daemon
11//!   serialize against each other (US6.12, the load-bearing cross-language
12//!   coupling proven by `tests/flock_interop.rs`).
13//! - **Atomic publish via tempfile + rename.** A reader never observes a torn
14//!   write; it sees either the old file or the fully-written new one. Optional
15//!   fields are preserved across updates by round-tripping through the typed
16//!   struct (no field-dropping reserialization).
17//! - **`state.status` is canonical; `registry.status` is a projection** (LD10).
18//!   This module stores both; conflict resolution (state wins) is the daemon's.
19
20use crate::AgentStatus;
21use serde::{Deserialize, Serialize};
22use std::fs::{File, OpenOptions};
23use std::io::{Read, Write};
24use std::path::{Path, PathBuf};
25
26/// Current registry schema version.
27///
28/// v4 (ab-a171ceb2) is a forward-compat bump for `host_mode`: v4 is
29/// structurally identical to v3 (host_mode is additive-optional and read
30/// version-independently via absent==exec coercion), but stamping v4 forces a
31/// pre-host_mode reader - which accepts only {1,2,3} and has no host_mode code
32/// - to REJECT the store rather than silently treat an interactive row as exec
33/// and orphan a live TUI during reconcile. Readers stay backward-compatible:
34/// the accepted-version set still spans 1..=4 (see ACCEPTED_SCHEMA_VERSIONS in
35/// client_verbs.rs and the Python load_registry range check).
36pub const REGISTRY_SCHEMA_VERSION: u32 = 4;
37/// Current per-agent state schema version (design: schema v1).
38pub const STATE_SCHEMA_VERSION: u32 = 1;
39
40/// Errors from state-file access.
41#[derive(Debug, thiserror::Error)]
42pub enum StateError {
43    #[error("state io error: {0}")]
44    Io(#[from] std::io::Error),
45    #[error("state json error: {0}")]
46    Json(#[from] serde_json::Error),
47    #[error(
48        "registry schema_version {found} unsupported; this fno understands 1..={max}. \
49         Upgrade or downgrade fno to match."
50    )]
51    UnsupportedSchemaVersion { found: u32, max: u32 },
52}
53
54/// The daemon-owned agent registry (`~/.fno/agents/registry.json`).
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct Registry {
57    pub schema_version: u32,
58    /// Rows. Python's `registry.write_registry` (cli/.../agents/registry.py)
59    /// stores these under the canonical top-level `"agents"` key and reads ONLY
60    /// that key (no `entries` fallback). Serialize under `agents` so a Rust write
61    /// verb (`rm`/`stop`/reconcile) that rewrites a Python-authored registry
62    /// leaves it readable by Python rather than stranding the surviving rows
63    /// under an `entries` key Python ignores (Codex P1, PR #364). `alias =
64    /// "entries"` keeps reading older daemon-written registries. Combined with
65    /// ab-e5a57efa this makes the typed read path parse Python registries.
66    #[serde(default, rename = "agents", alias = "entries")]
67    pub entries: Vec<RegistryEntry>,
68}
69
70impl Default for Registry {
71    fn default() -> Self {
72        Registry {
73            schema_version: REGISTRY_SCHEMA_VERSION,
74            entries: Vec::new(),
75        }
76    }
77}
78
79impl Registry {
80    /// Find an entry by agent name.
81    pub fn find(&self, name: &str) -> Option<&RegistryEntry> {
82        self.entries.iter().find(|e| e.name == name)
83    }
84
85    /// Mutable find by agent name.
86    pub fn find_mut(&mut self, name: &str) -> Option<&mut RegistryEntry> {
87        self.entries.iter_mut().find(|e| e.name == name)
88    }
89}
90
91/// One registry row (design schema v4). Optional fields default to `None` and
92/// are preserved across `update_registry` because the whole row round-trips
93/// through this typed struct.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct RegistryEntry {
96    pub name: String,
97    /// Daemon-set PTY field. Python's `AgentEntry` now mirrors it as
98    /// `short_id: str = ""` (ab-b946b59c) so a real PTY row in a mixed registry
99    /// is Python-readable and round-trips losslessly; `skip_serializing_if`
100    /// still drops it when empty so a *Rust*-authored exec/ask row stays slim and
101    /// a round-tripped Python row omits it (default-to-empty on read, ab-e5a57efa;
102    /// Codex P1, PR #364). A real daemon PTY agent always has a non-empty
103    /// short_id, so it still serializes for those rows; conversely a one-shot
104    /// `ask` row always has an empty short_id (no worker-socket identity). That
105    /// exclusivity is what [`RegistryEntry::is_one_shot_ask`] keys on -- a
106    /// non-empty short_id on an ask row, or an empty one on a PTY row, is a
107    /// producer bug. (Python mirrors with a `str` default, not `Option`, because
108    /// a `"short_id": null` would fail this `String` field's deserialize.)
109    #[serde(default, skip_serializing_if = "String::is_empty")]
110    pub short_id: String,
111    pub provider: String,
112    pub cwd: String,
113    /// Daemon-set PTY field, mirrored in Python's `AgentEntry` as
114    /// `project_root: str = ""` (ab-b946b59c; see `short_id`): default on read,
115    /// skip-when-empty on write.
116    #[serde(default, skip_serializing_if = "String::is_empty")]
117    pub project_root: String,
118    /// On disk this is Rust-set only (Python's `session_id` is a computed
119    /// `@property`, excluded from its serialized rows): skip when absent so
120    /// Python can read a Rust-written row (Codex P1). When a Rust PTY row DOES
121    /// record one, Python's load_registry drops the key before constructing the
122    /// entry and recomputes the same projection from the *_session_id fields
123    /// (ab-b946b59c).
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub session_id: Option<String>,
126    #[serde(default)]
127    pub claude_short_id: Option<String>,
128    /// The FULL claude session UUID -- the stream-json `--resume` target,
129    /// distinct from the 8-hex `claude_short_id`/jobId (a 32-bit prefix, not
130    /// collision-proof as a resume key). Shared field with Python's `AgentEntry`
131    /// (`#[serde(default)]`, always emitted as null when absent, matching the
132    /// sibling provider-id fields), so a row round-trips between the two
133    /// languages. The daemon reads it to build the resume argv for the
134    /// stream-json host lane. [stream-json host lane node]
135    #[serde(default)]
136    pub claude_session_uuid: Option<String>,
137    /// Daemon-set PTY field, mirrored in Python's `AgentEntry` (ab-b946b59c):
138    /// skip when absent (Codex P1).
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub messaging_socket_path: Option<String>,
141    #[serde(default)]
142    pub codex_session_id: Option<String>,
143    #[serde(default)]
144    pub gemini_session_id: Option<String>,
145    #[serde(default)]
146    pub mcp_channel_id: Option<String>,
147    /// Hosting mode: absent/`None` == `"exec"` (one-shot, the default for every
148    /// pre-existing row), `Some("interactive")` == a long-lived drivable TUI
149    /// (`fno agents host`/`promote`). Skip-when-`None` so a *Rust*-authored exec
150    /// row omits the key; Python's missing-key coercion then maps the absence
151    /// back to `"exec"`. (Python itself always emits the key via `asdict` -- as
152    /// `"exec"` or `"interactive"` -- and Rust reads the concrete value fine, so
153    /// both directions agree.) Consumers must read it via
154    /// [`RegistryEntry::host_mode_or_default`], never the raw `Option`, so the
155    /// absent==exec rule lives in one place. [interactive-drive node]
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub host_mode: Option<String>,
158    /// Daemon-set PTY field, mirrored in Python's `AgentEntry` (ab-b946b59c):
159    /// skip when absent (Codex P1).
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub cc_session_id: Option<String>,
162    pub status: AgentStatus,
163    #[serde(default)]
164    pub last_message_at: Option<String>,
165    pub created_at: String,
166    /// Daemon-set PTY field, mirrored in Python's `AgentEntry` as
167    /// `pid: Optional[int]` (ab-b946b59c): skip when absent so a round-tripped
168    /// Python row stays slim and Python-readable (Codex P1).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub pid: Option<u32>,
171    /// The worker process's start time, captured alongside `pid` at spawn, used
172    /// to detect PID reuse: a liveness/reap/signal decision treats `pid` as "our
173    /// worker" only if the live process's start time still matches this
174    /// (ab-d19e6458). Per-host, per-boot value (Linux: `/proc/<pid>/stat` field
175    /// 22 in clock ticks; macOS: `kinfo_proc` start `timeval` in microseconds) —
176    /// only ever compared for equality against a fresh read of the SAME pid, so
177    /// the unit/epoch difference across platforms is irrelevant. Daemon-set PTY
178    /// field, mirrored in Python's `AgentEntry` (ab-b946b59c); skip when absent.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub pid_start_time: Option<u64>,
181    #[serde(default)]
182    pub log_path: Option<String>,
183    /// Timestamp of the most recent reconcile probe (finding #1 High): the
184    /// reconcile sweep orders entries by ASC `last_reconciled_at` so a
185    /// budget-exhausted sweep stays fair across a large registry. Daemon-set,
186    /// mirrored in Python's `AgentEntry` (ab-b946b59c); skip when absent (Codex P1).
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub last_reconciled_at: Option<String>,
189}
190
191/// `host_mode` value for a one-shot exec session (the default when absent).
192pub const HOST_MODE_EXEC: &str = "exec";
193/// `host_mode` value for a long-lived drivable interactive session.
194pub const HOST_MODE_INTERACTIVE: &str = "interactive";
195
196impl RegistryEntry {
197    /// The hosting mode with the absent==exec rule applied in one place.
198    /// `None` on disk (and the legacy rows that predate the field) read as
199    /// [`HOST_MODE_EXEC`]; an explicit value passes through. Reconcile/liveness
200    /// and the spawn path must use this, never the raw `Option`, so a missing
201    /// key can never be mistaken for a non-exec mode. [interactive-drive node]
202    pub fn host_mode_or_default(&self) -> &str {
203        self.host_mode.as_deref().unwrap_or(HOST_MODE_EXEC)
204    }
205
206    /// True when this row is a long-lived interactive host (vs a one-shot exec
207    /// session). The reconcile branch keys off this: an exec worker that exited
208    /// is normal; an interactive worker is expected to stay live until `/quit`.
209    pub fn is_interactive(&self) -> bool {
210        self.host_mode_or_default() == HOST_MODE_INTERACTIVE
211    }
212
213    /// True when this row is a one-shot `ask` agent the daemon does NOT manage as
214    /// a worker process: empty `short_id` (no worker-socket identity) AND no
215    /// recorded `pid`. Such an agent has no process whose liveness could make it
216    /// `live` -- its terminal status is `exited`, and its post-run value is
217    /// *resumability* (a recorded provider session id), surfaced separately from
218    /// status via the `session_id` projection. Only PTY agents (`spawn`/`host`/
219    /// `promote`) carry a non-empty short_id + pid and can be `live`; this is the
220    /// invariant documented on the `short_id` field ("a real daemon PTY agent
221    /// always has a non-empty short_id"). Reconcile uses this to settle a
222    /// finished ask to `exited` by process-liveness alone, never consulting
223    /// session-file reachability for status. [plan ab-70faa65b, Locked Decision #1]
224    pub fn is_one_shot_ask(&self) -> bool {
225        self.short_id.is_empty() && self.pid.is_none()
226    }
227}
228
229/// Per-agent runtime state (`<short_id>/state.json`, schema v1). `state.status`
230/// is canonical (LD10).
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232pub struct AgentState {
233    pub schema_version: u32,
234    pub short_id: String,
235    pub status: AgentStatus,
236    #[serde(default)]
237    pub ready: bool,
238    #[serde(default)]
239    pub last_message_at: Option<String>,
240    #[serde(default)]
241    pub last_reply: Option<String>,
242    #[serde(default)]
243    pub restart_count: u32,
244    #[serde(default)]
245    pub last_restart_at: Option<String>,
246    /// `None` for shellout (claude) agents; `Some` for PTY-managed agents.
247    #[serde(default)]
248    pub pty: Option<PtyState>,
249}
250
251impl AgentState {
252    /// Construct a fresh PTY-managed agent state.
253    pub fn new_pty(short_id: impl Into<String>) -> Self {
254        AgentState {
255            schema_version: STATE_SCHEMA_VERSION,
256            short_id: short_id.into(),
257            status: AgentStatus::Spawning,
258            ready: false,
259            last_message_at: None,
260            last_reply: None,
261            restart_count: 0,
262            last_restart_at: None,
263            pty: Some(PtyState::default()),
264        }
265    }
266}
267
268/// An open interactive drive window. Bundling the drive facts behind a single
269/// `Option<DriveWindow>` makes the inconsistent `{drive_active: false,
270/// drive_session_id: Some(..)}` state impossible: either there is a window
271/// (`Some`) carrying all its fields, or there is none (`None`).
272#[derive(Debug, Clone, PartialEq, Default)]
273pub struct DriveWindow {
274    pub session_id: Option<String>,
275    pub mode: Option<String>,
276    /// Monotonic-clock baseline of the last drive heartbeat (count-during-sleep
277    /// ns; see [`crate::MonotonicTimestamp`]).
278    pub last_heartbeat_at_monotonic_ns: Option<u64>,
279}
280
281/// PTY sub-state. The on-disk shape stays flat (`active`, `drive_active`,
282/// `drive_session_id`, `drive_mode`, `last_heartbeat_at_monotonic_ns`) via a
283/// hand-written serde impl below, so cross-language schema parity (Wave 7) is a
284/// direct field map; in memory the drive cluster is one `Option<DriveWindow>`.
285#[derive(Debug, Clone, PartialEq, Default)]
286pub struct PtyState {
287    pub active: bool,
288    /// `Some` while an interactive drive window is open; `None` otherwise.
289    pub drive: Option<DriveWindow>,
290}
291
292impl PtyState {
293    /// Recovery step 4/5 ordering primitive (finding #12 Critical): atomically
294    /// READ the active drive window (returning its session id + mode + last
295    /// heartbeat) AND clear it. Callers MUST use the returned value to emit
296    /// `drive_crashed` — the read happens here, before the clear, so the event
297    /// reflects what the window was. Returns `None` if no drive was active.
298    ///
299    /// With the drive cluster behind one `Option`, read-then-clear is just
300    /// `Option::take`: there is no window between the read and the clear for a
301    /// second observer to see a half-cleared state.
302    pub fn take_active_drive(&mut self) -> Option<DriveWindow> {
303        self.drive.take()
304    }
305}
306
307/// Flat on-disk projection of [`PtyState`], mediating between the typed
308/// `Option<DriveWindow>` and the design's flat `state.json` schema. `drive_active`
309/// is the discriminant; the option fields default to `None`/absent.
310#[derive(Serialize, Deserialize)]
311struct PtyStateWire {
312    active: bool,
313    #[serde(default)]
314    drive_active: bool,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    drive_session_id: Option<String>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    drive_mode: Option<String>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    last_heartbeat_at_monotonic_ns: Option<u64>,
321}
322
323impl Serialize for PtyState {
324    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
325    where
326        S: serde::Serializer,
327    {
328        let wire = match &self.drive {
329            Some(d) => PtyStateWire {
330                active: self.active,
331                drive_active: true,
332                drive_session_id: d.session_id.clone(),
333                drive_mode: d.mode.clone(),
334                last_heartbeat_at_monotonic_ns: d.last_heartbeat_at_monotonic_ns,
335            },
336            None => PtyStateWire {
337                active: self.active,
338                drive_active: false,
339                drive_session_id: None,
340                drive_mode: None,
341                last_heartbeat_at_monotonic_ns: None,
342            },
343        };
344        wire.serialize(serializer)
345    }
346}
347
348impl<'de> Deserialize<'de> for PtyState {
349    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
350    where
351        D: serde::Deserializer<'de>,
352    {
353        let wire = PtyStateWire::deserialize(deserializer)?;
354        // `drive_active` is canonical for window presence. A legacy/partial file
355        // with the flag clear collapses any stray option fields to `None`, which
356        // is exactly the inconsistent state the refactor makes unrepresentable.
357        let drive = if wire.drive_active {
358            Some(DriveWindow {
359                session_id: wire.drive_session_id,
360                mode: wire.drive_mode,
361                last_heartbeat_at_monotonic_ns: wire.last_heartbeat_at_monotonic_ns,
362            })
363        } else {
364            None
365        };
366        Ok(PtyState {
367            active: wire.active,
368            drive,
369        })
370    }
371}
372
373// ---------------------------------------------------------------------------
374// Locked, atomic file access.
375// ---------------------------------------------------------------------------
376
377/// Load the registry under a shared lock. A missing file yields an empty
378/// registry (0 agents is a valid steady state, not an error). The shared lock
379/// is the daemon-down read path (`fno agents list` when the socket is down)
380/// AND recovery step 1.
381pub fn load_registry(path: &Path) -> Result<Registry, StateError> {
382    // Lock the SAME sidecar `update_registry` locks (shared mode here), not the
383    // data file. This is the canonical cross-language lock target: a Python
384    // `fno` writer taking `flock` on `<registry>.lock` and the Rust daemon's
385    // exclusive write-lock then live in one domain, so reader/writer and
386    // cross-language writers actually mutually exclude (US6.12). Locking the
387    // data file directly would (a) not exclude against the sidecar-based
388    // writer and (b) reintroduce the rename-invalidates-fd footgun.
389    // Acquire the lock FIRST, then decide existence: a `!path.exists()` check
390    // before the lock could race a concurrent writer creating registry.json and
391    // return a stale empty registry (Codex P2). The open-after-lock below is the
392    // authoritative existence check.
393    let lock = acquire_shared(&lock_path(path))?;
394    let result = match OpenOptions::new().read(true).open(path) {
395        Ok(file) => read_registry_tolerant(&file),
396        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
397            let _ = lock.unlock();
398            return Ok(Registry::default());
399        }
400        Err(e) => {
401            let _ = lock.unlock();
402            return Err(e.into());
403        }
404    };
405    let _ = lock.unlock();
406    result
407}
408
409/// Read a registry, tolerating ONLY a genuinely empty file (0 bytes / all
410/// whitespace) as the empty registry. A present-but-unparseable file (malformed
411/// JSON, schema mismatch, corruption) propagates `StateError::Json` instead of
412/// silently defaulting: a default fed back through `update_registry`'s
413/// read-modify-write would publish an empty registry and permanently wipe every
414/// other agent (Gemini high, PR #364). `write_json_atomic` publishes via
415/// tempfile + rename, so a reader never observes a torn write -- a parse failure
416/// is therefore real corruption, not the transient partial read the prior
417/// `unwrap_or_default()` was excusing.
418fn read_registry_tolerant(mut file: &File) -> Result<Registry, StateError> {
419    let mut buf = String::new();
420    file.read_to_string(&mut buf)?;
421    if buf.trim().is_empty() {
422        return Ok(Registry::default());
423    }
424    let reg: Registry = serde_json::from_str(&buf)?;
425    // Forward-compat guard on the TYPED daemon path (Codex P2, ab-a171ceb2):
426    // the raw client path (client_verbs::load_registry_entries) already rejects
427    // unsupported versions, but the daemon reads through here and previously
428    // accepted any u32. Reject anything outside 1..=REGISTRY_SCHEMA_VERSION so a
429    // pre-host_mode daemon refuses a v4 store (instead of treating an interactive
430    // row as exec) and the current daemon refuses a future v5 store.
431    if reg.schema_version < 1 || reg.schema_version > REGISTRY_SCHEMA_VERSION {
432        return Err(StateError::UnsupportedSchemaVersion {
433            found: reg.schema_version,
434            max: REGISTRY_SCHEMA_VERSION,
435        });
436    }
437    Ok(reg)
438}
439
440/// Read-modify-write the registry under an exclusive lock, publishing the
441/// result atomically (tempfile + rename). The lock is held across the whole
442/// read-modify-write so two daemons (or a daemon and a Python `fno`) never
443/// interleave. The closure mutates the registry in place.
444pub fn update_registry<F, T>(path: &Path, f: F) -> Result<T, StateError>
445where
446    F: FnOnce(&mut Registry) -> T,
447{
448    if let Some(parent) = path.parent() {
449        std::fs::create_dir_all(parent)?;
450    }
451    // Lock on a stable sidecar so the rename of the data file never invalidates
452    // the lock fd (renaming the locked file out from under a held flock is the
453    // classic footgun; locking the sidecar sidesteps it entirely).
454    let lock = acquire_exclusive(&lock_path(path))?;
455    let mut registry = read_existing_registry(path)?;
456    let out = f(&mut registry);
457    // Upgrade-on-write (Codex P2, ab-a171ceb2): stamp the current schema version
458    // so a Rust write of an older (e.g. v3) store bumps it to v4, matching
459    // Python's write_registry (which always writes SCHEMA_VERSION). Without this,
460    // adding host_mode to an existing v3 registry would leave schema_version:3 and
461    // a pre-host_mode reader would still accept it - defeating the forward-compat
462    // bump for every store that predates it (the common case).
463    registry.schema_version = REGISTRY_SCHEMA_VERSION;
464    write_json_atomic(path, &registry)?;
465    let _ = lock.unlock();
466    Ok(out)
467}
468
469fn read_existing_registry(path: &Path) -> Result<Registry, StateError> {
470    match OpenOptions::new().read(true).open(path) {
471        Ok(file) => read_registry_tolerant(&file),
472        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
473        Err(e) => Err(e.into()),
474    }
475}
476
477/// Load a per-agent `state.json`. `Ok(None)` when the file is absent (recovery
478/// distinguishes "registry entry without state.json" from a present-but-partial
479/// state).
480pub fn load_state(path: &Path) -> Result<Option<AgentState>, StateError> {
481    // Lock the SAME `.lock` sidecar `write_state_atomic` locks (shared mode),
482    // not the data file: readers and writers must synchronize on one inode or
483    // a read can race a concurrent write/rename (Codex P1). Acquire the lock
484    // BEFORE deciding existence so a writer creating the file mid-call cannot
485    // be missed.
486    let lock = acquire_shared(&lock_path(path))?;
487    let r = match OpenOptions::new().read(true).open(path) {
488        Ok(file) => read_json::<AgentState>(&file),
489        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
490            let _ = lock.unlock();
491            return Ok(None);
492        }
493        Err(e) => {
494            let _ = lock.unlock();
495            return Err(e.into());
496        }
497    };
498    let _ = lock.unlock();
499    match r {
500        Ok(s) => Ok(Some(s)),
501        // Present but empty/partial: treat as absent state so recovery marks
502        // the agent inconsistent rather than crashing.
503        Err(_) => Ok(None),
504    }
505}
506
507/// Atomically write a per-agent `state.json` (tempfile + rename) under an
508/// exclusive lock on its sidecar.
509pub fn write_state_atomic(path: &Path, state: &AgentState) -> Result<(), StateError> {
510    if let Some(parent) = path.parent() {
511        std::fs::create_dir_all(parent)?;
512    }
513    let lock = acquire_exclusive(&lock_path(path))?;
514    write_json_atomic(path, state)?;
515    let _ = lock.unlock();
516    Ok(())
517}
518
519/// Read-modify-write a per-agent `state.json` while holding the exclusive
520/// sidecar lock across the WHOLE operation, so concurrent writers cannot
521/// interleave between the read and the write (the lost-update footgun a
522/// `load_state` + `write_state_atomic` pair has).
523///
524/// Returns `Ok(false)` without calling `f` when the file is absent or partial:
525/// drive window mutations must never fabricate a `state.json` on the worker's
526/// behalf (recovery distinguishes "registry entry without state.json"). The
527/// drive admit / cleanup paths route their window writes through here so a
528/// stale-driver takeover cannot drop the authority window via a read that
529/// predates the new driver's write.
530pub fn update_state_atomic<F>(path: &Path, f: F) -> Result<bool, StateError>
531where
532    F: FnOnce(&mut AgentState),
533{
534    let lock = acquire_exclusive(&lock_path(path))?;
535    let existing = match OpenOptions::new().read(true).open(path) {
536        Ok(file) => read_json::<AgentState>(&file).ok(),
537        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
538        Err(e) => {
539            let _ = lock.unlock();
540            return Err(e.into());
541        }
542    };
543    let result = match existing {
544        Some(mut st) => {
545            f(&mut st);
546            write_json_atomic(path, &st)?;
547            true
548        }
549        None => false,
550    };
551    let _ = lock.unlock();
552    Ok(result)
553}
554
555fn lock_path(path: &Path) -> PathBuf {
556    let mut s = path.as_os_str().to_os_string();
557    s.push(".lock");
558    PathBuf::from(s)
559}
560
561/// Open (creating if needed) the lock sidecar and take an exclusive advisory
562/// lock, blocking until acquired. The returned `File` holds the lock until it
563/// is unlocked or dropped.
564fn acquire_exclusive(lock_file: &Path) -> Result<File, StateError> {
565    let file = OpenOptions::new()
566        .create(true)
567        .read(true)
568        .write(true)
569        .truncate(false)
570        .open(lock_file)?;
571    file.lock()?;
572    Ok(file)
573}
574
575/// Open (creating if needed) the lock sidecar and take a shared advisory lock,
576/// blocking until acquired. Multiple readers share; an exclusive writer
577/// excludes them. Same sidecar target as [`acquire_exclusive`].
578fn acquire_shared(lock_file: &Path) -> Result<File, StateError> {
579    if let Some(parent) = lock_file.parent() {
580        std::fs::create_dir_all(parent)?;
581    }
582    let file = OpenOptions::new()
583        .create(true)
584        .read(true)
585        .write(true)
586        .truncate(false)
587        .open(lock_file)?;
588    file.lock_shared()?;
589    Ok(file)
590}
591
592fn read_json<T: for<'de> Deserialize<'de>>(mut file: &File) -> Result<T, StateError> {
593    let mut buf = String::new();
594    file.read_to_string(&mut buf)?;
595    Ok(serde_json::from_str(&buf)?)
596}
597
598fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), StateError> {
599    let parent = path.parent().unwrap_or_else(|| Path::new("."));
600    std::fs::create_dir_all(parent)?;
601    let tmp = parent.join(format!(
602        ".{}.tmp.{}",
603        path.file_name().and_then(|s| s.to_str()).unwrap_or("state"),
604        std::process::id()
605    ));
606    {
607        let mut f = OpenOptions::new()
608            .create(true)
609            .write(true)
610            .truncate(true)
611            .open(&tmp)?;
612        let bytes = serde_json::to_vec_pretty(value)?;
613        f.write_all(&bytes)?;
614        f.sync_all()?;
615    }
616    std::fs::rename(&tmp, path)?;
617    Ok(())
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    fn tmpdir(tag: &str) -> PathBuf {
625        let mut p = std::env::temp_dir();
626        p.push(format!(
627            "fno-agents-state-{}-{}-{}",
628            tag,
629            std::process::id(),
630            std::time::SystemTime::now()
631                .duration_since(std::time::UNIX_EPOCH)
632                .unwrap()
633                .as_nanos()
634        ));
635        std::fs::create_dir_all(&p).unwrap();
636        p
637    }
638
639    fn sample_entry(name: &str) -> RegistryEntry {
640        RegistryEntry {
641            name: name.into(),
642            short_id: format!("{name}-id"),
643            provider: "codex".into(),
644            cwd: "/tmp/x".into(),
645            project_root: "/tmp/x".into(),
646            session_id: Some("uuid-1".into()),
647            claude_short_id: None,
648            claude_session_uuid: None,
649            messaging_socket_path: None,
650            codex_session_id: Some("uuid-1".into()),
651            gemini_session_id: None,
652            mcp_channel_id: None,
653            host_mode: None,
654            cc_session_id: None,
655            status: AgentStatus::Live,
656            last_message_at: None,
657            created_at: "2026-05-24T00:00:00Z".into(),
658            pid: Some(1234),
659            pid_start_time: None,
660            log_path: None,
661            last_reconciled_at: None,
662        }
663    }
664
665    #[test]
666    fn missing_registry_loads_empty() {
667        let dir = tmpdir("missing");
668        let reg = load_registry(&dir.join("registry.json")).unwrap();
669        assert_eq!(reg.schema_version, REGISTRY_SCHEMA_VERSION);
670        assert!(reg.entries.is_empty());
671        std::fs::remove_dir_all(&dir).ok();
672    }
673
674    #[test]
675    fn python_written_registry_loads_via_typed_path() {
676        // Regression for ab-e5a57efa: the typed daemon read path
677        // (`load_registry`, used by list/stop/rm/reconcile/status) must parse a
678        // registry authored by Python's `registry.write_registry`. That writer
679        // uses the top-level `"agents"` key and `AgentEntry` rows that omit the
680        // Rust-daemon-only `short_id`/`project_root` fields. Before the fix the
681        // whole-file parse failed and `unwrap_or_default()` returned 0 agents.
682        let dir = tmpdir("python-registry");
683        let path = dir.join("registry.json");
684        // Byte-for-byte the shape Python emits (no short_id, no project_root,
685        // key is "agents").
686        let python_json = r#"{
687  "schema_version": 3,
688  "agents": [
689    {
690      "name": "worker-claude",
691      "provider": "claude",
692      "cwd": "/Users/x/proj",
693      "log_path": "/Users/x/.fno/agents/worker-claude.log",
694      "claude_short_id": "abc123",
695      "codex_session_id": null,
696      "gemini_session_id": null,
697      "created_at": "2026-05-26T00:00:00Z",
698      "status": "live",
699      "last_message_at": null,
700      "mcp_channel_id": null
701    }
702  ]
703}"#;
704        std::fs::create_dir_all(&dir).unwrap();
705        std::fs::write(&path, python_json).unwrap();
706
707        let reg = load_registry(&path).unwrap();
708        assert_eq!(reg.entries.len(), 1, "Python-written row must be read");
709        let e = reg.find("worker-claude").unwrap();
710        assert_eq!(e.provider, "claude");
711        assert_eq!(e.status, AgentStatus::Live);
712        assert_eq!(e.claude_short_id.as_deref(), Some("abc123"));
713        // Rust-only fields default to empty for Python-authored rows.
714        assert_eq!(e.short_id, "");
715        assert_eq!(e.project_root, "");
716        std::fs::remove_dir_all(&dir).ok();
717    }
718
719    #[test]
720    fn python_row_roundtrips_to_python_shape_under_agents_key() {
721        // Codex P1 (PR #364): after the daemon rewrites a Python-authored
722        // registry (e.g. `rm` removing one agent), the surviving rows must stay
723        // readable by Python -- which reads ONLY the top-level `agents` key and
724        // whose `AgentEntry(**row)` rejects unknown keys. So the serialized form
725        // must (a) use `agents`, not `entries`, and (b) omit every Rust-only
726        // field that a Python row lacks (short_id/project_root/session_id/
727        // messaging_socket_path/cc_session_id/pid/last_reconciled_at).
728        let python_json = r#"{"schema_version":3,"agents":[
729            {"name":"w","provider":"codex","cwd":"/p","log_path":"/l",
730             "claude_short_id":null,"codex_session_id":"sid","gemini_session_id":null,
731             "created_at":"2026-05-26T00:00:00Z","status":"live","last_message_at":null,
732             "mcp_channel_id":null}]}"#;
733        let reg: Registry = serde_json::from_str(python_json).unwrap();
734        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
735
736        assert!(out.get("agents").is_some(), "must serialize under `agents`");
737        assert!(out.get("entries").is_none(), "must NOT serialize `entries`");
738        let row = &out["agents"][0];
739        for rust_only in [
740            "short_id",
741            "project_root",
742            "session_id",
743            "messaging_socket_path",
744            "cc_session_id",
745            "pid",
746            "pid_start_time",
747            "last_reconciled_at",
748        ] {
749            assert!(
750                row.get(rust_only).is_none(),
751                "Python-authored row must omit Rust-only field `{rust_only}`"
752            );
753        }
754        // Python's known fields survive.
755        assert_eq!(row["name"], "w");
756        assert_eq!(row["codex_session_id"], "sid");
757    }
758
759    #[test]
760    fn host_mode_cross_language_round_trip_parity() {
761        // interactive-drive node (ab-26b5fe82): the host_mode add must round-trip
762        // both directions across the Rust<->Python registry boundary.
763
764        // (a) Rust READS a Python-written row that OMITS host_mode -> exec.
765        let no_key = r#"{"schema_version":3,"agents":[
766            {"name":"legacy","provider":"codex","cwd":"/p","log_path":"/l",
767             "created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#;
768        let reg: Registry = serde_json::from_str(no_key).unwrap();
769        assert_eq!(reg.entries[0].host_mode, None);
770        assert_eq!(reg.entries[0].host_mode_or_default(), HOST_MODE_EXEC);
771        assert!(!reg.entries[0].is_interactive());
772
773        // (b) Rust READS a row carrying host_mode="interactive" -> interactive.
774        let interactive = r#"{"schema_version":3,"agents":[
775            {"name":"bot2","provider":"codex","cwd":"/p","log_path":"/l",
776             "codex_session_id":"019e7157","created_at":"2026-05-26T00:00:00Z",
777             "status":"live","host_mode":"interactive"}]}"#;
778        let reg: Registry = serde_json::from_str(interactive).unwrap();
779        assert_eq!(reg.entries[0].host_mode_or_default(), HOST_MODE_INTERACTIVE);
780        assert!(reg.entries[0].is_interactive());
781
782        // (c) Rust WRITES an exec row (host_mode None) -> key OMITTED, so a
783        // Python AgentEntry(**row) does not gain an unexpected key and Python's
784        // missing-key coercion maps the absence back to "exec".
785        let mut exec_entry = sample_entry("w");
786        exec_entry.host_mode = None;
787        let mut reg = Registry::default();
788        reg.entries.push(exec_entry);
789        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
790        assert!(
791            out["agents"][0].get("host_mode").is_none(),
792            "exec row must omit host_mode (skip_serializing_if)"
793        );
794
795        // (d) Rust WRITES an interactive row -> host_mode present and readable.
796        let mut int_entry = sample_entry("bot2");
797        int_entry.host_mode = Some(HOST_MODE_INTERACTIVE.to_string());
798        let mut reg = Registry::default();
799        reg.entries.push(int_entry);
800        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
801        assert_eq!(out["agents"][0]["host_mode"], "interactive");
802    }
803
804    #[test]
805    fn rust_reads_python_row_with_explicit_empty_and_null_fields() {
806        // ab-b946b59c: Python's `AgentEntry` now mirrors the Rust-only PTY
807        // fields, so its `asdict` emits them for EVERY row -- short_id/
808        // project_root as "" (their Rust type is `String`, so a null would fail
809        // deserialize) and the Option fields as null. Rust must read that shape.
810        let python_json = r#"{"schema_version":4,"agents":[
811            {"name":"py-ask","provider":"codex","cwd":"/p","log_path":"/l",
812             "short_id":"","project_root":"",
813             "claude_short_id":null,"codex_session_id":"sid","gemini_session_id":null,
814             "claude_session_uuid":null,"messaging_socket_path":null,"cc_session_id":null,
815             "mcp_channel_id":null,"host_mode":"exec",
816             "created_at":"2026-05-26T00:00:00Z","status":"exited","last_message_at":null,
817             "pid":null,"pid_start_time":null,"last_reconciled_at":null}]}"#;
818        let reg: Registry = serde_json::from_str(python_json).unwrap();
819        let e = &reg.entries[0];
820        assert_eq!(e.name, "py-ask");
821        assert_eq!(e.short_id, ""); // "" deserializes into the String field
822        assert_eq!(e.project_root, "");
823        assert_eq!(e.pid, None); // null -> None for the Option fields
824        assert_eq!(e.pid_start_time, None);
825        assert_eq!(e.cc_session_id, None);
826        assert_eq!(e.codex_session_id.as_deref(), Some("sid"));
827        assert!(e.is_one_shot_ask(), "empty short_id + no pid => ask row");
828    }
829
830    #[test]
831    fn pty_agent_still_serializes_its_short_id() {
832        // The skip-when-empty must NOT drop a real daemon agent's short_id/pid.
833        let mut reg = Registry::default();
834        reg.entries.push(sample_entry("worker-A")); // short_id "worker-A-id", pid Some
835        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
836        let row = &out["agents"][0];
837        assert_eq!(row["short_id"], "worker-A-id");
838        assert_eq!(row["pid"], 1234);
839    }
840
841    #[test]
842    fn empty_registry_file_loads_default_but_corrupt_file_errors() {
843        // Gemini high (PR #364): an empty/whitespace file is a valid empty
844        // registry, but a present-but-unparseable file must error LOUDLY rather
845        // than default -- otherwise update_registry's read-modify-write republishes
846        // the empty default and wipes every other agent.
847        let dir = tmpdir("corrupt-registry");
848        std::fs::create_dir_all(&dir).unwrap();
849        let path = dir.join("registry.json");
850
851        // Empty file -> empty registry, no error.
852        std::fs::write(&path, "   \n").unwrap();
853        assert!(load_registry(&path).unwrap().entries.is_empty());
854
855        // Corrupt (non-empty, unparseable) file -> error, not silent default.
856        std::fs::write(&path, "{ this is not json").unwrap();
857        assert!(
858            load_registry(&path).is_err(),
859            "corrupt registry must surface an error"
860        );
861        std::fs::remove_dir_all(&dir).ok();
862    }
863
864    #[test]
865    fn update_registry_refuses_to_wipe_a_corrupt_registry() {
866        // The data-loss path Gemini flagged: update_registry reads, mutates,
867        // writes. If the read silently defaulted on a corrupt file, the write
868        // would publish an (almost) empty registry. It must instead propagate the
869        // parse error and leave the file byte-for-byte intact.
870        let dir = tmpdir("no-wipe");
871        std::fs::create_dir_all(&dir).unwrap();
872        let path = dir.join("registry.json");
873        let corrupt = "{\"schema_version\": 3, \"agents\": [ BROKEN";
874        std::fs::write(&path, corrupt).unwrap();
875
876        let result = update_registry(&path, |r| r.entries.push(sample_entry("new-A")));
877        assert!(result.is_err(), "update over corrupt registry must error");
878        assert_eq!(
879            std::fs::read_to_string(&path).unwrap(),
880            corrupt,
881            "corrupt registry must be left untouched, not overwritten"
882        );
883        std::fs::remove_dir_all(&dir).ok();
884    }
885
886    #[test]
887    fn update_registry_upgrades_schema_version_on_write() {
888        // Codex P2 (ab-a171ceb2): a Rust write of an existing older store must
889        // bump schema_version to the current version, or the forward-compat bump
890        // never takes effect for the common case (stores that predate it).
891        let dir = tmpdir("upgrade-on-write");
892        std::fs::create_dir_all(&dir).unwrap();
893        let path = dir.join("registry.json");
894        std::fs::write(
895            &path,
896            r#"{"schema_version":3,"agents":[{"name":"w","provider":"codex","cwd":"/p","log_path":"/l","created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#,
897        )
898        .unwrap();
899        update_registry(&path, |r| r.entries.push(sample_entry("w2"))).unwrap();
900        let on_disk: serde_json::Value =
901            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
902        assert_eq!(
903            on_disk["schema_version"], REGISTRY_SCHEMA_VERSION,
904            "Rust write must upgrade the on-disk schema_version"
905        );
906        std::fs::remove_dir_all(&dir).ok();
907    }
908
909    #[test]
910    fn load_registry_rejects_unsupported_schema_version() {
911        // Codex P2 (ab-a171ceb2): the typed daemon read path must reject a version
912        // outside 1..=REGISTRY_SCHEMA_VERSION (a future v5, or - for an old daemon -
913        // a v4 it cannot interpret), while v1..=v4 still read.
914        let dir = tmpdir("version-guard");
915        std::fs::create_dir_all(&dir).unwrap();
916        let path = dir.join("registry.json");
917        std::fs::write(&path, r#"{"schema_version":5,"agents":[]}"#).unwrap();
918        match load_registry(&path) {
919            Err(StateError::UnsupportedSchemaVersion { found, max }) => {
920                assert_eq!(found, 5);
921                assert_eq!(max, REGISTRY_SCHEMA_VERSION);
922            }
923            other => panic!("expected UnsupportedSchemaVersion, got {other:?}"),
924        }
925        std::fs::write(&path, r#"{"schema_version":1,"agents":[]}"#).unwrap();
926        assert!(
927            load_registry(&path).is_ok(),
928            "v1 must still read (back-compat)"
929        );
930        std::fs::remove_dir_all(&dir).ok();
931    }
932
933    #[test]
934    fn update_then_load_roundtrips_and_preserves_optionals() {
935        let dir = tmpdir("roundtrip");
936        let path = dir.join("registry.json");
937        update_registry(&path, |r| r.entries.push(sample_entry("worker-A"))).unwrap();
938
939        // A second update that only flips status must preserve codex_session_id.
940        update_registry(&path, |r| {
941            r.find_mut("worker-A").unwrap().status = AgentStatus::Idle;
942        })
943        .unwrap();
944
945        let reg = load_registry(&path).unwrap();
946        let e = reg.find("worker-A").unwrap();
947        assert_eq!(e.status, AgentStatus::Idle);
948        assert_eq!(e.codex_session_id.as_deref(), Some("uuid-1"));
949        assert_eq!(e.pid, Some(1234));
950        std::fs::remove_dir_all(&dir).ok();
951    }
952
953    #[test]
954    fn state_json_absent_is_none_present_roundtrips() {
955        let dir = tmpdir("state");
956        let path = dir.join("wkA/state.json");
957        assert!(load_state(&path).unwrap().is_none());
958
959        let st = AgentState::new_pty("wkA");
960        write_state_atomic(&path, &st).unwrap();
961        let back = load_state(&path).unwrap().unwrap();
962        assert_eq!(back.short_id, "wkA");
963        assert_eq!(back.status, AgentStatus::Spawning);
964        assert!(back.pty.is_some());
965        std::fs::remove_dir_all(&dir).ok();
966    }
967
968    #[test]
969    fn empty_state_file_treated_as_absent() {
970        // Recovery's "registry entry with partial state.json" path: a present
971        // but empty file must read as None (-> inconsistent), never an error.
972        let dir = tmpdir("empty-state");
973        let path = dir.join("state.json");
974        std::fs::write(&path, b"").unwrap();
975        assert!(load_state(&path).unwrap().is_none());
976        std::fs::remove_dir_all(&dir).ok();
977    }
978
979    #[test]
980    fn take_active_drive_reads_before_clear() {
981        // The recovery ordering invariant in miniature: the returned value
982        // carries the session id, and after the call the window is cleared.
983        let mut pty = PtyState {
984            active: true,
985            drive: Some(DriveWindow {
986                session_id: Some("drive-uuid".into()),
987                mode: Some("interactive".into()),
988                last_heartbeat_at_monotonic_ns: Some(42),
989            }),
990        };
991        let taken = pty.take_active_drive().expect("a drive was active");
992        assert_eq!(taken.session_id.as_deref(), Some("drive-uuid"));
993        assert_eq!(taken.mode.as_deref(), Some("interactive"));
994        // Cleared after read.
995        assert!(pty.drive.is_none());
996        // Idempotent: a second take finds nothing.
997        assert!(pty.take_active_drive().is_none());
998    }
999
1000    #[test]
1001    fn take_active_drive_none_when_no_drive() {
1002        let mut pty = PtyState::default();
1003        assert!(pty.take_active_drive().is_none());
1004    }
1005
1006    #[test]
1007    fn pty_state_wire_shape_is_flat_and_stable() {
1008        // The Option<DriveWindow> in-memory shape must still serialize to the
1009        // flat state.json schema (Wave 7 cross-language parity).
1010        let no_drive = PtyState {
1011            active: true,
1012            drive: None,
1013        };
1014        assert_eq!(
1015            serde_json::to_value(&no_drive).unwrap(),
1016            serde_json::json!({"active": true, "drive_active": false})
1017        );
1018
1019        let with_drive = PtyState {
1020            active: true,
1021            drive: Some(DriveWindow {
1022                session_id: Some("d-1".into()),
1023                mode: Some("interactive".into()),
1024                last_heartbeat_at_monotonic_ns: Some(99),
1025            }),
1026        };
1027        assert_eq!(
1028            serde_json::to_value(&with_drive).unwrap(),
1029            serde_json::json!({
1030                "active": true,
1031                "drive_active": true,
1032                "drive_session_id": "d-1",
1033                "drive_mode": "interactive",
1034                "last_heartbeat_at_monotonic_ns": 99
1035            })
1036        );
1037        // Roundtrips back to the same typed value.
1038        let back: PtyState =
1039            serde_json::from_value(serde_json::to_value(&with_drive).unwrap()).unwrap();
1040        assert_eq!(back, with_drive);
1041    }
1042
1043    #[test]
1044    fn pty_state_collapses_inconsistent_legacy_shape() {
1045        // A legacy/partial file with drive_active:false but a stray session_id
1046        // deserializes to drive: None - the inconsistent state is normalized
1047        // away rather than carried.
1048        let legacy = serde_json::json!({
1049            "active": true,
1050            "drive_active": false,
1051            "drive_session_id": "stray",
1052        });
1053        let pty: PtyState = serde_json::from_value(legacy).unwrap();
1054        assert!(pty.drive.is_none());
1055    }
1056}