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, ®istry)?;
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(®).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(®).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(®).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 = ®.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(®).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}