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::collections::{BTreeMap, BTreeSet};
23use std::fs::{File, OpenOptions};
24use std::io::{Read, Write};
25use std::path::{Path, PathBuf};
26
27/// Current registry schema version.
28///
29/// v4 (ab-a171ceb2) is a forward-compat bump for `host_mode`: v4 is
30/// structurally identical to v3 (host_mode is additive-optional and read
31/// version-independently via absent==exec coercion), but stamping v4 forces a
32/// pre-host_mode reader - which accepts only {1,2,3} and has no host_mode code
33/// - to REJECT the store rather than silently treat an interactive row as exec
34/// and orphan a live TUI during reconcile. Readers stay backward-compatible:
35/// the accepted-version set still spans 1..=4 (see ACCEPTED_SCHEMA_VERSIONS in
36/// client_verbs.rs and the Python load_registry range check).
37///
38/// v5 (inside-out E3.1, X2/X3) is the same kind of forward-compat bump for the
39/// additive `inside_leg` field: structurally identical to v4 (an absent
40/// `inside_leg` reads as `None`), but stamping v5 forces a pre-inside-leg reader
41/// to REJECT rather than silently DROP a stored inside-leg report on write-back
42/// (Rust serde has no `deny_unknown_fields`, so an old daemon would otherwise
43/// round-trip the field out of existence). Accepted set widens to 1..=5.
44///
45/// v6 (mux agent edge, 4a-G2) is the same kind of forward-compat bump for the
46/// additive `mux` ref: structurally identical to v5 (an absent `mux` reads as
47/// `None`), but stamping v6 forces a pre-mux reader to REJECT rather than
48/// silently drop the ref on write-back - losing it would orphan a live
49/// mux-hosted agent (badges, inject, and list all dispatch on the ref during
50/// the dual-run window). Accepted set widens to 1..=6.
51///
52/// v7 (screen-manifest fallback authority) is the same bump for the additive
53/// `screen_state` verdict: absent reads as `None`, but a pre-v7 writer would
54/// silently drop a stored verdict on write-back and blind the manifest rung
55/// of the badge lattice. Accepted set widens to 1..=7.
56// v8 (x-ec59) is the canonical-identity bump for `harness` / `harness_session_id`
57// (mirrors Python's SCHEMA_VERSION): a pre-v8 reader rejects the store rather than
58// silently dropping the canonical fields on a read-modify-write.
59//
60// v9 (x-1b1e) removes `claude_short_id`: the claude jobId (a pure prefix of the
61// session UUID) now lives in `short_id`, unifying the transport-key field across
62// providers. A legacy row's `claude_short_id` backfills into `short_id` on load
63// (see `backfill_short_id`); a pre-v9 reader must reject a v9 store rather than
64// drop the jobId on a read-modify-write. Accepted set widens to 1..=9.
65//
66// v10 (x-880e) removes the on-disk `provider` field and the legacy per-provider
67// session-id trio (`codex_session_id`, `gemini_session_id`, `claude_session_uuid`):
68// `harness` is the sole identity axis and `harness_session_id` the sole session id.
69// A legacy row's `provider` backfills `legacy_provider` -> `harness`, and each
70// per-provider key backfills `harness_session_id`, at load (accept-on-read); those
71// keys are `skip_serializing` so they never round-trip. A pre-v10 reader must reject
72// a v10 store rather than mis-read a harness-only row. Accepted set widens to 1..=10.
73//
74// v11 (US9) adds the crown fields (`crown_level`/`crown_scope`/`crown_grantor`),
75// mirrored here as additive-optional passthrough so the daemon preserves a
76// spawn-stamped crown across a read-modify-write (a Python-only field would be
77// dropped when the daemon re-serializes the row). Python's asdict emits them on
78// every written row, so a pre-v11 reader must reject a v11 store rather than
79// TypeError on the unknown keys. Accepted set widens to 1..=11.
80pub const REGISTRY_SCHEMA_VERSION: u32 = 11;
81/// Current per-agent state schema version (design: schema v1).
82pub const STATE_SCHEMA_VERSION: u32 = 1;
83
84/// Errors from state-file access.
85#[derive(Debug, thiserror::Error)]
86pub enum StateError {
87    #[error("state io error: {0}")]
88    Io(#[from] std::io::Error),
89    #[error("state json error: {0}")]
90    Json(#[from] serde_json::Error),
91    #[error(
92        "registry schema_version {found} unsupported; this fno understands 1..={max}. \
93         Upgrade or downgrade fno to match."
94    )]
95    UnsupportedSchemaVersion { found: u32, max: u32 },
96    #[error("registry invariant violation: {0}")]
97    InvariantViolation(String),
98}
99
100/// The daemon-owned agent registry (`~/.fno/agents/registry.json`).
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct Registry {
103    pub schema_version: u32,
104    /// Rows. Python's `registry.write_registry` (cli/.../agents/registry.py)
105    /// stores these under the canonical top-level `"agents"` key and reads ONLY
106    /// that key (no `entries` fallback). Serialize under `agents` so a Rust write
107    /// verb (`rm`/`stop`/reconcile) that rewrites a Python-authored registry
108    /// leaves it readable by Python rather than stranding the surviving rows
109    /// under an `entries` key Python ignores (Codex P1, PR #364). `alias =
110    /// "entries"` keeps reading older daemon-written registries. Combined with
111    /// ab-e5a57efa this makes the typed read path parse Python registries.
112    #[serde(default, rename = "agents", alias = "entries")]
113    pub entries: Vec<RegistryEntry>,
114}
115
116impl Default for Registry {
117    fn default() -> Self {
118        Registry {
119            schema_version: REGISTRY_SCHEMA_VERSION,
120            entries: Vec::new(),
121        }
122    }
123}
124
125impl Registry {
126    /// Find an entry by agent name.
127    pub fn find(&self, name: &str) -> Option<&RegistryEntry> {
128        self.entries.iter().find(|e| e.name == name)
129    }
130
131    /// Mutable find by agent name.
132    pub fn find_mut(&mut self, name: &str) -> Option<&mut RegistryEntry> {
133        self.entries.iter_mut().find(|e| e.name == name)
134    }
135}
136
137/// Inside-leg agent state (inside-out multiplexer E3, "contract v2"). The inside
138/// leg is a hook that reports a claude pane's lifecycle state WITHOUT spawning or
139/// sending keystrokes; the daemon stores its latest report on the registry row.
140/// Serializes lowercase (`working` / `blocked` / `done`) to match herdr's
141/// `report_agent` wire shape. PTY liveness (`ConnState::Exited`) always overrides
142/// this badge -- a dead pane is never resurrected by a stale inside-leg state
143/// (umbrella Locked Decision D4).
144#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(rename_all = "lowercase")]
146pub enum InsideLegState {
147    Working,
148    Blocked,
149    Done,
150}
151
152/// The stored form of one inside-leg report (contract v2: X2). The wire payload
153/// the daemon receives is `{session_id, seq, state, reason?, ttl_ms?}`; the
154/// daemon adds `received_at` and stores the rest here on the [`RegistryEntry`].
155/// `seq` is per-`session_id` monotonic so a reordered/duplicate report can be
156/// dropped (`seq <= last_seq`); `ttl_ms` bounds how long the badge stays live
157/// before it ages to unknown. NOTE (E3.1 scope): this struct is the storage
158/// CONTRACT only -- the seq-drop, TTL-aging, and 3-tier authority BEHAVIOUR that
159/// consume these fields land in E3.2/E3.3. Mirrored in Python's `AgentEntry`
160/// (`inside_leg: Optional[dict]`, a lossless passthrough) so a row round-trips
161/// across the mixed-language registry (X3 / ab-b946b59c).
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
163pub struct InsideLegReport {
164    pub state: InsideLegState,
165    pub seq: u64,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub reason: Option<String>,
168    pub received_at: String,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub ttl_ms: Option<u64>,
171}
172
173/// The stored form of one screen-manifest verdict (the fallback rung of the
174/// badge lattice: pane-exit > hook > screen-manifest > liveness). Written only
175/// by the daemon's scrape sweep, and ONLY for rows with no `inside_leg`
176/// authority (per-capability arbitration: a hook-bearing agent is never
177/// scraped). `state` is the manifest vocabulary (`working`/`idle`/`blocked` -
178/// note `idle`, not the hook's `done`); `rule` is the matched
179/// [`crate::manifest::ManifestRule`] id, kept for the `detect explain`
180/// surface; `seq` is per-row monotonic so verdict history orders; `at` is the
181/// registry's `YYYY-MM-DDThh:mm:ssZ` stamp and `ttl_ms` bounds reader trust
182/// exactly like `inside_leg.received_at`/`ttl_ms` (the sweep refreshes `at`
183/// before it lapses, so a live daemon keeps a steady verdict fresh; a dead
184/// daemon's last verdict ages out instead of pinning a stale badge). Mirrored
185/// in Python's `AgentEntry` as `screen_state: Optional[dict]` (X3 passthrough).
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
187pub struct ScreenStateReport {
188    pub state: String,
189    pub rule: String,
190    pub seq: u64,
191    pub at: String,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub ttl_ms: Option<u64>,
194    /// (x-c929) The answerable-prompt payload when this `blocked` verdict came
195    /// from a rule with an `[answer]` grammar and the region yielded a clean
196    /// numbered menu; `None` for every other state or a focus-only blocked
197    /// prompt. Rides the badge to the sideline (JSON passthrough); the mux
198    /// server re-verifies its fingerprint before injecting a picked answer.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub answerable: Option<crate::manifest::AnswerablePrompt>,
201}
202
203impl ScreenStateReport {
204    /// True while this verdict is trustworthy at `now_secs` - the same aging
205    /// discipline as [`InsideLegReport::is_live_at`]: no `ttl_ms` never
206    /// self-ages; a TTL'd verdict expires once `at + ttl_ms` passes; an
207    /// unparseable `at` fails CLOSED (expired, liveness-only).
208    pub fn is_live_at(&self, now_secs: u64) -> bool {
209        let Some(ttl_ms) = self.ttl_ms else {
210            return true;
211        };
212        match rfc3339_like_to_secs(&self.at) {
213            Some(recv) => now_secs.saturating_sub(recv).saturating_mul(1000) <= ttl_ms,
214            None => false,
215        }
216    }
217}
218
219impl InsideLegReport {
220    /// True when this report is still authoritative at `now_secs` (epoch
221    /// seconds), the TTL half of the 3-tier authority lattice (inside-out E3.3,
222    /// AC-X2-2). A report with no `ttl_ms` never ages out on its own -- it is
223    /// cleared only by the ordered exit teardown, a `done`, or a newer report.
224    /// A report WITH a ttl expires once `received_at + ttl_ms` has passed, so a
225    /// `working` whose inside-leg process died (PTY still alive, exit-override
226    /// never fires) cannot pin a permanent stale badge. A `received_at` that
227    /// does not parse fails CLOSED (treated as expired -> the scraper takes
228    /// over), never as live: a corrupt stamp must not be the thing that pins a
229    /// forever-`working`.
230    pub fn is_live_at(&self, now_secs: u64) -> bool {
231        let Some(ttl_ms) = self.ttl_ms else {
232            return true;
233        };
234        match rfc3339_like_to_secs(&self.received_at) {
235            Some(recv) => now_secs.saturating_sub(recv).saturating_mul(1000) <= ttl_ms,
236            None => false,
237        }
238    }
239
240    /// True when `received_at` is within `window_secs` of `now_secs` -- a plain
241    /// recency test (distinct from `is_live_at`, which never ages a report that
242    /// carries no `ttl_ms`). Used as the "provably live" signal that stops an
243    /// ask/mail routing miss from false-orphaning a live worker (x-c393). An
244    /// unparseable stamp fails CLOSED (not recent), so a corrupt row can never
245    /// shield a dead session from orphaning.
246    pub fn received_within(&self, now_secs: u64, window_secs: u64) -> bool {
247        match rfc3339_like_to_secs(&self.received_at) {
248            // A future stamp (recv > now) is corrupt/clock-skewed, not recent:
249            // require recv <= now so it cannot suppress orphaning (fail closed).
250            Some(recv) => recv <= now_secs && now_secs - recv <= window_secs,
251            None => false,
252        }
253    }
254}
255
256/// True when a badge report ENTERS `target` from a different prior state (x-dd84).
257/// This is the whole episode gate for the OS-notification wire: firing only on
258/// the edge INTO `blocked`/`done` means a repeat report at `target` (prev already
259/// `target`) does not re-fire, and a return to `working` then back to `blocked`
260/// fires once more - "once per blocked episode" with no per-row bookkeeping. A
261/// missing prior report (`None`) counts as entering.
262pub fn enters(prev: Option<InsideLegState>, new: InsideLegState, target: InsideLegState) -> bool {
263    new == target && prev != Some(target)
264}
265
266/// Parse the fixed `YYYY-MM-DDThh:mm:ssZ` UTC stamp the registry writes
267/// (`now_rfc3339_like`) back to epoch seconds. Inverse of the daemon's `civil`
268/// (epoch -> civil) helper, using Howard Hinnant's days-from-civil. Returns
269/// `None` for any shape that is not exactly that form (wrong length, non-digit
270/// fields, missing separators) so a malformed or legacy stamp fails the TTL
271/// gate closed rather than pinning a stale badge. Fractional seconds / offsets
272/// are intentionally unsupported: the only producer is `now_rfc3339_like`,
273/// which never emits them.
274pub fn rfc3339_like_to_secs(s: &str) -> Option<u64> {
275    let b = s.as_bytes();
276    // "2026-06-27T00:00:00Z" == 20 bytes, separators at fixed offsets.
277    if b.len() != 20
278        || b[4] != b'-'
279        || b[7] != b'-'
280        || b[10] != b'T'
281        || b[13] != b':'
282        || b[16] != b':'
283        || b[19] != b'Z'
284    {
285        return None;
286    }
287    // Parse the digits straight from the validated byte slice -- no UTF-8
288    // boundary check or temporary allocation, and an explicit non-digit reject
289    // (gemini review).
290    let num = |lo: usize, hi: usize| -> Option<i64> {
291        let mut val = 0i64;
292        for &ch in b.get(lo..hi)? {
293            if !ch.is_ascii_digit() {
294                return None;
295            }
296            val = val * 10 + i64::from(ch - b'0');
297        }
298        Some(val)
299    };
300    let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
301    let (h, mi, se) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
302    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 60 {
303        return None;
304    }
305    // days_from_civil (Hinnant): days since 1970-01-01 for a proleptic Gregorian
306    // y/m/d. Mirrors the daemon's `civil` constants in reverse.
307    let yy = if mo <= 2 { y - 1 } else { y };
308    let era = if yy >= 0 { yy } else { yy - 399 } / 400;
309    let yoe = yy - era * 400;
310    let mp = if mo > 2 { mo - 3 } else { mo + 9 };
311    let doy = (153 * mp + 2) / 5 + d - 1;
312    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
313    let days = era * 146_097 + doe - 719_468;
314    let secs = days * 86_400 + h * 3600 + mi * 60 + se;
315    u64::try_from(secs).ok()
316}
317
318/// Where a mux-hosted agent's PTY lives (4a-G2, brief Locked 4/7): the mux
319/// session name + the pane id `fno mux pane run` printed. A row carries
320/// exactly ONE live ref - `mux` XOR a worker-socket identity (non-empty
321/// `short_id`) XOR a `claude --bg` thread (`claude_short_id`) - enforced at
322/// write time by [`validate_single_live_ref`]; every consumer (list, badges,
323/// inject) dispatches on the ref during the G2-G4 dual-run window. Mirrored in
324/// Python's `AgentEntry` as `mux: Optional[dict]` (X3 rule).
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326pub struct MuxRef {
327    pub session: String,
328    pub pane_id: u64,
329}
330
331/// One registry row (design schema v6). Optional fields default to `None` and
332/// are preserved across `update_registry` because the whole row round-trips
333/// through this typed struct.
334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
335pub struct RegistryEntry {
336    pub name: String,
337    /// Daemon-set PTY field. Python's `AgentEntry` now mirrors it as
338    /// `short_id: str = ""` (ab-b946b59c) so a real PTY row in a mixed registry
339    /// is Python-readable and round-trips losslessly; `skip_serializing_if`
340    /// still drops it when empty so a *Rust*-authored exec/ask row stays slim and
341    /// a round-tripped Python row omits it (default-to-empty on read, ab-e5a57efa;
342    /// Codex P1, PR #364). A real daemon PTY agent always has a non-empty
343    /// short_id, so it still serializes for those rows; conversely a one-shot
344    /// `ask` row always has an empty short_id (no worker-socket identity). That
345    /// exclusivity is what [`RegistryEntry::is_one_shot_ask`] keys on -- a
346    /// non-empty short_id on an ask row, or an empty one on a PTY row, is a
347    /// producer bug. (Python mirrors with a `str` default, not `Option`, because
348    /// a `"short_id": null` would fail this `String` field's deserialize.)
349    #[serde(default, skip_serializing_if = "String::is_empty")]
350    pub short_id: String,
351    /// v10 backfill-only (x-880e): the removed on-disk `provider` key. Deserialized
352    /// under its old name so a legacy row's identity survives the read, but NEVER
353    /// serialized -- [`RegistryEntry::backfill_harness_aliases`] moves it into
354    /// `harness` at load. This is the Rust mirror of Python's `load_registry`
355    /// popping `provider`. `harness` is the sole on-disk identity axis.
356    #[serde(default, rename = "provider", skip_serializing)]
357    pub legacy_provider: String,
358    pub cwd: String,
359    /// Daemon-set PTY field, mirrored in Python's `AgentEntry` as
360    /// `project_root: str = ""` (ab-b946b59c; see `short_id`): default on read,
361    /// skip-when-empty on write.
362    #[serde(default, skip_serializing_if = "String::is_empty")]
363    pub project_root: String,
364    /// On disk this is Rust-set only (Python's `session_id` is a computed
365    /// `@property`, excluded from its serialized rows): skip when absent so
366    /// Python can read a Rust-written row (Codex P1). When a Rust PTY row DOES
367    /// record one, Python's load_registry drops the key before constructing the
368    /// entry and recomputes the same projection from the *_session_id fields
369    /// (ab-b946b59c).
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub session_id: Option<String>,
372    /// The FULL claude session UUID -- the stream-json `--resume` target,
373    /// distinct from the 8-hex jobId in `short_id`. v10 (x-880e): a load-derived
374    /// in-memory alias only. `skip_serializing` keeps it off disk (harness_session_id
375    /// is the sole persisted session id); `backfill_harness_aliases` populates it
376    /// from `harness_session_id` on load, so the ~30 daemon read sites need no churn.
377    /// A post-load mutation of this field is synced back into `harness_session_id`
378    /// at the write choke point (AC6-FR). [stream-json host lane node]
379    #[serde(default, skip_serializing)]
380    pub claude_session_uuid: Option<String>,
381    /// Canonical harness identity (x-ec59), mirroring Python's `AgentEntry`:
382    /// `harness` is the harness name (identity only -- `provider` stays
383    /// load-bearing for dispatch) and `harness_session_id` is the worker's own
384    /// session id in its harness's store. Both additive-optional, back-filled
385    /// from the legacy per-provider fields at load via
386    /// [`RegistryEntry::backfill_harness_aliases`] so a Rust reader of a legacy
387    /// row and a Python reader of a Rust-minted canonical row both resolve.
388    /// Skip-when-`None` keeps a Rust-authored row slim; Python's `asdict` always
389    /// emits the key, so a Python row round-trips fine.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub harness: Option<String>,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub harness_session_id: Option<String>,
394    /// Daemon-set PTY field, mirrored in Python's `AgentEntry` (ab-b946b59c):
395    /// skip when absent (Codex P1).
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub messaging_socket_path: Option<String>,
398    // v10 (x-880e): load-derived in-memory aliases only; skip_serializing keeps
399    // them off disk (harness_session_id is the sole persisted session id) and
400    // backfill_harness_aliases populates them on load, so daemon read sites need
401    // no churn. A post-load mutation syncs back at the write choke point (AC6-FR).
402    #[serde(default, skip_serializing)]
403    pub codex_session_id: Option<String>,
404    #[serde(default, skip_serializing)]
405    pub gemini_session_id: Option<String>,
406    #[serde(default)]
407    pub mcp_channel_id: Option<String>,
408    /// Hosting mode: absent/`None` == `"exec"` (one-shot, the default for every
409    /// pre-existing row), `Some("interactive")` == a long-lived drivable TUI
410    /// (`fno agents host`/`promote`). Skip-when-`None` so a *Rust*-authored exec
411    /// row omits the key; Python's missing-key coercion then maps the absence
412    /// back to `"exec"`. (Python itself always emits the key via `asdict` -- as
413    /// `"exec"` or `"interactive"` -- and Rust reads the concrete value fine, so
414    /// both directions agree.) Consumers must read it via
415    /// [`RegistryEntry::host_mode_or_default`], never the raw `Option`, so the
416    /// absent==exec rule lives in one place. [interactive-drive node]
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub host_mode: Option<String>,
419    /// Daemon-set PTY field, mirrored in Python's `AgentEntry` (ab-b946b59c):
420    /// skip when absent (Codex P1).
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub cc_session_id: Option<String>,
423    pub status: AgentStatus,
424    #[serde(default)]
425    pub last_message_at: Option<String>,
426    pub created_at: String,
427    /// Daemon-set PTY field, mirrored in Python's `AgentEntry` as
428    /// `pid: Optional[int]` (ab-b946b59c): skip when absent so a round-tripped
429    /// Python row stays slim and Python-readable (Codex P1).
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub pid: Option<u32>,
432    /// The worker process's start time, captured alongside `pid` at spawn, used
433    /// to detect PID reuse: a liveness/reap/signal decision treats `pid` as "our
434    /// worker" only if the live process's start time still matches this
435    /// (ab-d19e6458). Per-host, per-boot value (Linux: `/proc/<pid>/stat` field
436    /// 22 in clock ticks; macOS: `kinfo_proc` start `timeval` in microseconds) —
437    /// only ever compared for equality against a fresh read of the SAME pid, so
438    /// the unit/epoch difference across platforms is irrelevant. Daemon-set PTY
439    /// field, mirrored in Python's `AgentEntry` (ab-b946b59c); skip when absent.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub pid_start_time: Option<u64>,
442    #[serde(default)]
443    pub log_path: Option<String>,
444    /// Timestamp of the most recent reconcile probe (finding #1 High): the
445    /// reconcile sweep orders entries by ASC `last_reconciled_at` so a
446    /// budget-exhausted sweep stays fair across a large registry. Daemon-set,
447    /// mirrored in Python's `AgentEntry` (ab-b946b59c); skip when absent (Codex P1).
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub last_reconciled_at: Option<String>,
450    /// Latest inside-leg report for this row's claude pane (inside-out E3,
451    /// contract v2). `None` for every non-inside-leg row (the default for every
452    /// pre-existing row, and for any provider/lane that does not run a hook).
453    /// Skip-when-`None` so a row without a report stays slim and a stale reader
454    /// rejects via the v5 schema bump rather than silently dropping it. Mirrored
455    /// in Python's `AgentEntry` as `inside_leg: Optional[dict]` (X3 / ab-b946b59c).
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub inside_leg: Option<InsideLegReport>,
458    /// When the dead-row GC first observed this row's backing process as gone
459    /// (ISO 8601 UTC), stamped by the GC sweep on the first tick it sees the row
460    /// terminal/dead and cleared again if the row re-registers live (x-b1aa). It
461    /// anchors the `config.agents.dead_row_grace` window: a row is reaped only
462    /// once `now - exited_at` is strictly past the grace. Deliberately NOT set at
463    /// the status->Exited transition (reconcile re-stamps `last_reconciled_at` on
464    /// every probe, so that field can't anchor a stable clock); the GC's
465    /// first-observation stamp is stable until the row is reaped or resurrected.
466    /// Daemon-set, mirrored in Python's `AgentEntry` as `exited_at`; skip when
467    /// absent so a pre-GC row round-trips losslessly (additive-optional, no
468    /// schema bump).
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub exited_at: Option<String>,
471    /// The mux hosting ref for a pane-substrate agent (4a-G2): `Some` means
472    /// this row's PTY is a pane in `mux.session`, and pane-exit facts /
473    /// live-inject / sideline badges all key on it. `None` for every daemon
474    /// worker, bg-thread, and headless row. One live ref per row (mux XOR
475    /// worker XOR bg) - see [`MuxRef`] and [`validate_single_live_ref`].
476    /// Skip-when-`None` so a pre-mux row stays slim; a stale reader rejects
477    /// via the v6 schema bump rather than silently dropping the ref. Mirrored
478    /// in Python's `AgentEntry` as `mux: Optional[dict]` (X3).
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub mux: Option<MuxRef>,
481    /// Latest screen-manifest verdict for this row's mux pane (v7, the
482    /// fallback rung under the hook). Daemon-scrape-set, and mutually
483    /// exclusive with a live `inside_leg` authority BY THE WRITER (the sweep
484    /// skips hook-bearing rows; the inside-leg store clears this field on the
485    /// capability flip) - readers still treat inside_leg as unconditionally
486    /// senior, defense in depth. Skip-when-`None` so an unscraped row stays
487    /// slim and a stale reader rejects via the v7 bump rather than silently
488    /// dropping a verdict. Mirrored in Python's `AgentEntry` as
489    /// `screen_state: Optional[dict]` (X3).
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub screen_state: Option<ScreenStateReport>,
492    /// Crown fields (US9, v11): who holds an orchestrator crown and at what
493    /// altitude. The Python spawn path is the sole writer (grantor-stamped,
494    /// never self-declared); the daemon only custodies them so a spawn-stamped
495    /// crown round-trips losslessly across a read-modify-write - the same X3
496    /// passthrough treatment as `inside_leg`/`screen_state`. Skip-when-`None`
497    /// keeps a Rust-authored uncrowned row slim; Python's `asdict` always emits
498    /// the keys, so a crowned Python row round-trips fine. Crown liveness ==
499    /// this row's liveness (no separate lifecycle).
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub crown_level: Option<u32>,
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub crown_scope: Option<String>,
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub crown_grantor: Option<String>,
506    /// v9 backfill-only (x-1b1e): the removed `claude_short_id`. Deserialized
507    /// (under its old key) so a legacy row's jobId survives the read, but NEVER
508    /// serialized -- [`RegistryEntry::backfill_short_id`] moves it into
509    /// `short_id` at load and clears it, so it never round-trips. This is the
510    /// Rust mirror of Python's `load_registry` popping `claude_short_id` from the
511    /// raw row. Not part of identity; no consumer reads it directly.
512    #[serde(default, rename = "claude_short_id", skip_serializing)]
513    pub legacy_claude_short_id: Option<String>,
514}
515
516/// The one-live-ref invariant (brief Locked 7), checked at write time by both
517/// [`update_registry`] (Rust) and Python's `write_registry`: a row that carries
518/// the `mux` ref must not ALSO carry a transport identity (non-empty `short_id`:
519/// a worker-socket key or, since v9, a `claude --bg` jobId) - a double-ref row
520/// would make consumers dispatch the same agent down two substrates. Scoped to
521/// mux rows only: pre-existing worker/bg field combinations are not this
522/// invariant's business. (Backfill runs before this check, so a legacy bg
523/// row's jobId is already in `short_id`.)
524pub fn validate_single_live_ref(entry: &RegistryEntry) -> Result<(), String> {
525    if entry.mux.is_none() {
526        return Ok(());
527    }
528    if !entry.short_id.is_empty() {
529        return Err(format!(
530            "registry row {:?} carries a mux ref alongside a worker/bg ref; a row holds exactly one live ref (mux XOR worker XOR bg)",
531            entry.name,
532        ));
533    }
534    Ok(())
535}
536
537/// `host_mode` value for a one-shot exec session (the default when absent).
538pub const HOST_MODE_EXEC: &str = "exec";
539/// `host_mode` value for a long-lived drivable interactive session.
540pub const HOST_MODE_INTERACTIVE: &str = "interactive";
541/// `host_mode` value for an ADOPTED `claude --bg` session footnote holds live via
542/// a daemon `control.sock` attach (G1 held-attach substrate, x-26df). Distinct
543/// from `interactive` (a footnote-SPAWNED PTY worker): an `attached` row's process
544/// is Claude's, not footnote's, and it is driven over the held attach, not a
545/// worker socket. G2 teaches grid to consume it; the standard worker reconcile
546/// must not treat it as a managed PTY worker.
547pub const HOST_MODE_ATTACHED: &str = "attached";
548
549/// Claude spawn `mode` (D2, inside-out-multiplexer E1). Disambiguates the two
550/// claude PTY lanes WITHIN an interactive `host_mode`: `stream_json` is the
551/// Agent-SDK adoption lane (`claude -p --resume`, billed against the SDK pool);
552/// `interactive` is the subscription-billed `ClaudeProvider` PTY lane (the
553/// keystone). Absent reads as `stream_json` so every existing promote call site
554/// keeps its current behavior; grid/relay request `interactive` explicitly. The
555/// daemon routes on this field, never on a guess.
556pub const CLAUDE_MODE_STREAM_JSON: &str = "stream_json";
557/// See [`CLAUDE_MODE_STREAM_JSON`]: the interactive subscription-billed lane.
558pub const CLAUDE_MODE_INTERACTIVE: &str = "interactive";
559
560impl RegistryEntry {
561    /// Two-way sync of `harness`/`harness_session_id` with the legacy
562    /// per-provider identity fields (x-ec59), the Rust mirror of Python's
563    /// `harness_identity.sync_harness_aliases` + the registry harness back-fill.
564    /// Applied at load so a Rust reader of a legacy row and a Python reader of a
565    /// Rust-minted canonical row both resolve. `harness` adopts `provider` when
566    /// absent (provider is always set; harness is identity-only, never gates the
567    /// read). Then canonical wins: a set `harness_session_id` syncs the matching
568    /// legacy key (a conflicting legacy value is overwritten, never leaked);
569    /// otherwise the first present legacy value back-fills `harness_session_id`.
570    /// The claude legacy key is `claude_session_uuid` (the registry's identity),
571    /// NOT the manifest's `claude_session_id`.
572    pub fn backfill_harness_aliases(&mut self) {
573        if self.harness.is_none() && !self.legacy_provider.is_empty() {
574            self.harness = Some(self.legacy_provider.clone());
575        }
576        match self.harness_session_id.clone() {
577            Some(hsid) if !hsid.is_empty() => match self.harness.as_deref() {
578                Some("claude") => self.claude_session_uuid = Some(hsid),
579                Some("codex") => self.codex_session_id = Some(hsid),
580                Some("gemini") => self.gemini_session_id = Some(hsid),
581                _ => {}
582            },
583            _ => {
584                // Adopt from THIS harness's own legacy key when known, so a stale
585                // legacy id of a DIFFERENT harness can't cross-contaminate; only a
586                // genuinely unknown harness scans all keys (a pre-migration row
587                // whose harness has not been resolved).
588                let legacy = match self.harness.as_deref() {
589                    Some("claude") => self.claude_session_uuid.clone(),
590                    Some("codex") => self.codex_session_id.clone(),
591                    Some("gemini") => self.gemini_session_id.clone(),
592                    _ => self
593                        .claude_session_uuid
594                        .clone()
595                        .or_else(|| self.codex_session_id.clone())
596                        .or_else(|| self.gemini_session_id.clone()),
597                };
598                if let Some(value) = legacy {
599                    if !value.is_empty() && value != "null" {
600                        self.harness_session_id = Some(value);
601                    }
602                }
603            }
604        }
605    }
606
607    /// v9 transport-key backfill (x-1b1e), the Rust mirror of Python's
608    /// `load_registry` popping the removed `claude_short_id` into `short_id`.
609    /// Applied at load, before [`validate_single_live_ref`]: a legacy row's
610    /// jobId (deserialized into `legacy_claude_short_id`) moves into an empty
611    /// `short_id` and the transient is cleared so it never round-trips. A
612    /// conflicting pair (both set, different values -- the drift this removal
613    /// kills) KEEPS `short_id` and returns the legacy value so the caller can
614    /// warn once; it never silently prefers the legacy value.
615    pub fn backfill_short_id(&mut self) -> Option<String> {
616        let legacy = self.legacy_claude_short_id.take()?;
617        if legacy.is_empty() {
618            return None;
619        }
620        if self.short_id.is_empty() {
621            self.short_id = legacy;
622            None
623        } else if self.short_id != legacy {
624            Some(legacy) // conflict: keep short_id, surface for a warn
625        } else {
626            None
627        }
628    }
629
630    /// The provider transport key (v9, x-1b1e), or `None` when this row has
631    /// none: the non-empty `short_id`. For claude it is the jobId (`claude
632    /// attach/logs <jobId>`); for a daemon PTY row the worker-socket key. The
633    /// single accessor consumers use to reach a session's wire handle, so no
634    /// verb re-implements the empty-string guard. [x-1b1e transport extraction]
635    pub fn transport_short(&self) -> Option<&str> {
636        (!self.short_id.is_empty()).then_some(self.short_id.as_str())
637    }
638
639    /// The row's harness name as a required-string view (x-880e). The single
640    /// accessor every RegistryEntry consumer uses instead of the raw identity
641    /// field, so the provider->harness migration touches one place. `harness` is
642    /// set on load by [`RegistryEntry::backfill_harness_aliases`]; during the
643    /// migration window a not-yet-backfilled fresh row falls back to the legacy
644    /// `provider`. Collapses to `harness`-only once `provider` is removed.
645    pub fn harness_name(&self) -> &str {
646        match self.harness.as_deref() {
647            Some(h) if !h.is_empty() => h,
648            // A not-yet-backfilled fresh row falls back to the load-only
649            // legacy_provider (empty for a v10 row); backfill sets harness on load.
650            _ => &self.legacy_provider,
651        }
652    }
653
654    /// The hosting mode with the absent==exec rule applied in one place.
655    /// `None` on disk (and the legacy rows that predate the field) read as
656    /// [`HOST_MODE_EXEC`]; an explicit value passes through. Reconcile/liveness
657    /// and the spawn path must use this, never the raw `Option`, so a missing
658    /// key can never be mistaken for a non-exec mode. [interactive-drive node]
659    pub fn host_mode_or_default(&self) -> &str {
660        self.host_mode.as_deref().unwrap_or(HOST_MODE_EXEC)
661    }
662
663    /// True when this row is a long-lived interactive host (vs a one-shot exec
664    /// session). The reconcile branch keys off this: an exec worker that exited
665    /// is normal; an interactive worker is expected to stay live until `/quit`.
666    pub fn is_interactive(&self) -> bool {
667        self.host_mode_or_default() == HOST_MODE_INTERACTIVE
668    }
669
670    /// True when this row is a one-shot `ask` agent the daemon does NOT manage as
671    /// a worker process: empty `short_id` (no worker-socket identity) AND no
672    /// recorded `pid`. Such an agent has no process whose liveness could make it
673    /// `live` -- its terminal status is `exited`, and its post-run value is
674    /// *resumability* (a recorded provider session id), surfaced separately from
675    /// status via the `session_id` projection. Only PTY agents (`spawn`/`host`/
676    /// `promote`) carry a non-empty short_id + pid and can be `live`; this is the
677    /// invariant documented on the `short_id` field ("a real daemon PTY agent
678    /// always has a non-empty short_id"). Reconcile uses this to settle a
679    /// finished ask to `exited` by process-liveness alone, never consulting
680    /// session-file reachability for status. [plan ab-70faa65b, Locked Decision #1]
681    pub fn is_one_shot_ask(&self) -> bool {
682        // v9 (x-1b1e) moved the claude jobId from `claude_short_id` into
683        // `short_id`, so a claude shellout (`ask`/`--bg`) row now carries a
684        // non-empty short_id and the empty-short_id proxy no longer catches it.
685        // Mirror recover()'s provider+host_mode guard: a non-interactive claude
686        // row has no daemon PTY, so its surviving session file is a resumability
687        // artifact, not "running" -- without this it would fall through to the
688        // reachability probe and be kept falsely `live` forever.
689        let is_claude_shellout = self.harness_name() == "claude" && !self.is_interactive();
690        // A mux-hosted row (4a-G2) also has an empty short_id and may lack a
691        // pid (the pane-child lookup is best-effort), but it is a LIVE hosted
692        // agent, never a finished ask - without this exclusion the reconcile
693        // sweep would flip it to Exited unprobed (codex P1, PR #142).
694        (self.short_id.is_empty() || is_claude_shellout) && self.pid.is_none() && self.mux.is_none()
695    }
696}
697
698/// Per-agent runtime state (`<short_id>/state.json`, schema v1). `state.status`
699/// is canonical (LD10).
700#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
701pub struct AgentState {
702    pub schema_version: u32,
703    pub short_id: String,
704    pub status: AgentStatus,
705    #[serde(default)]
706    pub ready: bool,
707    #[serde(default)]
708    pub last_message_at: Option<String>,
709    #[serde(default)]
710    pub last_reply: Option<String>,
711    #[serde(default)]
712    pub restart_count: u32,
713    #[serde(default)]
714    pub last_restart_at: Option<String>,
715    /// `None` for shellout (claude) agents; `Some` for PTY-managed agents.
716    #[serde(default)]
717    pub pty: Option<PtyState>,
718}
719
720impl AgentState {
721    /// Construct a fresh PTY-managed agent state.
722    pub fn new_pty(short_id: impl Into<String>) -> Self {
723        AgentState {
724            schema_version: STATE_SCHEMA_VERSION,
725            short_id: short_id.into(),
726            status: AgentStatus::Spawning,
727            ready: false,
728            last_message_at: None,
729            last_reply: None,
730            restart_count: 0,
731            last_restart_at: None,
732            pty: Some(PtyState::default()),
733        }
734    }
735}
736
737/// An open interactive drive window. Bundling the drive facts behind a single
738/// `Option<DriveWindow>` makes the inconsistent `{drive_active: false,
739/// drive_session_id: Some(..)}` state impossible: either there is a window
740/// (`Some`) carrying all its fields, or there is none (`None`).
741#[derive(Debug, Clone, PartialEq, Default)]
742pub struct DriveWindow {
743    pub session_id: Option<String>,
744    pub mode: Option<String>,
745    /// Monotonic-clock baseline of the last drive heartbeat (count-during-sleep
746    /// ns; see [`crate::MonotonicTimestamp`]).
747    pub last_heartbeat_at_monotonic_ns: Option<u64>,
748}
749
750/// PTY sub-state. The on-disk shape stays flat (`active`, `drive_active`,
751/// `drive_session_id`, `drive_mode`, `last_heartbeat_at_monotonic_ns`) via a
752/// hand-written serde impl below, so cross-language schema parity (Wave 7) is a
753/// direct field map; in memory the drive cluster is one `Option<DriveWindow>`.
754#[derive(Debug, Clone, PartialEq, Default)]
755pub struct PtyState {
756    pub active: bool,
757    /// `Some` while an interactive drive window is open; `None` otherwise.
758    pub drive: Option<DriveWindow>,
759}
760
761impl PtyState {
762    /// Recovery step 4/5 ordering primitive (finding #12 Critical): atomically
763    /// READ the active drive window (returning its session id + mode + last
764    /// heartbeat) AND clear it. Callers MUST use the returned value to emit
765    /// `drive_crashed` — the read happens here, before the clear, so the event
766    /// reflects what the window was. Returns `None` if no drive was active.
767    ///
768    /// With the drive cluster behind one `Option`, read-then-clear is just
769    /// `Option::take`: there is no window between the read and the clear for a
770    /// second observer to see a half-cleared state.
771    pub fn take_active_drive(&mut self) -> Option<DriveWindow> {
772        self.drive.take()
773    }
774}
775
776/// Flat on-disk projection of [`PtyState`], mediating between the typed
777/// `Option<DriveWindow>` and the design's flat `state.json` schema. `drive_active`
778/// is the discriminant; the option fields default to `None`/absent.
779#[derive(Serialize, Deserialize)]
780struct PtyStateWire {
781    active: bool,
782    #[serde(default)]
783    drive_active: bool,
784    #[serde(default, skip_serializing_if = "Option::is_none")]
785    drive_session_id: Option<String>,
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    drive_mode: Option<String>,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    last_heartbeat_at_monotonic_ns: Option<u64>,
790}
791
792impl Serialize for PtyState {
793    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
794    where
795        S: serde::Serializer,
796    {
797        let wire = match &self.drive {
798            Some(d) => PtyStateWire {
799                active: self.active,
800                drive_active: true,
801                drive_session_id: d.session_id.clone(),
802                drive_mode: d.mode.clone(),
803                last_heartbeat_at_monotonic_ns: d.last_heartbeat_at_monotonic_ns,
804            },
805            None => PtyStateWire {
806                active: self.active,
807                drive_active: false,
808                drive_session_id: None,
809                drive_mode: None,
810                last_heartbeat_at_monotonic_ns: None,
811            },
812        };
813        wire.serialize(serializer)
814    }
815}
816
817impl<'de> Deserialize<'de> for PtyState {
818    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
819    where
820        D: serde::Deserializer<'de>,
821    {
822        let wire = PtyStateWire::deserialize(deserializer)?;
823        // `drive_active` is canonical for window presence. A legacy/partial file
824        // with the flag clear collapses any stray option fields to `None`, which
825        // is exactly the inconsistent state the refactor makes unrepresentable.
826        let drive = if wire.drive_active {
827            Some(DriveWindow {
828                session_id: wire.drive_session_id,
829                mode: wire.drive_mode,
830                last_heartbeat_at_monotonic_ns: wire.last_heartbeat_at_monotonic_ns,
831            })
832        } else {
833            None
834        };
835        Ok(PtyState {
836            active: wire.active,
837            drive,
838        })
839    }
840}
841
842// ---------------------------------------------------------------------------
843// Locked, atomic file access.
844// ---------------------------------------------------------------------------
845
846/// Load the registry under a shared lock. A missing file yields an empty
847/// registry (0 agents is a valid steady state, not an error). The shared lock
848/// is the daemon-down read path (`fno agents list` when the socket is down)
849/// AND recovery step 1.
850pub fn load_registry(path: &Path) -> Result<Registry, StateError> {
851    // Lock the SAME sidecar `update_registry` locks (shared mode here), not the
852    // data file. This is the canonical cross-language lock target: a Python
853    // `fno` writer taking `flock` on `<registry>.lock` and the Rust daemon's
854    // exclusive write-lock then live in one domain, so reader/writer and
855    // cross-language writers actually mutually exclude (US6.12). Locking the
856    // data file directly would (a) not exclude against the sidecar-based
857    // writer and (b) reintroduce the rename-invalidates-fd footgun.
858    // Acquire the lock FIRST, then decide existence: a `!path.exists()` check
859    // before the lock could race a concurrent writer creating registry.json and
860    // return a stale empty registry (Codex P2). The open-after-lock below is the
861    // authoritative existence check.
862    let lock = acquire_shared(&lock_path(path))?;
863    let result = match OpenOptions::new().read(true).open(path) {
864        Ok(file) => read_registry_tolerant(&file),
865        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
866            let _ = lock.unlock();
867            return Ok(Registry::default());
868        }
869        Err(e) => {
870            let _ = lock.unlock();
871            return Err(e.into());
872        }
873    };
874    let _ = lock.unlock();
875    result
876}
877
878/// Read a registry, tolerating ONLY a genuinely empty file (0 bytes / all
879/// whitespace) as the empty registry. A present-but-unparseable file (malformed
880/// JSON, schema mismatch, corruption) propagates `StateError::Json` instead of
881/// silently defaulting: a default fed back through `update_registry`'s
882/// read-modify-write would publish an empty registry and permanently wipe every
883/// other agent (Gemini high, PR #364). `write_json_atomic` publishes via
884/// tempfile + rename, so a reader never observes a torn write -- a parse failure
885/// is therefore real corruption, not the transient partial read the prior
886/// `unwrap_or_default()` was excusing.
887fn read_registry_tolerant(mut file: &File) -> Result<Registry, StateError> {
888    let mut buf = String::new();
889    file.read_to_string(&mut buf)?;
890    if buf.trim().is_empty() {
891        return Ok(Registry::default());
892    }
893    let mut reg: Registry = serde_json::from_str(&buf)?;
894    // Harness identity back-fill (x-ec59): canonical fields resolve from the
895    // legacy per-provider fields on every load, so a legacy row read by Rust and
896    // a canonical row written by Rust both round-trip. Applied here (the single
897    // read choke point) covers both load_registry and update_registry's RMW read.
898    for entry in &mut reg.entries {
899        entry.backfill_harness_aliases();
900        // v9 transport-key backfill (x-1b1e): move a legacy row's
901        // `claude_short_id` into `short_id`. A conflicting pair keeps `short_id`
902        // and warns once (never silently prefers the legacy value).
903        if let Some(legacy) = entry.backfill_short_id() {
904            eprintln!(
905                "fno agents: warning: registry row {:?} carries short_id={:?} and legacy claude_short_id={:?}; keeping short_id",
906                entry.name, entry.short_id, legacy
907            );
908        }
909    }
910    // Forward-compat guard on the TYPED daemon path (Codex P2, ab-a171ceb2):
911    // the raw client path (client_verbs::load_registry_entries) already rejects
912    // unsupported versions, but the daemon reads through here and previously
913    // accepted any u32. Reject anything outside 1..=REGISTRY_SCHEMA_VERSION so a
914    // pre-inside-leg daemon refuses a v5 store (instead of silently dropping the
915    // inside-leg report) and the current daemon refuses a future v6 store.
916    if reg.schema_version < 1 || reg.schema_version > REGISTRY_SCHEMA_VERSION {
917        return Err(StateError::UnsupportedSchemaVersion {
918            found: reg.schema_version,
919            max: REGISTRY_SCHEMA_VERSION,
920        });
921    }
922    Ok(reg)
923}
924
925/// Read-modify-write the registry under an exclusive lock, publishing the
926/// result atomically (tempfile + rename). The lock is held across the whole
927/// read-modify-write so two daemons (or a daemon and a Python `fno`) never
928/// interleave. The closure mutates the registry in place.
929pub fn update_registry<F, T>(path: &Path, f: F) -> Result<T, StateError>
930where
931    F: FnOnce(&mut Registry) -> T,
932{
933    if let Some(parent) = path.parent() {
934        std::fs::create_dir_all(parent)?;
935    }
936    // Lock on a stable sidecar so the rename of the data file never invalidates
937    // the lock fd (renaming the locked file out from under a held flock is the
938    // classic footgun; locking the sidecar sidesteps it entirely).
939    let lock = acquire_exclusive(&lock_path(path))?;
940    let mut registry = read_existing_registry(path)?;
941    let before = registry
942        .entries
943        .iter()
944        .map(|entry| (entry.name.clone(), identity_signature(entry)))
945        .collect::<BTreeMap<_, _>>();
946    let out = f(&mut registry);
947    // Write-path harness sync (x-880e, AC6-FR): a closure that mutated a legacy
948    // session-id field (the stream-json adopt path writes claude_session_uuid on a
949    // uuid-less bg row) must land the value in harness_session_id before serde
950    // drops the now-skip_serializing legacy key. backfill adopts legacy->canonical
951    // when harness_session_id is unset -- and the only such mutation fires on rows
952    // whose harness_session_id is None -- so no post-load mutation is lost.
953    for entry in &mut registry.entries {
954        entry.backfill_harness_aliases();
955    }
956    validate_changed_identities(&before, &registry.entries)
957        .map_err(StateError::InvariantViolation)?;
958    // One-live-ref invariant (4a-G2), enforced at the single Rust write choke
959    // point so no closure can persist a double-ref row. The lock guard drops
960    // on the early return, so a violation never wedges the registry.
961    for entry in &registry.entries {
962        if let Err(msg) = validate_single_live_ref(entry) {
963            return Err(StateError::InvariantViolation(msg));
964        }
965    }
966    // Upgrade-on-write (Codex P2, ab-a171ceb2): stamp the current schema version
967    // so a Rust write of an older (e.g. v3) store bumps it to v4, matching
968    // Python's write_registry (which always writes SCHEMA_VERSION). Without this,
969    // adding host_mode to an existing v3 registry would leave schema_version:3 and
970    // a pre-host_mode reader would still accept it - defeating the forward-compat
971    // bump for every store that predates it (the common case).
972    registry.schema_version = REGISTRY_SCHEMA_VERSION;
973    write_json_atomic(path, &registry)?;
974    let _ = lock.unlock();
975    Ok(out)
976}
977
978type IdentitySignature = (String, String, String, String);
979
980fn identity_signature(entry: &RegistryEntry) -> IdentitySignature {
981    (
982        entry.name.clone(),
983        entry.short_id.clone(),
984        entry.harness_name().to_string(),
985        entry.harness_session_id.clone().unwrap_or_default(),
986    )
987}
988
989fn validate_changed_identities(
990    before: &BTreeMap<String, IdentitySignature>,
991    entries: &[RegistryEntry],
992) -> Result<(), String> {
993    use crate::identity::{canonical_handle, legacy_prefix_handle, session_handle_tier};
994
995    let matches = |token: &str, other: &RegistryEntry, include_legacy: bool| {
996        if token == other.name || (!other.short_id.is_empty() && token == other.short_id) {
997            return true;
998        }
999        let Some(session_id) = other.harness_session_id.as_deref() else {
1000            return false;
1001        };
1002        match session_handle_tier(token, session_id) {
1003            Some(2) => include_legacy,
1004            Some(_) => true,
1005            None => false,
1006        }
1007    };
1008
1009    for (index, candidate) in entries.iter().enumerate() {
1010        if before.get(&candidate.name) == Some(&identity_signature(candidate)) {
1011            continue;
1012        }
1013        let mut strong = BTreeSet::from([candidate.name.clone()]);
1014        if !candidate.short_id.is_empty() {
1015            strong.insert(candidate.short_id.clone());
1016        }
1017        let session_id = candidate.harness_session_id.as_deref().unwrap_or("");
1018        if !session_id.is_empty() {
1019            strong.insert(session_id.to_string());
1020            strong.insert(canonical_handle(session_id));
1021        }
1022        let legacy = (!session_id.is_empty()).then(|| legacy_prefix_handle(session_id));
1023        for (other_index, other) in entries.iter().enumerate() {
1024            if index == other_index {
1025                continue;
1026            }
1027            let collision = strong
1028                .iter()
1029                .find(|token| matches(token, other, true))
1030                .cloned()
1031                .or_else(|| {
1032                    legacy
1033                        .as_ref()
1034                        .filter(|token| matches(token, other, false))
1035                        .cloned()
1036                });
1037            if let Some(token) = collision {
1038                return Err(format!(
1039                    "registry identity {token:?} for new or changed row {:?} collides with row {:?}; use a different name or the full session id",
1040                    candidate.name, other.name
1041                ));
1042            }
1043        }
1044    }
1045    Ok(())
1046}
1047
1048fn read_existing_registry(path: &Path) -> Result<Registry, StateError> {
1049    match OpenOptions::new().read(true).open(path) {
1050        Ok(file) => read_registry_tolerant(&file),
1051        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
1052        Err(e) => Err(e.into()),
1053    }
1054}
1055
1056/// Load a per-agent `state.json`. `Ok(None)` when the file is absent (recovery
1057/// distinguishes "registry entry without state.json" from a present-but-partial
1058/// state).
1059pub fn load_state(path: &Path) -> Result<Option<AgentState>, StateError> {
1060    // Lock the SAME `.lock` sidecar `write_state_atomic` locks (shared mode),
1061    // not the data file: readers and writers must synchronize on one inode or
1062    // a read can race a concurrent write/rename (Codex P1). Acquire the lock
1063    // BEFORE deciding existence so a writer creating the file mid-call cannot
1064    // be missed.
1065    let lock = acquire_shared(&lock_path(path))?;
1066    let r = match OpenOptions::new().read(true).open(path) {
1067        Ok(file) => read_json::<AgentState>(&file),
1068        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1069            let _ = lock.unlock();
1070            return Ok(None);
1071        }
1072        Err(e) => {
1073            let _ = lock.unlock();
1074            return Err(e.into());
1075        }
1076    };
1077    let _ = lock.unlock();
1078    match r {
1079        Ok(s) => Ok(Some(s)),
1080        // Present but empty/partial: treat as absent state so recovery marks
1081        // the agent inconsistent rather than crashing.
1082        Err(_) => Ok(None),
1083    }
1084}
1085
1086/// Atomically write a per-agent `state.json` (tempfile + rename) under an
1087/// exclusive lock on its sidecar.
1088pub fn write_state_atomic(path: &Path, state: &AgentState) -> Result<(), StateError> {
1089    if let Some(parent) = path.parent() {
1090        std::fs::create_dir_all(parent)?;
1091    }
1092    let lock = acquire_exclusive(&lock_path(path))?;
1093    write_json_atomic(path, state)?;
1094    let _ = lock.unlock();
1095    Ok(())
1096}
1097
1098/// Read-modify-write a per-agent `state.json` while holding the exclusive
1099/// sidecar lock across the WHOLE operation, so concurrent writers cannot
1100/// interleave between the read and the write (the lost-update footgun a
1101/// `load_state` + `write_state_atomic` pair has).
1102///
1103/// Returns `Ok(false)` without calling `f` when the file is absent or partial:
1104/// drive window mutations must never fabricate a `state.json` on the worker's
1105/// behalf (recovery distinguishes "registry entry without state.json"). The
1106/// drive admit / cleanup paths route their window writes through here so a
1107/// stale-driver takeover cannot drop the authority window via a read that
1108/// predates the new driver's write.
1109pub fn update_state_atomic<F>(path: &Path, f: F) -> Result<bool, StateError>
1110where
1111    F: FnOnce(&mut AgentState),
1112{
1113    let lock = acquire_exclusive(&lock_path(path))?;
1114    let existing = match OpenOptions::new().read(true).open(path) {
1115        Ok(file) => read_json::<AgentState>(&file).ok(),
1116        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1117        Err(e) => {
1118            let _ = lock.unlock();
1119            return Err(e.into());
1120        }
1121    };
1122    let result = match existing {
1123        Some(mut st) => {
1124            f(&mut st);
1125            write_json_atomic(path, &st)?;
1126            true
1127        }
1128        None => false,
1129    };
1130    let _ = lock.unlock();
1131    Ok(result)
1132}
1133
1134fn lock_path(path: &Path) -> PathBuf {
1135    let mut s = path.as_os_str().to_os_string();
1136    s.push(".lock");
1137    PathBuf::from(s)
1138}
1139
1140/// Open (creating if needed) the lock sidecar and take an exclusive advisory
1141/// lock, blocking until acquired. The returned `File` holds the lock until it
1142/// is unlocked or dropped.
1143fn acquire_exclusive(lock_file: &Path) -> Result<File, StateError> {
1144    let file = OpenOptions::new()
1145        .create(true)
1146        .read(true)
1147        .write(true)
1148        .truncate(false)
1149        .open(lock_file)?;
1150    file.lock()?;
1151    Ok(file)
1152}
1153
1154/// Open (creating if needed) the lock sidecar and take a shared advisory lock,
1155/// blocking until acquired. Multiple readers share; an exclusive writer
1156/// excludes them. Same sidecar target as [`acquire_exclusive`].
1157fn acquire_shared(lock_file: &Path) -> Result<File, StateError> {
1158    if let Some(parent) = lock_file.parent() {
1159        std::fs::create_dir_all(parent)?;
1160    }
1161    let file = OpenOptions::new()
1162        .create(true)
1163        .read(true)
1164        .write(true)
1165        .truncate(false)
1166        .open(lock_file)?;
1167    file.lock_shared()?;
1168    Ok(file)
1169}
1170
1171fn read_json<T: for<'de> Deserialize<'de>>(mut file: &File) -> Result<T, StateError> {
1172    let mut buf = String::new();
1173    file.read_to_string(&mut buf)?;
1174    Ok(serde_json::from_str(&buf)?)
1175}
1176
1177fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), StateError> {
1178    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1179    std::fs::create_dir_all(parent)?;
1180    let tmp = parent.join(format!(
1181        ".{}.tmp.{}",
1182        path.file_name().and_then(|s| s.to_str()).unwrap_or("state"),
1183        std::process::id()
1184    ));
1185    {
1186        let mut f = OpenOptions::new()
1187            .create(true)
1188            .write(true)
1189            .truncate(true)
1190            .open(&tmp)?;
1191        let bytes = serde_json::to_vec_pretty(value)?;
1192        f.write_all(&bytes)?;
1193        f.sync_all()?;
1194    }
1195    std::fs::rename(&tmp, path)?;
1196    Ok(())
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::*;
1202
1203    #[test]
1204    fn enters_fires_once_per_episode() {
1205        use InsideLegState::{Blocked, Done, Working};
1206        // Walk working -> blocked -> blocked -> blocked -> working -> blocked.
1207        // `enters(.., Blocked)` must be true ONLY on the two edges into blocked
1208        // (positions 2 and 6), not on the repeats within an episode.
1209        let seq = [Working, Blocked, Blocked, Blocked, Working, Blocked];
1210        let fired: Vec<bool> = seq
1211            .iter()
1212            .enumerate()
1213            .map(|(i, &s)| {
1214                let prev = if i == 0 { None } else { Some(seq[i - 1]) };
1215                enters(prev, s, Blocked)
1216            })
1217            .collect();
1218        assert_eq!(fired, [false, true, false, false, false, true]);
1219        // A first-ever report of blocked (prev None) counts as entering.
1220        assert!(enters(None, Blocked, Blocked));
1221        // Done is its own episode axis, independent of blocked.
1222        assert!(enters(Some(Working), Done, Done));
1223        assert!(!enters(Some(Done), Done, Done));
1224    }
1225
1226    fn tmpdir(tag: &str) -> PathBuf {
1227        let mut p = std::env::temp_dir();
1228        p.push(format!(
1229            "fno-agents-state-{}-{}-{}",
1230            tag,
1231            std::process::id(),
1232            std::time::SystemTime::now()
1233                .duration_since(std::time::UNIX_EPOCH)
1234                .unwrap()
1235                .as_nanos()
1236        ));
1237        std::fs::create_dir_all(&p).unwrap();
1238        p
1239    }
1240
1241    fn sample_entry(name: &str) -> RegistryEntry {
1242        RegistryEntry {
1243            name: name.into(),
1244            short_id: format!("{name}-id"),
1245            legacy_provider: "codex".into(),
1246            harness: None,
1247            harness_session_id: None,
1248            cwd: "/tmp/x".into(),
1249            project_root: "/tmp/x".into(),
1250            session_id: Some("uuid-1".into()),
1251            claude_session_uuid: None,
1252            messaging_socket_path: None,
1253            codex_session_id: Some("uuid-1".into()),
1254            gemini_session_id: None,
1255            mcp_channel_id: None,
1256            host_mode: None,
1257            cc_session_id: None,
1258            status: AgentStatus::Live,
1259            last_message_at: None,
1260            created_at: "2026-05-24T00:00:00Z".into(),
1261            pid: Some(1234),
1262            pid_start_time: None,
1263            log_path: None,
1264            last_reconciled_at: None,
1265            inside_leg: None,
1266            exited_at: None,
1267            mux: None,
1268            screen_state: None,
1269            crown_level: None,
1270            crown_scope: None,
1271            crown_grantor: None,
1272            legacy_claude_short_id: None,
1273        }
1274    }
1275
1276    #[test]
1277    fn state_mux_ref_roundtrips_and_python_dict_shape_parses() {
1278        // 4a-G2: the mux ref survives the typed round-trip, and the exact
1279        // JSON shape Python's AgentEntry writes ({"session": ..., "pane_id":
1280        // ...} under "mux") parses back into MuxRef (X3 mixed-language rule).
1281        let mut e = sample_entry("mux-agent");
1282        e.short_id = String::new(); // one live ref: mux only
1283        e.mux = Some(MuxRef {
1284            session: "work".into(),
1285            pane_id: 7,
1286        });
1287        let json = serde_json::to_string(&e).unwrap();
1288        let back: RegistryEntry = serde_json::from_str(&json).unwrap();
1289        assert_eq!(back.mux.as_ref().unwrap().session, "work");
1290        assert_eq!(back.mux.as_ref().unwrap().pane_id, 7);
1291
1292        // Python-authored shape (dict passthrough) parses identically.
1293        let python_row = r#"{"name":"m","provider":"claude","cwd":"/p","log_path":null,
1294            "claude_short_id":null,"codex_session_id":null,"gemini_session_id":null,
1295            "created_at":"2026-07-02T00:00:00Z","status":"live","last_message_at":null,
1296            "mcp_channel_id":null,"mux":{"session":"main","pane_id":3}}"#;
1297        let row: RegistryEntry = serde_json::from_str(python_row).unwrap();
1298        assert_eq!(row.mux.as_ref().unwrap().pane_id, 3);
1299        // A pre-mux row (absent key) reads as None.
1300        assert_eq!(sample_entry("plain").mux, None);
1301    }
1302
1303    #[test]
1304    fn harness_backfill_legacy_row_gains_canonical() {
1305        // x-ec59 / AC1-EDGE: a pre-migration Python row (provider + the legacy
1306        // per-provider uuid, no harness) gains the canonical pair on load.
1307        let python_legacy = r#"{"name":"w","provider":"claude","cwd":"/p","log_path":null,
1308            "claude_short_id":"7c5dcf5d","claude_session_uuid":"UUID-1","codex_session_id":null,
1309            "gemini_session_id":null,"created_at":"2026-07-13T00:00:00Z","status":"live",
1310            "last_message_at":null,"mcp_channel_id":null}"#;
1311        let mut e: RegistryEntry = serde_json::from_str(python_legacy).unwrap();
1312        e.backfill_harness_aliases();
1313        assert_eq!(e.harness.as_deref(), Some("claude"));
1314        assert_eq!(e.harness_session_id.as_deref(), Some("UUID-1"));
1315    }
1316
1317    #[test]
1318    fn backfill_short_id_moves_legacy_into_empty_short() {
1319        // AC2-EDGE (Rust side): a legacy row's claude_short_id moves into short_id.
1320        let legacy = r#"{"name":"w","provider":"claude","cwd":"/p","log_path":null,
1321            "claude_short_id":"7c5dcf5d","created_at":"2026-07-13T00:00:00Z","status":"live"}"#;
1322        let mut e: RegistryEntry = serde_json::from_str(legacy).unwrap();
1323        assert_eq!(e.backfill_short_id(), None);
1324        assert_eq!(e.short_id, "7c5dcf5d");
1325        assert_eq!(e.legacy_claude_short_id, None); // consumed
1326    }
1327
1328    #[test]
1329    fn backfill_short_id_conflict_keeps_short_and_reports_legacy() {
1330        // AC3-EDGE (Rust side): both set, different -> short_id wins, legacy surfaced.
1331        let conflict = r#"{"name":"w","provider":"claude","cwd":"/p","log_path":null,
1332            "short_id":"aaaaaaaa","claude_short_id":"bbbbbbbb",
1333            "created_at":"2026-07-13T00:00:00Z","status":"live"}"#;
1334        let mut e: RegistryEntry = serde_json::from_str(conflict).unwrap();
1335        assert_eq!(e.backfill_short_id().as_deref(), Some("bbbbbbbb"));
1336        assert_eq!(e.short_id, "aaaaaaaa"); // short_id wins
1337    }
1338
1339    #[test]
1340    fn harness_backfill_canonical_only_row_syncs_legacy() {
1341        // A canonical-only row (post-migration mint): the legacy alias is synced
1342        // so an old reader still resolves the session.
1343        let mut e = sample_entry("w");
1344        e.legacy_provider = "claude".into();
1345        e.codex_session_id = None;
1346        e.session_id = None;
1347        e.claude_session_uuid = None;
1348        e.harness = Some("claude".into());
1349        e.harness_session_id = Some("CANON".into());
1350        e.backfill_harness_aliases();
1351        assert_eq!(e.claude_session_uuid.as_deref(), Some("CANON"));
1352    }
1353
1354    #[test]
1355    fn harness_backfill_conflict_is_canonical_wins() {
1356        // AC2-EDGE (Rust side): a conflicting legacy value is overwritten.
1357        let mut e = sample_entry("w");
1358        e.legacy_provider = "claude".into();
1359        e.harness = Some("claude".into());
1360        e.harness_session_id = Some("CANON".into());
1361        e.claude_session_uuid = Some("STALE".into());
1362        e.backfill_harness_aliases();
1363        assert_eq!(e.harness_session_id.as_deref(), Some("CANON"));
1364        assert_eq!(e.claude_session_uuid.as_deref(), Some("CANON"));
1365    }
1366
1367    #[test]
1368    fn harness_backfill_does_not_cross_contaminate() {
1369        // A claude row carrying a stale codex id must NOT adopt it: only the
1370        // row's own harness key is consulted when harness is known.
1371        let mut e = sample_entry("w");
1372        e.legacy_provider = "claude".into();
1373        e.harness = Some("claude".into());
1374        e.harness_session_id = None;
1375        e.claude_session_uuid = None;
1376        e.codex_session_id = Some("STALE-CODEX".into());
1377        e.session_id = None;
1378        e.backfill_harness_aliases();
1379        assert_eq!(e.harness_session_id, None);
1380    }
1381
1382    #[test]
1383    fn harness_backfill_reads_python_canonical_row_via_registry() {
1384        // Cross-language: a Python-authored canonical codex row parses into
1385        // Registry and, after the load-time backfill (mirrors
1386        // read_registry_tolerant), resolves the legacy alias too.
1387        let python_json = r#"{"schema_version":7,"agents":[{"name":"w","provider":"codex",
1388            "cwd":"/p","log_path":null,"claude_short_id":null,"codex_session_id":null,
1389            "gemini_session_id":null,"created_at":"2026-07-13T00:00:00Z","status":"live",
1390            "last_message_at":null,"mcp_channel_id":null,"harness":"codex",
1391            "harness_session_id":"THREAD"}]}"#;
1392        let mut reg: Registry = serde_json::from_str(python_json).unwrap();
1393        for e in &mut reg.entries {
1394            e.backfill_harness_aliases();
1395        }
1396        assert_eq!(reg.entries[0].harness_session_id.as_deref(), Some("THREAD"));
1397        assert_eq!(reg.entries[0].codex_session_id.as_deref(), Some("THREAD"));
1398    }
1399
1400    #[test]
1401    fn state_mux_row_skips_key_when_absent() {
1402        // Slim rows: no "mux" key serialized for non-mux rows, so a
1403        // round-tripped worker row stays byte-familiar to older tooling.
1404        let v = serde_json::to_value(sample_entry("w")).unwrap();
1405        assert!(v.get("mux").is_none());
1406    }
1407
1408    #[test]
1409    fn state_mux_row_is_never_a_one_shot_ask() {
1410        // codex P1 (PR #142): empty short_id + no pid describes a mux row too;
1411        // reconcile must not settle a live hosted agent as a finished ask.
1412        let mut e = sample_entry("mux-live");
1413        e.short_id = String::new();
1414        e.pid = None;
1415        assert!(e.is_one_shot_ask(), "baseline: bare row reads as ask");
1416        e.mux = Some(MuxRef {
1417            session: "main".into(),
1418            pane_id: 4,
1419        });
1420        assert!(!e.is_one_shot_ask(), "a mux ref is a live hosting handle");
1421    }
1422
1423    #[test]
1424    fn state_v9_claude_shellout_row_is_a_one_shot_ask() {
1425        // x-1b1e regression: v9 moved the claude jobId into short_id, so a
1426        // finished claude `ask`/`--bg` row now carries a NON-empty short_id.
1427        // The empty-short_id proxy no longer catches it; without the provider+
1428        // host_mode guard reconcile would fall through to the reachability probe
1429        // and keep the row falsely `live` off its surviving (resumability-only)
1430        // session file -- the exact defect recover() already had to fix.
1431        let mut ask = sample_entry("cc-ask");
1432        ask.legacy_provider = "claude".into();
1433        ask.short_id = "7c5dcf5d".into(); // v9: jobId lives here now
1434        ask.host_mode = None; // exec (shellout), not interactive
1435        ask.pid = None;
1436        ask.mux = None;
1437        assert!(
1438            ask.is_one_shot_ask(),
1439            "a v9 claude shellout row (non-empty short_id, exec, no pid) is a one-shot ask"
1440        );
1441
1442        // An interactive claude stream worker DOES have a daemon PTY: probe it,
1443        // never settle it by liveness-alone.
1444        let mut worker = ask.clone();
1445        worker.host_mode = Some(HOST_MODE_INTERACTIVE.into());
1446        assert!(
1447            !worker.is_one_shot_ask(),
1448            "an interactive claude worker is PTY-managed, not a one-shot ask"
1449        );
1450
1451        // An adopted row carries an external pid -> excluded by the pid guard.
1452        let mut adopted = ask.clone();
1453        adopted.host_mode = Some(HOST_MODE_ATTACHED.into());
1454        adopted.pid = Some(4242);
1455        assert!(
1456            !adopted.is_one_shot_ask(),
1457            "an adopted row (external pid) is not a one-shot ask"
1458        );
1459    }
1460
1461    #[test]
1462    fn state_update_registry_enforces_one_live_ref() {
1463        // Write-time invariant (brief Locked 7): a mux ref alongside a worker
1464        // short_id (or a bg claude_short_id) is refused; the store is left
1465        // untouched and the lock released (a later clean write succeeds).
1466        let dir = tmpdir("one-ref");
1467        let path = dir.join("registry.json");
1468        let res = update_registry(&path, |r| {
1469            let mut e = sample_entry("double"); // sample has short_id set
1470            e.mux = Some(MuxRef {
1471                session: "main".into(),
1472                pane_id: 1,
1473            });
1474            r.entries.push(e);
1475        });
1476        assert!(
1477            matches!(res, Err(StateError::InvariantViolation(_))),
1478            "double-ref row must be refused: {res:?}"
1479        );
1480        assert!(
1481            load_registry(&path).unwrap().entries.is_empty(),
1482            "refused write must not persist"
1483        );
1484        // bg-thread ref (jobId in short_id, v9) + mux is refused the same way.
1485        let res = update_registry(&path, |r| {
1486            let mut e = sample_entry("bg-double");
1487            e.short_id = "abcd1234".into();
1488            e.mux = Some(MuxRef {
1489                session: "main".into(),
1490                pane_id: 2,
1491            });
1492            r.entries.push(e);
1493        });
1494        assert!(matches!(res, Err(StateError::InvariantViolation(_))));
1495        // A clean mux-only row persists (lock was released by the refusals).
1496        update_registry(&path, |r| {
1497            let mut e = sample_entry("clean");
1498            e.short_id = String::new();
1499            e.mux = Some(MuxRef {
1500                session: "main".into(),
1501                pane_id: 3,
1502            });
1503            r.entries.push(e);
1504        })
1505        .unwrap();
1506        let reg = load_registry(&path).unwrap();
1507        assert_eq!(reg.entries.len(), 1);
1508        assert_eq!(reg.schema_version, REGISTRY_SCHEMA_VERSION);
1509        std::fs::remove_dir_all(&dir).ok();
1510    }
1511
1512    #[test]
1513    fn state_update_registry_refuses_new_canonical_handle_collision() {
1514        let dir = tmpdir("identity-collision");
1515        let path = dir.join("registry.json");
1516        update_registry(&path, |registry| {
1517            let mut first = sample_entry("first");
1518            first.short_id = "transport1".into();
1519            first.harness = Some("codex".into());
1520            first.harness_session_id = Some("aaaaaaaa-0000-0000-0000-1111deadbeef".into());
1521            registry.entries.push(first);
1522        })
1523        .unwrap();
1524
1525        let result = update_registry(&path, |registry| {
1526            let mut second = sample_entry("second");
1527            second.short_id = "transport2".into();
1528            second.harness = Some("codex".into());
1529            second.harness_session_id = Some("bbbbbbbb-0000-0000-0000-2222deadbeef".into());
1530            registry.entries.push(second);
1531        });
1532
1533        assert!(matches!(result, Err(StateError::InvariantViolation(_))));
1534        assert_eq!(load_registry(&path).unwrap().entries.len(), 1);
1535        std::fs::remove_dir_all(&dir).ok();
1536    }
1537
1538    #[test]
1539    fn state_update_registry_allows_retired_prefix_collision() {
1540        let dir = tmpdir("legacy-prefix-compatible");
1541        let path = dir.join("registry.json");
1542        update_registry(&path, |registry| {
1543            let mut first = sample_entry("first");
1544            first.short_id = "transport1".into();
1545            first.harness = Some("codex".into());
1546            first.harness_session_id = Some("019fb417-0000-0000-0000-111122223333".into());
1547            registry.entries.push(first);
1548        })
1549        .unwrap();
1550        update_registry(&path, |registry| {
1551            let mut second = sample_entry("second");
1552            second.short_id = "transport2".into();
1553            second.harness = Some("codex".into());
1554            second.harness_session_id = Some("019fb417-0000-0000-0000-444455556666".into());
1555            registry.entries.push(second);
1556        })
1557        .unwrap();
1558
1559        assert_eq!(load_registry(&path).unwrap().entries.len(), 2);
1560        std::fs::remove_dir_all(&dir).ok();
1561    }
1562
1563    #[test]
1564    fn missing_registry_loads_empty() {
1565        let dir = tmpdir("missing");
1566        let reg = load_registry(&dir.join("registry.json")).unwrap();
1567        assert_eq!(reg.schema_version, REGISTRY_SCHEMA_VERSION);
1568        assert!(reg.entries.is_empty());
1569        std::fs::remove_dir_all(&dir).ok();
1570    }
1571
1572    #[test]
1573    fn python_written_registry_loads_via_typed_path() {
1574        // Regression for ab-e5a57efa: the typed daemon read path
1575        // (`load_registry`, used by list/stop/rm/reconcile/status) must parse a
1576        // registry authored by Python's `registry.write_registry`. That writer
1577        // uses the top-level `"agents"` key and `AgentEntry` rows that omit the
1578        // Rust-daemon-only `short_id`/`project_root` fields. Before the fix the
1579        // whole-file parse failed and `unwrap_or_default()` returned 0 agents.
1580        let dir = tmpdir("python-registry");
1581        let path = dir.join("registry.json");
1582        // Byte-for-byte the shape Python emits (no short_id, no project_root,
1583        // key is "agents").
1584        let python_json = r#"{
1585  "schema_version": 3,
1586  "agents": [
1587    {
1588      "name": "worker-claude",
1589      "provider": "claude",
1590      "cwd": "/Users/x/proj",
1591      "log_path": "/Users/x/.fno/agents/worker-claude.log",
1592      "claude_short_id": "abc123",
1593      "codex_session_id": null,
1594      "gemini_session_id": null,
1595      "created_at": "2026-05-26T00:00:00Z",
1596      "status": "live",
1597      "last_message_at": null,
1598      "mcp_channel_id": null
1599    }
1600  ]
1601}"#;
1602        std::fs::create_dir_all(&dir).unwrap();
1603        std::fs::write(&path, python_json).unwrap();
1604
1605        let reg = load_registry(&path).unwrap();
1606        assert_eq!(reg.entries.len(), 1, "Python-written row must be read");
1607        let e = reg.find("worker-claude").unwrap();
1608        assert_eq!(e.harness_name(), "claude");
1609        assert_eq!(e.status, AgentStatus::Live);
1610        // v9: the legacy claude_short_id backfills into short_id on load.
1611        assert_eq!(e.short_id, "abc123");
1612        assert_eq!(e.legacy_claude_short_id, None); // consumed by the backfill
1613                                                    // The other Rust-only field defaults to empty for Python-authored rows.
1614        assert_eq!(e.project_root, "");
1615        std::fs::remove_dir_all(&dir).ok();
1616    }
1617
1618    #[test]
1619    fn python_row_roundtrips_to_python_shape_under_agents_key() {
1620        // Codex P1 (PR #364): after the daemon rewrites a Python-authored
1621        // registry (e.g. `rm` removing one agent), the surviving rows must stay
1622        // readable by Python -- which reads ONLY the top-level `agents` key and
1623        // whose `AgentEntry(**row)` rejects unknown keys. So the serialized form
1624        // must (a) use `agents`, not `entries`, and (b) omit every Rust-only
1625        // field that a Python row lacks (short_id/project_root/session_id/
1626        // messaging_socket_path/cc_session_id/pid/last_reconciled_at).
1627        // v10 (x-880e): a Python-authored row is harness-shaped -- harness +
1628        // harness_session_id, no provider or per-provider session keys.
1629        let python_json = r#"{"schema_version":10,"agents":[
1630            {"name":"w","harness":"codex","cwd":"/p","log_path":"/l",
1631             "harness_session_id":"sid","created_at":"2026-05-26T00:00:00Z",
1632             "status":"live","last_message_at":null,"mcp_channel_id":null}]}"#;
1633        let reg: Registry = serde_json::from_str(python_json).unwrap();
1634        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
1635
1636        assert!(out.get("agents").is_some(), "must serialize under `agents`");
1637        assert!(out.get("entries").is_none(), "must NOT serialize `entries`");
1638        let row = &out["agents"][0];
1639        for rust_only in [
1640            "short_id",
1641            "project_root",
1642            "session_id",
1643            "messaging_socket_path",
1644            "cc_session_id",
1645            "pid",
1646            "pid_start_time",
1647            "last_reconciled_at",
1648        ] {
1649            assert!(
1650                row.get(rust_only).is_none(),
1651                "Python-authored row must omit Rust-only field `{rust_only}`"
1652            );
1653        }
1654        // v10: the removed identity keys never re-serialize (skip_serializing).
1655        for removed in [
1656            "provider",
1657            "codex_session_id",
1658            "gemini_session_id",
1659            "claude_session_uuid",
1660        ] {
1661            assert!(
1662                row.get(removed).is_none(),
1663                "v10 row must omit removed key `{removed}`"
1664            );
1665        }
1666        // The canonical identity fields survive.
1667        assert_eq!(row["name"], "w");
1668        assert_eq!(row["harness"], "codex");
1669        assert_eq!(row["harness_session_id"], "sid");
1670    }
1671
1672    #[test]
1673    fn host_mode_cross_language_round_trip_parity() {
1674        // interactive-drive node (ab-26b5fe82): the host_mode add must round-trip
1675        // both directions across the Rust<->Python registry boundary.
1676
1677        // (a) Rust READS a Python-written row that OMITS host_mode -> exec.
1678        let no_key = r#"{"schema_version":3,"agents":[
1679            {"name":"legacy","provider":"codex","cwd":"/p","log_path":"/l",
1680             "created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#;
1681        let reg: Registry = serde_json::from_str(no_key).unwrap();
1682        assert_eq!(reg.entries[0].host_mode, None);
1683        assert_eq!(reg.entries[0].host_mode_or_default(), HOST_MODE_EXEC);
1684        assert!(!reg.entries[0].is_interactive());
1685
1686        // (b) Rust READS a row carrying host_mode="interactive" -> interactive.
1687        let interactive = r#"{"schema_version":3,"agents":[
1688            {"name":"bot2","provider":"codex","cwd":"/p","log_path":"/l",
1689             "codex_session_id":"019e7157","created_at":"2026-05-26T00:00:00Z",
1690             "status":"live","host_mode":"interactive"}]}"#;
1691        let reg: Registry = serde_json::from_str(interactive).unwrap();
1692        assert_eq!(reg.entries[0].host_mode_or_default(), HOST_MODE_INTERACTIVE);
1693        assert!(reg.entries[0].is_interactive());
1694
1695        // (c) Rust WRITES an exec row (host_mode None) -> key OMITTED, so a
1696        // Python AgentEntry(**row) does not gain an unexpected key and Python's
1697        // missing-key coercion maps the absence back to "exec".
1698        let mut exec_entry = sample_entry("w");
1699        exec_entry.host_mode = None;
1700        let mut reg = Registry::default();
1701        reg.entries.push(exec_entry);
1702        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
1703        assert!(
1704            out["agents"][0].get("host_mode").is_none(),
1705            "exec row must omit host_mode (skip_serializing_if)"
1706        );
1707
1708        // (d) Rust WRITES an interactive row -> host_mode present and readable.
1709        let mut int_entry = sample_entry("bot2");
1710        int_entry.host_mode = Some(HOST_MODE_INTERACTIVE.to_string());
1711        let mut reg = Registry::default();
1712        reg.entries.push(int_entry);
1713        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
1714        assert_eq!(out["agents"][0]["host_mode"], "interactive");
1715    }
1716
1717    #[test]
1718    fn screen_state_cross_language_round_trip_parity() {
1719        // v7: the additive `screen_state` verdict must round-trip both
1720        // directions across the Rust<->Python registry boundary, exactly like
1721        // inside_leg (v5) and mux (v6) before it.
1722
1723        // (a) Rust READS a row that OMITS screen_state -> None, no migration.
1724        let no_key = r#"{"schema_version":6,"agents":[
1725            {"name":"legacy","provider":"codex","cwd":"/p","log_path":"/l",
1726             "created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#;
1727        let reg: Registry = serde_json::from_str(no_key).unwrap();
1728        assert_eq!(reg.entries[0].screen_state, None);
1729
1730        // (b) Rust READS a full verdict -> Some, all fields land.
1731        let with_verdict = r#"{"schema_version":7,"agents":[
1732            {"name":"pane","provider":"codex","cwd":"/p","log_path":"/l",
1733             "created_at":"2026-05-26T00:00:00Z","status":"live",
1734             "screen_state":{"state":"idle","rule":"idle_prompt","seq":3,
1735                             "at":"2026-07-02T00:00:00Z","ttl_ms":30000}}]}"#;
1736        let reg: Registry = serde_json::from_str(with_verdict).unwrap();
1737        let v = reg.entries[0].screen_state.as_ref().unwrap();
1738        assert_eq!(v.state, "idle");
1739        assert_eq!(v.rule, "idle_prompt");
1740        assert_eq!(v.seq, 3);
1741        assert_eq!(v.at, "2026-07-02T00:00:00Z");
1742        assert_eq!(v.ttl_ms, Some(30000));
1743
1744        // (c) Rust WRITES a row without a verdict -> key OMITTED, so a Python
1745        // AgentEntry(**row) gains no unexpected key.
1746        let mut reg = Registry::default();
1747        reg.entries.push(sample_entry("w"));
1748        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
1749        assert!(
1750            out["agents"][0].get("screen_state").is_none(),
1751            "row without a verdict must omit screen_state (skip_serializing_if)"
1752        );
1753
1754        // (d) Full round-trip preserves the verdict unchanged.
1755        let mut scraped = sample_entry("pane");
1756        scraped.screen_state = Some(ScreenStateReport {
1757            state: "blocked".into(),
1758            rule: "permission_prompt".into(),
1759            seq: 9,
1760            at: "2026-07-02T01:00:00Z".into(),
1761            ttl_ms: None,
1762            answerable: None,
1763        });
1764        let mut reg = Registry::default();
1765        reg.entries.push(scraped.clone());
1766        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
1767        assert!(
1768            out["agents"][0]["screen_state"].get("ttl_ms").is_none(),
1769            "absent ttl_ms omitted"
1770        );
1771        let reg2: Registry = serde_json::from_value(out).unwrap();
1772        assert_eq!(reg2.entries[0].screen_state, scraped.screen_state);
1773    }
1774
1775    #[test]
1776    fn screen_state_report_ttl_ages_and_fails_closed() {
1777        let now = rfc3339_like_to_secs("2026-07-02T00:01:00Z").unwrap();
1778        let mk = |at: &str, ttl_ms: Option<u64>| ScreenStateReport {
1779            state: "working".into(),
1780            rule: "busy".into(),
1781            seq: 1,
1782            at: at.into(),
1783            ttl_ms,
1784            answerable: None,
1785        };
1786        // No TTL never self-ages; in-TTL live; lapsed expires; corrupt stamp
1787        // fails closed (a bad `at` must not pin a forever-working badge).
1788        assert!(mk("2026-07-02T00:00:00Z", None).is_live_at(now));
1789        assert!(mk("2026-07-02T00:00:30Z", Some(60_000)).is_live_at(now));
1790        assert!(!mk("2026-07-02T00:00:00Z", Some(5_000)).is_live_at(now));
1791        assert!(!mk("garbage", Some(60_000)).is_live_at(now));
1792    }
1793
1794    #[test]
1795    fn inside_leg_cross_language_round_trip_parity() {
1796        // inside-out E3.1 (X2/X3): the additive `inside_leg` field must round-trip
1797        // both directions across the Rust<->Python registry boundary, like every
1798        // prior additive RegistryEntry field.
1799
1800        // (a) Rust READS a Python-written row that OMITS inside_leg -> None.
1801        let no_key = r#"{"schema_version":5,"agents":[
1802            {"name":"legacy","provider":"codex","cwd":"/p","log_path":"/l",
1803             "created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#;
1804        let reg: Registry = serde_json::from_str(no_key).unwrap();
1805        assert_eq!(reg.entries[0].inside_leg, None);
1806
1807        // (b) Rust READS a full inside-leg report -> Some, lowercase state parses,
1808        // optional reason/ttl_ms present.
1809        let with_report = r#"{"schema_version":5,"agents":[
1810            {"name":"pane","provider":"claude","cwd":"/p","log_path":"/l",
1811             "created_at":"2026-05-26T00:00:00Z","status":"live",
1812             "inside_leg":{"state":"working","seq":7,"reason":"running tests",
1813                           "received_at":"2026-06-27T00:00:00Z","ttl_ms":5000}}]}"#;
1814        let reg: Registry = serde_json::from_str(with_report).unwrap();
1815        let rep = reg.entries[0].inside_leg.as_ref().unwrap();
1816        assert_eq!(rep.state, InsideLegState::Working);
1817        assert_eq!(rep.seq, 7);
1818        assert_eq!(rep.reason.as_deref(), Some("running tests"));
1819        assert_eq!(rep.received_at, "2026-06-27T00:00:00Z");
1820        assert_eq!(rep.ttl_ms, Some(5000));
1821
1822        // (c) Rust WRITES a row without a report -> key OMITTED (skip_serializing_if),
1823        // so a Python AgentEntry(**row) does not gain an unexpected key and a stale
1824        // reader never sees the field.
1825        let mut bare = sample_entry("w");
1826        bare.inside_leg = None;
1827        let mut reg = Registry::default();
1828        reg.entries.push(bare);
1829        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
1830        assert!(
1831            out["agents"][0].get("inside_leg").is_none(),
1832            "row without a report must omit inside_leg (skip_serializing_if)"
1833        );
1834
1835        // (d) Rust WRITES a report -> present, state lowercase, absent reason/ttl
1836        // omitted (skip_serializing_if on the nested struct).
1837        let mut withrep = sample_entry("pane");
1838        withrep.inside_leg = Some(InsideLegReport {
1839            state: InsideLegState::Done,
1840            seq: 12,
1841            reason: None,
1842            received_at: "2026-06-27T01:00:00Z".into(),
1843            ttl_ms: None,
1844        });
1845        let mut reg = Registry::default();
1846        reg.entries.push(withrep);
1847        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
1848        let badge = &out["agents"][0]["inside_leg"];
1849        assert_eq!(badge["state"], "done");
1850        assert_eq!(badge["seq"], 12);
1851        assert!(badge.get("reason").is_none(), "absent reason omitted");
1852        assert!(badge.get("ttl_ms").is_none(), "absent ttl_ms omitted");
1853
1854        // (e) Full round-trip preserves the report unchanged.
1855        let reg2: Registry = serde_json::from_value(out).unwrap();
1856        assert_eq!(
1857            reg2.entries[0].inside_leg,
1858            Some(InsideLegReport {
1859                state: InsideLegState::Done,
1860                seq: 12,
1861                reason: None,
1862                received_at: "2026-06-27T01:00:00Z".into(),
1863                ttl_ms: None,
1864            })
1865        );
1866    }
1867
1868    #[test]
1869    fn rfc3339_like_to_secs_round_trips_known_stamps() {
1870        // The unix epoch and a couple of fixed dates; values cross-checked against
1871        // `date -u -d <stamp> +%s`. Proves the days-from-civil inverse matches the
1872        // daemon's civil() forward direction (the producer of received_at).
1873        assert_eq!(rfc3339_like_to_secs("1970-01-01T00:00:00Z"), Some(0));
1874        assert_eq!(
1875            rfc3339_like_to_secs("2026-06-27T00:00:00Z"),
1876            Some(1_782_518_400)
1877        );
1878        assert_eq!(
1879            rfc3339_like_to_secs("2026-06-27T00:00:05Z"),
1880            Some(1_782_518_405)
1881        );
1882    }
1883
1884    #[test]
1885    fn rfc3339_like_to_secs_rejects_malformed() {
1886        // Wrong length, bad separators, non-digit, out-of-range fields, and the
1887        // fractional/offset forms now_rfc3339_like never emits -- all None so the
1888        // TTL gate fails closed rather than trusting a garbage stamp.
1889        for bad in [
1890            "",
1891            "2026-06-27",
1892            "2026-06-27T00:00:00",    // no Z
1893            "2026/06/27T00:00:00Z",   // wrong separators
1894            "20260627T000000Z",       // compact form, wrong length
1895            "2026-13-27T00:00:00Z",   // month 13
1896            "2026-06-27T24:00:00Z",   // hour 24
1897            "2026-06-27T00:00:00.5Z", // fractional (21 bytes)
1898            "abcd-ef-ghTij:kl:mnZ",   // non-digit
1899        ] {
1900            assert_eq!(rfc3339_like_to_secs(bad), None, "must reject {bad:?}");
1901        }
1902    }
1903
1904    #[test]
1905    fn inside_leg_is_live_at_ttl_gate() {
1906        let recv = "2026-06-27T00:00:00Z";
1907        let recv_secs = rfc3339_like_to_secs(recv).unwrap();
1908        let rep = |ttl| InsideLegReport {
1909            state: InsideLegState::Working,
1910            seq: 1,
1911            reason: None,
1912            received_at: recv.into(),
1913            ttl_ms: ttl,
1914        };
1915
1916        // No ttl -> never ages out on its own (cleared by teardown/done/newer report).
1917        assert!(rep(None).is_live_at(recv_secs + 10_000));
1918
1919        // ttl=5000ms: live at +4s, live exactly at +5s (<=), expired at +6s (AC-X2-2).
1920        assert!(rep(Some(5000)).is_live_at(recv_secs + 4));
1921        assert!(rep(Some(5000)).is_live_at(recv_secs + 5));
1922        assert!(!rep(Some(5000)).is_live_at(recv_secs + 6));
1923
1924        // A clock that reads BEFORE received_at (skew) is still live (saturating_sub).
1925        assert!(rep(Some(5000)).is_live_at(recv_secs.saturating_sub(100)));
1926
1927        // An unparseable received_at with a ttl fails CLOSED (expired), so a corrupt
1928        // stamp can never pin a permanent badge.
1929        let mut corrupt = rep(Some(5000));
1930        corrupt.received_at = "not-a-stamp".into();
1931        assert!(!corrupt.is_live_at(recv_secs));
1932    }
1933
1934    #[test]
1935    fn rust_reads_python_row_with_explicit_empty_and_null_fields() {
1936        // ab-b946b59c: Python's `AgentEntry` now mirrors the Rust-only PTY
1937        // fields, so its `asdict` emits them for EVERY row -- short_id/
1938        // project_root as "" (their Rust type is `String`, so a null would fail
1939        // deserialize) and the Option fields as null. Rust must read that shape.
1940        let python_json = r#"{"schema_version":4,"agents":[
1941            {"name":"py-ask","provider":"codex","cwd":"/p","log_path":"/l",
1942             "short_id":"","project_root":"",
1943             "claude_short_id":null,"codex_session_id":"sid","gemini_session_id":null,
1944             "claude_session_uuid":null,"messaging_socket_path":null,"cc_session_id":null,
1945             "mcp_channel_id":null,"host_mode":"exec",
1946             "created_at":"2026-05-26T00:00:00Z","status":"exited","last_message_at":null,
1947             "pid":null,"pid_start_time":null,"last_reconciled_at":null}]}"#;
1948        let reg: Registry = serde_json::from_str(python_json).unwrap();
1949        let e = &reg.entries[0];
1950        assert_eq!(e.name, "py-ask");
1951        assert_eq!(e.short_id, ""); // "" deserializes into the String field
1952        assert_eq!(e.project_root, "");
1953        assert_eq!(e.pid, None); // null -> None for the Option fields
1954        assert_eq!(e.pid_start_time, None);
1955        assert_eq!(e.cc_session_id, None);
1956        assert_eq!(e.codex_session_id.as_deref(), Some("sid"));
1957        assert!(e.is_one_shot_ask(), "empty short_id + no pid => ask row");
1958    }
1959
1960    #[test]
1961    fn pty_agent_still_serializes_its_short_id() {
1962        // The skip-when-empty must NOT drop a real daemon agent's short_id/pid.
1963        let mut reg = Registry::default();
1964        reg.entries.push(sample_entry("worker-A")); // short_id "worker-A-id", pid Some
1965        let out: serde_json::Value = serde_json::to_value(&reg).unwrap();
1966        let row = &out["agents"][0];
1967        assert_eq!(row["short_id"], "worker-A-id");
1968        assert_eq!(row["pid"], 1234);
1969    }
1970
1971    #[test]
1972    fn empty_registry_file_loads_default_but_corrupt_file_errors() {
1973        // Gemini high (PR #364): an empty/whitespace file is a valid empty
1974        // registry, but a present-but-unparseable file must error LOUDLY rather
1975        // than default -- otherwise update_registry's read-modify-write republishes
1976        // the empty default and wipes every other agent.
1977        let dir = tmpdir("corrupt-registry");
1978        std::fs::create_dir_all(&dir).unwrap();
1979        let path = dir.join("registry.json");
1980
1981        // Empty file -> empty registry, no error.
1982        std::fs::write(&path, "   \n").unwrap();
1983        assert!(load_registry(&path).unwrap().entries.is_empty());
1984
1985        // Corrupt (non-empty, unparseable) file -> error, not silent default.
1986        std::fs::write(&path, "{ this is not json").unwrap();
1987        assert!(
1988            load_registry(&path).is_err(),
1989            "corrupt registry must surface an error"
1990        );
1991        std::fs::remove_dir_all(&dir).ok();
1992    }
1993
1994    #[test]
1995    fn update_registry_refuses_to_wipe_a_corrupt_registry() {
1996        // The data-loss path Gemini flagged: update_registry reads, mutates,
1997        // writes. If the read silently defaulted on a corrupt file, the write
1998        // would publish an (almost) empty registry. It must instead propagate the
1999        // parse error and leave the file byte-for-byte intact.
2000        let dir = tmpdir("no-wipe");
2001        std::fs::create_dir_all(&dir).unwrap();
2002        let path = dir.join("registry.json");
2003        let corrupt = "{\"schema_version\": 3, \"agents\": [ BROKEN";
2004        std::fs::write(&path, corrupt).unwrap();
2005
2006        let result = update_registry(&path, |r| r.entries.push(sample_entry("new-A")));
2007        assert!(result.is_err(), "update over corrupt registry must error");
2008        assert_eq!(
2009            std::fs::read_to_string(&path).unwrap(),
2010            corrupt,
2011            "corrupt registry must be left untouched, not overwritten"
2012        );
2013        std::fs::remove_dir_all(&dir).ok();
2014    }
2015
2016    #[test]
2017    fn update_registry_upgrades_schema_version_on_write() {
2018        // Codex P2 (ab-a171ceb2): a Rust write of an existing older store must
2019        // bump schema_version to the current version, or the forward-compat bump
2020        // never takes effect for the common case (stores that predate it).
2021        let dir = tmpdir("upgrade-on-write");
2022        std::fs::create_dir_all(&dir).unwrap();
2023        let path = dir.join("registry.json");
2024        std::fs::write(
2025            &path,
2026            r#"{"schema_version":3,"agents":[{"name":"w","provider":"codex","cwd":"/p","log_path":"/l","created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#,
2027        )
2028        .unwrap();
2029        update_registry(&path, |r| r.entries.push(sample_entry("w2"))).unwrap();
2030        let on_disk: serde_json::Value =
2031            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2032        assert_eq!(
2033            on_disk["schema_version"], REGISTRY_SCHEMA_VERSION,
2034            "Rust write must upgrade the on-disk schema_version"
2035        );
2036        std::fs::remove_dir_all(&dir).ok();
2037    }
2038
2039    #[test]
2040    fn load_registry_rejects_unsupported_schema_version() {
2041        // Codex P2 (ab-a171ceb2): the typed daemon read path must reject a version
2042        // outside 1..=REGISTRY_SCHEMA_VERSION (a future v12, or - for an old daemon -
2043        // a version it cannot interpret), while v1..=current still read.
2044        let dir = tmpdir("version-guard");
2045        std::fs::create_dir_all(&dir).unwrap();
2046        let path = dir.join("registry.json");
2047        std::fs::write(&path, r#"{"schema_version":12,"agents":[]}"#).unwrap();
2048        match load_registry(&path) {
2049            Err(StateError::UnsupportedSchemaVersion { found, max }) => {
2050                assert_eq!(found, 12);
2051                assert_eq!(max, REGISTRY_SCHEMA_VERSION);
2052            }
2053            other => panic!("expected UnsupportedSchemaVersion, got {other:?}"),
2054        }
2055        std::fs::write(&path, r#"{"schema_version":1,"agents":[]}"#).unwrap();
2056        assert!(
2057            load_registry(&path).is_ok(),
2058            "v1 must still read (back-compat)"
2059        );
2060        std::fs::remove_dir_all(&dir).ok();
2061    }
2062
2063    #[test]
2064    fn update_then_load_roundtrips_and_preserves_optionals() {
2065        let dir = tmpdir("roundtrip");
2066        let path = dir.join("registry.json");
2067        update_registry(&path, |r| r.entries.push(sample_entry("worker-A"))).unwrap();
2068
2069        // A second update that only flips status must preserve codex_session_id.
2070        update_registry(&path, |r| {
2071            r.find_mut("worker-A").unwrap().status = AgentStatus::Idle;
2072        })
2073        .unwrap();
2074
2075        let reg = load_registry(&path).unwrap();
2076        let e = reg.find("worker-A").unwrap();
2077        assert_eq!(e.status, AgentStatus::Idle);
2078        assert_eq!(e.codex_session_id.as_deref(), Some("uuid-1"));
2079        assert_eq!(e.pid, Some(1234));
2080        std::fs::remove_dir_all(&dir).ok();
2081    }
2082
2083    #[test]
2084    fn state_json_absent_is_none_present_roundtrips() {
2085        let dir = tmpdir("state");
2086        let path = dir.join("wkA/state.json");
2087        assert!(load_state(&path).unwrap().is_none());
2088
2089        let st = AgentState::new_pty("wkA");
2090        write_state_atomic(&path, &st).unwrap();
2091        let back = load_state(&path).unwrap().unwrap();
2092        assert_eq!(back.short_id, "wkA");
2093        assert_eq!(back.status, AgentStatus::Spawning);
2094        assert!(back.pty.is_some());
2095        std::fs::remove_dir_all(&dir).ok();
2096    }
2097
2098    #[test]
2099    fn empty_state_file_treated_as_absent() {
2100        // Recovery's "registry entry with partial state.json" path: a present
2101        // but empty file must read as None (-> inconsistent), never an error.
2102        let dir = tmpdir("empty-state");
2103        let path = dir.join("state.json");
2104        std::fs::write(&path, b"").unwrap();
2105        assert!(load_state(&path).unwrap().is_none());
2106        std::fs::remove_dir_all(&dir).ok();
2107    }
2108
2109    #[test]
2110    fn take_active_drive_reads_before_clear() {
2111        // The recovery ordering invariant in miniature: the returned value
2112        // carries the session id, and after the call the window is cleared.
2113        let mut pty = PtyState {
2114            active: true,
2115            drive: Some(DriveWindow {
2116                session_id: Some("drive-uuid".into()),
2117                mode: Some("interactive".into()),
2118                last_heartbeat_at_monotonic_ns: Some(42),
2119            }),
2120        };
2121        let taken = pty.take_active_drive().expect("a drive was active");
2122        assert_eq!(taken.session_id.as_deref(), Some("drive-uuid"));
2123        assert_eq!(taken.mode.as_deref(), Some("interactive"));
2124        // Cleared after read.
2125        assert!(pty.drive.is_none());
2126        // Idempotent: a second take finds nothing.
2127        assert!(pty.take_active_drive().is_none());
2128    }
2129
2130    #[test]
2131    fn take_active_drive_none_when_no_drive() {
2132        let mut pty = PtyState::default();
2133        assert!(pty.take_active_drive().is_none());
2134    }
2135
2136    #[test]
2137    fn pty_state_wire_shape_is_flat_and_stable() {
2138        // The Option<DriveWindow> in-memory shape must still serialize to the
2139        // flat state.json schema (Wave 7 cross-language parity).
2140        let no_drive = PtyState {
2141            active: true,
2142            drive: None,
2143        };
2144        assert_eq!(
2145            serde_json::to_value(&no_drive).unwrap(),
2146            serde_json::json!({"active": true, "drive_active": false})
2147        );
2148
2149        let with_drive = PtyState {
2150            active: true,
2151            drive: Some(DriveWindow {
2152                session_id: Some("d-1".into()),
2153                mode: Some("interactive".into()),
2154                last_heartbeat_at_monotonic_ns: Some(99),
2155            }),
2156        };
2157        assert_eq!(
2158            serde_json::to_value(&with_drive).unwrap(),
2159            serde_json::json!({
2160                "active": true,
2161                "drive_active": true,
2162                "drive_session_id": "d-1",
2163                "drive_mode": "interactive",
2164                "last_heartbeat_at_monotonic_ns": 99
2165            })
2166        );
2167        // Roundtrips back to the same typed value.
2168        let back: PtyState =
2169            serde_json::from_value(serde_json::to_value(&with_drive).unwrap()).unwrap();
2170        assert_eq!(back, with_drive);
2171    }
2172
2173    #[test]
2174    fn pty_state_collapses_inconsistent_legacy_shape() {
2175        // A legacy/partial file with drive_active:false but a stray session_id
2176        // deserializes to drive: None - the inconsistent state is normalized
2177        // away rather than carried.
2178        let legacy = serde_json::json!({
2179            "active": true,
2180            "drive_active": false,
2181            "drive_session_id": "stray",
2182        });
2183        let pty: PtyState = serde_json::from_value(legacy).unwrap();
2184        assert!(pty.drive.is_none());
2185    }
2186}