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