use crate::AgentStatus;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
pub const REGISTRY_SCHEMA_VERSION: u32 = 11;
pub const STATE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum StateError {
#[error("state io error: {0}")]
Io(#[from] std::io::Error),
#[error("state json error: {0}")]
Json(#[from] serde_json::Error),
#[error(
"registry schema_version {found} unsupported; this fno understands 1..={max}. \
Upgrade or downgrade fno to match."
)]
UnsupportedSchemaVersion { found: u32, max: u32 },
#[error("registry invariant violation: {0}")]
InvariantViolation(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Registry {
pub schema_version: u32,
#[serde(default, rename = "agents", alias = "entries")]
pub entries: Vec<RegistryEntry>,
}
impl Default for Registry {
fn default() -> Self {
Registry {
schema_version: REGISTRY_SCHEMA_VERSION,
entries: Vec::new(),
}
}
}
impl Registry {
pub fn find(&self, name: &str) -> Option<&RegistryEntry> {
self.entries.iter().find(|e| e.name == name)
}
pub fn find_mut(&mut self, name: &str) -> Option<&mut RegistryEntry> {
self.entries.iter_mut().find(|e| e.name == name)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum InsideLegState {
Working,
Blocked,
Done,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InsideLegReport {
pub state: InsideLegState,
pub seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub received_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScreenStateReport {
pub state: String,
pub rule: String,
pub seq: u64,
pub at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answerable: Option<crate::manifest::AnswerablePrompt>,
}
impl ScreenStateReport {
pub fn is_live_at(&self, now_secs: u64) -> bool {
let Some(ttl_ms) = self.ttl_ms else {
return true;
};
match rfc3339_like_to_secs(&self.at) {
Some(recv) => now_secs.saturating_sub(recv).saturating_mul(1000) <= ttl_ms,
None => false,
}
}
}
impl InsideLegReport {
pub fn is_live_at(&self, now_secs: u64) -> bool {
let Some(ttl_ms) = self.ttl_ms else {
return true;
};
match rfc3339_like_to_secs(&self.received_at) {
Some(recv) => now_secs.saturating_sub(recv).saturating_mul(1000) <= ttl_ms,
None => false,
}
}
pub fn received_within(&self, now_secs: u64, window_secs: u64) -> bool {
match rfc3339_like_to_secs(&self.received_at) {
Some(recv) => recv <= now_secs && now_secs - recv <= window_secs,
None => false,
}
}
}
pub fn enters(prev: Option<InsideLegState>, new: InsideLegState, target: InsideLegState) -> bool {
new == target && prev != Some(target)
}
pub fn rfc3339_like_to_secs(s: &str) -> Option<u64> {
let b = s.as_bytes();
if b.len() != 20
|| b[4] != b'-'
|| b[7] != b'-'
|| b[10] != b'T'
|| b[13] != b':'
|| b[16] != b':'
|| b[19] != b'Z'
{
return None;
}
let num = |lo: usize, hi: usize| -> Option<i64> {
let mut val = 0i64;
for &ch in b.get(lo..hi)? {
if !ch.is_ascii_digit() {
return None;
}
val = val * 10 + i64::from(ch - b'0');
}
Some(val)
};
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
let (h, mi, se) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 60 {
return None;
}
let yy = if mo <= 2 { y - 1 } else { y };
let era = if yy >= 0 { yy } else { yy - 399 } / 400;
let yoe = yy - era * 400;
let mp = if mo > 2 { mo - 3 } else { mo + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
let secs = days * 86_400 + h * 3600 + mi * 60 + se;
u64::try_from(secs).ok()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MuxRef {
pub session: String,
pub pane_id: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RegistryEntry {
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub short_id: String,
#[serde(default, rename = "provider", skip_serializing)]
pub legacy_provider: String,
pub cwd: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub project_root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing)]
pub claude_session_uuid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub messaging_socket_path: Option<String>,
#[serde(default, skip_serializing)]
pub codex_session_id: Option<String>,
#[serde(default, skip_serializing)]
pub gemini_session_id: Option<String>,
#[serde(default)]
pub mcp_channel_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cc_session_id: Option<String>,
pub status: AgentStatus,
#[serde(default)]
pub last_message_at: Option<String>,
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid_start_time: Option<u64>,
#[serde(default)]
pub log_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_reconciled_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inside_leg: Option<InsideLegReport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exited_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mux: Option<MuxRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_state: Option<ScreenStateReport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crown_level: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crown_scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crown_grantor: Option<String>,
#[serde(default, rename = "claude_short_id", skip_serializing)]
pub legacy_claude_short_id: Option<String>,
}
pub fn validate_single_live_ref(entry: &RegistryEntry) -> Result<(), String> {
if entry.mux.is_none() {
return Ok(());
}
if !entry.short_id.is_empty() {
return Err(format!(
"registry row {:?} carries a mux ref alongside a worker/bg ref; a row holds exactly one live ref (mux XOR worker XOR bg)",
entry.name,
));
}
Ok(())
}
pub const HOST_MODE_EXEC: &str = "exec";
pub const HOST_MODE_INTERACTIVE: &str = "interactive";
pub const HOST_MODE_ATTACHED: &str = "attached";
pub const CLAUDE_MODE_STREAM_JSON: &str = "stream_json";
pub const CLAUDE_MODE_INTERACTIVE: &str = "interactive";
impl RegistryEntry {
pub fn backfill_harness_aliases(&mut self) {
if self.harness.is_none() && !self.legacy_provider.is_empty() {
self.harness = Some(self.legacy_provider.clone());
}
match self.harness_session_id.clone() {
Some(hsid) if !hsid.is_empty() => match self.harness.as_deref() {
Some("claude") => self.claude_session_uuid = Some(hsid),
Some("codex") => self.codex_session_id = Some(hsid),
Some("gemini") => self.gemini_session_id = Some(hsid),
_ => {}
},
_ => {
let legacy = match self.harness.as_deref() {
Some("claude") => self.claude_session_uuid.clone(),
Some("codex") => self.codex_session_id.clone(),
Some("gemini") => self.gemini_session_id.clone(),
_ => self
.claude_session_uuid
.clone()
.or_else(|| self.codex_session_id.clone())
.or_else(|| self.gemini_session_id.clone()),
};
if let Some(value) = legacy {
if !value.is_empty() && value != "null" {
self.harness_session_id = Some(value);
}
}
}
}
}
pub fn backfill_short_id(&mut self) -> Option<String> {
let legacy = self.legacy_claude_short_id.take()?;
if legacy.is_empty() {
return None;
}
if self.short_id.is_empty() {
self.short_id = legacy;
None
} else if self.short_id != legacy {
Some(legacy) } else {
None
}
}
pub fn transport_short(&self) -> Option<&str> {
(!self.short_id.is_empty()).then_some(self.short_id.as_str())
}
pub fn harness_name(&self) -> &str {
match self.harness.as_deref() {
Some(h) if !h.is_empty() => h,
_ => &self.legacy_provider,
}
}
pub fn host_mode_or_default(&self) -> &str {
self.host_mode.as_deref().unwrap_or(HOST_MODE_EXEC)
}
pub fn is_interactive(&self) -> bool {
self.host_mode_or_default() == HOST_MODE_INTERACTIVE
}
pub fn is_one_shot_ask(&self) -> bool {
let is_claude_shellout = self.harness_name() == "claude" && !self.is_interactive();
(self.short_id.is_empty() || is_claude_shellout) && self.pid.is_none() && self.mux.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentState {
pub schema_version: u32,
pub short_id: String,
pub status: AgentStatus,
#[serde(default)]
pub ready: bool,
#[serde(default)]
pub last_message_at: Option<String>,
#[serde(default)]
pub last_reply: Option<String>,
#[serde(default)]
pub restart_count: u32,
#[serde(default)]
pub last_restart_at: Option<String>,
#[serde(default)]
pub pty: Option<PtyState>,
}
impl AgentState {
pub fn new_pty(short_id: impl Into<String>) -> Self {
AgentState {
schema_version: STATE_SCHEMA_VERSION,
short_id: short_id.into(),
status: AgentStatus::Spawning,
ready: false,
last_message_at: None,
last_reply: None,
restart_count: 0,
last_restart_at: None,
pty: Some(PtyState::default()),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct DriveWindow {
pub session_id: Option<String>,
pub mode: Option<String>,
pub last_heartbeat_at_monotonic_ns: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PtyState {
pub active: bool,
pub drive: Option<DriveWindow>,
}
impl PtyState {
pub fn take_active_drive(&mut self) -> Option<DriveWindow> {
self.drive.take()
}
}
#[derive(Serialize, Deserialize)]
struct PtyStateWire {
active: bool,
#[serde(default)]
drive_active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
drive_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
drive_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_heartbeat_at_monotonic_ns: Option<u64>,
}
impl Serialize for PtyState {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let wire = match &self.drive {
Some(d) => PtyStateWire {
active: self.active,
drive_active: true,
drive_session_id: d.session_id.clone(),
drive_mode: d.mode.clone(),
last_heartbeat_at_monotonic_ns: d.last_heartbeat_at_monotonic_ns,
},
None => PtyStateWire {
active: self.active,
drive_active: false,
drive_session_id: None,
drive_mode: None,
last_heartbeat_at_monotonic_ns: None,
},
};
wire.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for PtyState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = PtyStateWire::deserialize(deserializer)?;
let drive = if wire.drive_active {
Some(DriveWindow {
session_id: wire.drive_session_id,
mode: wire.drive_mode,
last_heartbeat_at_monotonic_ns: wire.last_heartbeat_at_monotonic_ns,
})
} else {
None
};
Ok(PtyState {
active: wire.active,
drive,
})
}
}
pub fn load_registry(path: &Path) -> Result<Registry, StateError> {
let lock = acquire_shared(&lock_path(path))?;
let result = match OpenOptions::new().read(true).open(path) {
Ok(file) => read_registry_tolerant(&file),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let _ = lock.unlock();
return Ok(Registry::default());
}
Err(e) => {
let _ = lock.unlock();
return Err(e.into());
}
};
let _ = lock.unlock();
result
}
fn read_registry_tolerant(mut file: &File) -> Result<Registry, StateError> {
let mut buf = String::new();
file.read_to_string(&mut buf)?;
if buf.trim().is_empty() {
return Ok(Registry::default());
}
let mut reg: Registry = serde_json::from_str(&buf)?;
for entry in &mut reg.entries {
entry.backfill_harness_aliases();
if let Some(legacy) = entry.backfill_short_id() {
eprintln!(
"fno agents: warning: registry row {:?} carries short_id={:?} and legacy claude_short_id={:?}; keeping short_id",
entry.name, entry.short_id, legacy
);
}
}
if reg.schema_version < 1 || reg.schema_version > REGISTRY_SCHEMA_VERSION {
return Err(StateError::UnsupportedSchemaVersion {
found: reg.schema_version,
max: REGISTRY_SCHEMA_VERSION,
});
}
Ok(reg)
}
pub fn update_registry<F, T>(path: &Path, f: F) -> Result<T, StateError>
where
F: FnOnce(&mut Registry) -> T,
{
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let lock = acquire_exclusive(&lock_path(path))?;
let mut registry = read_existing_registry(path)?;
let before = registry
.entries
.iter()
.map(|entry| (entry.name.clone(), identity_signature(entry)))
.collect::<BTreeMap<_, _>>();
let out = f(&mut registry);
for entry in &mut registry.entries {
entry.backfill_harness_aliases();
}
validate_changed_identities(&before, ®istry.entries)
.map_err(StateError::InvariantViolation)?;
for entry in ®istry.entries {
if let Err(msg) = validate_single_live_ref(entry) {
return Err(StateError::InvariantViolation(msg));
}
}
registry.schema_version = REGISTRY_SCHEMA_VERSION;
write_json_atomic(path, ®istry)?;
let _ = lock.unlock();
Ok(out)
}
type IdentitySignature = (String, String, String, String);
fn identity_signature(entry: &RegistryEntry) -> IdentitySignature {
(
entry.name.clone(),
entry.short_id.clone(),
entry.harness_name().to_string(),
entry.harness_session_id.clone().unwrap_or_default(),
)
}
fn validate_changed_identities(
before: &BTreeMap<String, IdentitySignature>,
entries: &[RegistryEntry],
) -> Result<(), String> {
use crate::identity::{canonical_handle, legacy_prefix_handle, session_handle_tier};
let matches = |token: &str, other: &RegistryEntry, include_legacy: bool| {
if token == other.name || (!other.short_id.is_empty() && token == other.short_id) {
return true;
}
let Some(session_id) = other.harness_session_id.as_deref() else {
return false;
};
match session_handle_tier(token, session_id) {
Some(2) => include_legacy,
Some(_) => true,
None => false,
}
};
for (index, candidate) in entries.iter().enumerate() {
if before.get(&candidate.name) == Some(&identity_signature(candidate)) {
continue;
}
let mut strong = BTreeSet::from([candidate.name.clone()]);
if !candidate.short_id.is_empty() {
strong.insert(candidate.short_id.clone());
}
let session_id = candidate.harness_session_id.as_deref().unwrap_or("");
if !session_id.is_empty() {
strong.insert(session_id.to_string());
strong.insert(canonical_handle(session_id));
}
let legacy = (!session_id.is_empty()).then(|| legacy_prefix_handle(session_id));
for (other_index, other) in entries.iter().enumerate() {
if index == other_index {
continue;
}
let collision = strong
.iter()
.find(|token| matches(token, other, true))
.cloned()
.or_else(|| {
legacy
.as_ref()
.filter(|token| matches(token, other, false))
.cloned()
});
if let Some(token) = collision {
return Err(format!(
"registry identity {token:?} for new or changed row {:?} collides with row {:?}; use a different name or the full session id",
candidate.name, other.name
));
}
}
}
Ok(())
}
fn read_existing_registry(path: &Path) -> Result<Registry, StateError> {
match OpenOptions::new().read(true).open(path) {
Ok(file) => read_registry_tolerant(&file),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
Err(e) => Err(e.into()),
}
}
pub fn load_state(path: &Path) -> Result<Option<AgentState>, StateError> {
let lock = acquire_shared(&lock_path(path))?;
let r = match OpenOptions::new().read(true).open(path) {
Ok(file) => read_json::<AgentState>(&file),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let _ = lock.unlock();
return Ok(None);
}
Err(e) => {
let _ = lock.unlock();
return Err(e.into());
}
};
let _ = lock.unlock();
match r {
Ok(s) => Ok(Some(s)),
Err(_) => Ok(None),
}
}
pub fn write_state_atomic(path: &Path, state: &AgentState) -> Result<(), StateError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let lock = acquire_exclusive(&lock_path(path))?;
write_json_atomic(path, state)?;
let _ = lock.unlock();
Ok(())
}
pub fn update_state_atomic<F>(path: &Path, f: F) -> Result<bool, StateError>
where
F: FnOnce(&mut AgentState),
{
let lock = acquire_exclusive(&lock_path(path))?;
let existing = match OpenOptions::new().read(true).open(path) {
Ok(file) => read_json::<AgentState>(&file).ok(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
let _ = lock.unlock();
return Err(e.into());
}
};
let result = match existing {
Some(mut st) => {
f(&mut st);
write_json_atomic(path, &st)?;
true
}
None => false,
};
let _ = lock.unlock();
Ok(result)
}
fn lock_path(path: &Path) -> PathBuf {
let mut s = path.as_os_str().to_os_string();
s.push(".lock");
PathBuf::from(s)
}
fn acquire_exclusive(lock_file: &Path) -> Result<File, StateError> {
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(lock_file)?;
file.lock()?;
Ok(file)
}
fn acquire_shared(lock_file: &Path) -> Result<File, StateError> {
if let Some(parent) = lock_file.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(lock_file)?;
file.lock_shared()?;
Ok(file)
}
fn read_json<T: for<'de> Deserialize<'de>>(mut file: &File) -> Result<T, StateError> {
let mut buf = String::new();
file.read_to_string(&mut buf)?;
Ok(serde_json::from_str(&buf)?)
}
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), StateError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let tmp = parent.join(format!(
".{}.tmp.{}",
path.file_name().and_then(|s| s.to_str()).unwrap_or("state"),
std::process::id()
));
{
let mut f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp)?;
let bytes = serde_json::to_vec_pretty(value)?;
f.write_all(&bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enters_fires_once_per_episode() {
use InsideLegState::{Blocked, Done, Working};
let seq = [Working, Blocked, Blocked, Blocked, Working, Blocked];
let fired: Vec<bool> = seq
.iter()
.enumerate()
.map(|(i, &s)| {
let prev = if i == 0 { None } else { Some(seq[i - 1]) };
enters(prev, s, Blocked)
})
.collect();
assert_eq!(fired, [false, true, false, false, false, true]);
assert!(enters(None, Blocked, Blocked));
assert!(enters(Some(Working), Done, Done));
assert!(!enters(Some(Done), Done, Done));
}
fn tmpdir(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"fno-agents-state-{}-{}-{}",
tag,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&p).unwrap();
p
}
fn sample_entry(name: &str) -> RegistryEntry {
RegistryEntry {
name: name.into(),
short_id: format!("{name}-id"),
legacy_provider: "codex".into(),
harness: None,
harness_session_id: None,
cwd: "/tmp/x".into(),
project_root: "/tmp/x".into(),
session_id: Some("uuid-1".into()),
claude_session_uuid: None,
messaging_socket_path: None,
codex_session_id: Some("uuid-1".into()),
gemini_session_id: None,
mcp_channel_id: None,
host_mode: None,
cc_session_id: None,
status: AgentStatus::Live,
last_message_at: None,
created_at: "2026-05-24T00:00:00Z".into(),
pid: Some(1234),
pid_start_time: None,
log_path: None,
last_reconciled_at: None,
inside_leg: None,
exited_at: None,
mux: None,
screen_state: None,
crown_level: None,
crown_scope: None,
crown_grantor: None,
legacy_claude_short_id: None,
}
}
#[test]
fn state_mux_ref_roundtrips_and_python_dict_shape_parses() {
let mut e = sample_entry("mux-agent");
e.short_id = String::new(); e.mux = Some(MuxRef {
session: "work".into(),
pane_id: 7,
});
let json = serde_json::to_string(&e).unwrap();
let back: RegistryEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.mux.as_ref().unwrap().session, "work");
assert_eq!(back.mux.as_ref().unwrap().pane_id, 7);
let python_row = r#"{"name":"m","provider":"claude","cwd":"/p","log_path":null,
"claude_short_id":null,"codex_session_id":null,"gemini_session_id":null,
"created_at":"2026-07-02T00:00:00Z","status":"live","last_message_at":null,
"mcp_channel_id":null,"mux":{"session":"main","pane_id":3}}"#;
let row: RegistryEntry = serde_json::from_str(python_row).unwrap();
assert_eq!(row.mux.as_ref().unwrap().pane_id, 3);
assert_eq!(sample_entry("plain").mux, None);
}
#[test]
fn harness_backfill_legacy_row_gains_canonical() {
let python_legacy = r#"{"name":"w","provider":"claude","cwd":"/p","log_path":null,
"claude_short_id":"7c5dcf5d","claude_session_uuid":"UUID-1","codex_session_id":null,
"gemini_session_id":null,"created_at":"2026-07-13T00:00:00Z","status":"live",
"last_message_at":null,"mcp_channel_id":null}"#;
let mut e: RegistryEntry = serde_json::from_str(python_legacy).unwrap();
e.backfill_harness_aliases();
assert_eq!(e.harness.as_deref(), Some("claude"));
assert_eq!(e.harness_session_id.as_deref(), Some("UUID-1"));
}
#[test]
fn backfill_short_id_moves_legacy_into_empty_short() {
let legacy = r#"{"name":"w","provider":"claude","cwd":"/p","log_path":null,
"claude_short_id":"7c5dcf5d","created_at":"2026-07-13T00:00:00Z","status":"live"}"#;
let mut e: RegistryEntry = serde_json::from_str(legacy).unwrap();
assert_eq!(e.backfill_short_id(), None);
assert_eq!(e.short_id, "7c5dcf5d");
assert_eq!(e.legacy_claude_short_id, None); }
#[test]
fn backfill_short_id_conflict_keeps_short_and_reports_legacy() {
let conflict = r#"{"name":"w","provider":"claude","cwd":"/p","log_path":null,
"short_id":"aaaaaaaa","claude_short_id":"bbbbbbbb",
"created_at":"2026-07-13T00:00:00Z","status":"live"}"#;
let mut e: RegistryEntry = serde_json::from_str(conflict).unwrap();
assert_eq!(e.backfill_short_id().as_deref(), Some("bbbbbbbb"));
assert_eq!(e.short_id, "aaaaaaaa"); }
#[test]
fn harness_backfill_canonical_only_row_syncs_legacy() {
let mut e = sample_entry("w");
e.legacy_provider = "claude".into();
e.codex_session_id = None;
e.session_id = None;
e.claude_session_uuid = None;
e.harness = Some("claude".into());
e.harness_session_id = Some("CANON".into());
e.backfill_harness_aliases();
assert_eq!(e.claude_session_uuid.as_deref(), Some("CANON"));
}
#[test]
fn harness_backfill_conflict_is_canonical_wins() {
let mut e = sample_entry("w");
e.legacy_provider = "claude".into();
e.harness = Some("claude".into());
e.harness_session_id = Some("CANON".into());
e.claude_session_uuid = Some("STALE".into());
e.backfill_harness_aliases();
assert_eq!(e.harness_session_id.as_deref(), Some("CANON"));
assert_eq!(e.claude_session_uuid.as_deref(), Some("CANON"));
}
#[test]
fn harness_backfill_does_not_cross_contaminate() {
let mut e = sample_entry("w");
e.legacy_provider = "claude".into();
e.harness = Some("claude".into());
e.harness_session_id = None;
e.claude_session_uuid = None;
e.codex_session_id = Some("STALE-CODEX".into());
e.session_id = None;
e.backfill_harness_aliases();
assert_eq!(e.harness_session_id, None);
}
#[test]
fn harness_backfill_reads_python_canonical_row_via_registry() {
let python_json = r#"{"schema_version":7,"agents":[{"name":"w","provider":"codex",
"cwd":"/p","log_path":null,"claude_short_id":null,"codex_session_id":null,
"gemini_session_id":null,"created_at":"2026-07-13T00:00:00Z","status":"live",
"last_message_at":null,"mcp_channel_id":null,"harness":"codex",
"harness_session_id":"THREAD"}]}"#;
let mut reg: Registry = serde_json::from_str(python_json).unwrap();
for e in &mut reg.entries {
e.backfill_harness_aliases();
}
assert_eq!(reg.entries[0].harness_session_id.as_deref(), Some("THREAD"));
assert_eq!(reg.entries[0].codex_session_id.as_deref(), Some("THREAD"));
}
#[test]
fn state_mux_row_skips_key_when_absent() {
let v = serde_json::to_value(sample_entry("w")).unwrap();
assert!(v.get("mux").is_none());
}
#[test]
fn state_mux_row_is_never_a_one_shot_ask() {
let mut e = sample_entry("mux-live");
e.short_id = String::new();
e.pid = None;
assert!(e.is_one_shot_ask(), "baseline: bare row reads as ask");
e.mux = Some(MuxRef {
session: "main".into(),
pane_id: 4,
});
assert!(!e.is_one_shot_ask(), "a mux ref is a live hosting handle");
}
#[test]
fn state_v9_claude_shellout_row_is_a_one_shot_ask() {
let mut ask = sample_entry("cc-ask");
ask.legacy_provider = "claude".into();
ask.short_id = "7c5dcf5d".into(); ask.host_mode = None; ask.pid = None;
ask.mux = None;
assert!(
ask.is_one_shot_ask(),
"a v9 claude shellout row (non-empty short_id, exec, no pid) is a one-shot ask"
);
let mut worker = ask.clone();
worker.host_mode = Some(HOST_MODE_INTERACTIVE.into());
assert!(
!worker.is_one_shot_ask(),
"an interactive claude worker is PTY-managed, not a one-shot ask"
);
let mut adopted = ask.clone();
adopted.host_mode = Some(HOST_MODE_ATTACHED.into());
adopted.pid = Some(4242);
assert!(
!adopted.is_one_shot_ask(),
"an adopted row (external pid) is not a one-shot ask"
);
}
#[test]
fn state_update_registry_enforces_one_live_ref() {
let dir = tmpdir("one-ref");
let path = dir.join("registry.json");
let res = update_registry(&path, |r| {
let mut e = sample_entry("double"); e.mux = Some(MuxRef {
session: "main".into(),
pane_id: 1,
});
r.entries.push(e);
});
assert!(
matches!(res, Err(StateError::InvariantViolation(_))),
"double-ref row must be refused: {res:?}"
);
assert!(
load_registry(&path).unwrap().entries.is_empty(),
"refused write must not persist"
);
let res = update_registry(&path, |r| {
let mut e = sample_entry("bg-double");
e.short_id = "abcd1234".into();
e.mux = Some(MuxRef {
session: "main".into(),
pane_id: 2,
});
r.entries.push(e);
});
assert!(matches!(res, Err(StateError::InvariantViolation(_))));
update_registry(&path, |r| {
let mut e = sample_entry("clean");
e.short_id = String::new();
e.mux = Some(MuxRef {
session: "main".into(),
pane_id: 3,
});
r.entries.push(e);
})
.unwrap();
let reg = load_registry(&path).unwrap();
assert_eq!(reg.entries.len(), 1);
assert_eq!(reg.schema_version, REGISTRY_SCHEMA_VERSION);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn state_update_registry_refuses_new_canonical_handle_collision() {
let dir = tmpdir("identity-collision");
let path = dir.join("registry.json");
update_registry(&path, |registry| {
let mut first = sample_entry("first");
first.short_id = "transport1".into();
first.harness = Some("codex".into());
first.harness_session_id = Some("aaaaaaaa-0000-0000-0000-1111deadbeef".into());
registry.entries.push(first);
})
.unwrap();
let result = update_registry(&path, |registry| {
let mut second = sample_entry("second");
second.short_id = "transport2".into();
second.harness = Some("codex".into());
second.harness_session_id = Some("bbbbbbbb-0000-0000-0000-2222deadbeef".into());
registry.entries.push(second);
});
assert!(matches!(result, Err(StateError::InvariantViolation(_))));
assert_eq!(load_registry(&path).unwrap().entries.len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn state_update_registry_allows_retired_prefix_collision() {
let dir = tmpdir("legacy-prefix-compatible");
let path = dir.join("registry.json");
update_registry(&path, |registry| {
let mut first = sample_entry("first");
first.short_id = "transport1".into();
first.harness = Some("codex".into());
first.harness_session_id = Some("019fb417-0000-0000-0000-111122223333".into());
registry.entries.push(first);
})
.unwrap();
update_registry(&path, |registry| {
let mut second = sample_entry("second");
second.short_id = "transport2".into();
second.harness = Some("codex".into());
second.harness_session_id = Some("019fb417-0000-0000-0000-444455556666".into());
registry.entries.push(second);
})
.unwrap();
assert_eq!(load_registry(&path).unwrap().entries.len(), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_registry_loads_empty() {
let dir = tmpdir("missing");
let reg = load_registry(&dir.join("registry.json")).unwrap();
assert_eq!(reg.schema_version, REGISTRY_SCHEMA_VERSION);
assert!(reg.entries.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn python_written_registry_loads_via_typed_path() {
let dir = tmpdir("python-registry");
let path = dir.join("registry.json");
let python_json = r#"{
"schema_version": 3,
"agents": [
{
"name": "worker-claude",
"provider": "claude",
"cwd": "/Users/x/proj",
"log_path": "/Users/x/.fno/agents/worker-claude.log",
"claude_short_id": "abc123",
"codex_session_id": null,
"gemini_session_id": null,
"created_at": "2026-05-26T00:00:00Z",
"status": "live",
"last_message_at": null,
"mcp_channel_id": null
}
]
}"#;
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&path, python_json).unwrap();
let reg = load_registry(&path).unwrap();
assert_eq!(reg.entries.len(), 1, "Python-written row must be read");
let e = reg.find("worker-claude").unwrap();
assert_eq!(e.harness_name(), "claude");
assert_eq!(e.status, AgentStatus::Live);
assert_eq!(e.short_id, "abc123");
assert_eq!(e.legacy_claude_short_id, None); assert_eq!(e.project_root, "");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn python_row_roundtrips_to_python_shape_under_agents_key() {
let python_json = r#"{"schema_version":10,"agents":[
{"name":"w","harness":"codex","cwd":"/p","log_path":"/l",
"harness_session_id":"sid","created_at":"2026-05-26T00:00:00Z",
"status":"live","last_message_at":null,"mcp_channel_id":null}]}"#;
let reg: Registry = serde_json::from_str(python_json).unwrap();
let out: serde_json::Value = serde_json::to_value(®).unwrap();
assert!(out.get("agents").is_some(), "must serialize under `agents`");
assert!(out.get("entries").is_none(), "must NOT serialize `entries`");
let row = &out["agents"][0];
for rust_only in [
"short_id",
"project_root",
"session_id",
"messaging_socket_path",
"cc_session_id",
"pid",
"pid_start_time",
"last_reconciled_at",
] {
assert!(
row.get(rust_only).is_none(),
"Python-authored row must omit Rust-only field `{rust_only}`"
);
}
for removed in [
"provider",
"codex_session_id",
"gemini_session_id",
"claude_session_uuid",
] {
assert!(
row.get(removed).is_none(),
"v10 row must omit removed key `{removed}`"
);
}
assert_eq!(row["name"], "w");
assert_eq!(row["harness"], "codex");
assert_eq!(row["harness_session_id"], "sid");
}
#[test]
fn host_mode_cross_language_round_trip_parity() {
let no_key = r#"{"schema_version":3,"agents":[
{"name":"legacy","provider":"codex","cwd":"/p","log_path":"/l",
"created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#;
let reg: Registry = serde_json::from_str(no_key).unwrap();
assert_eq!(reg.entries[0].host_mode, None);
assert_eq!(reg.entries[0].host_mode_or_default(), HOST_MODE_EXEC);
assert!(!reg.entries[0].is_interactive());
let interactive = r#"{"schema_version":3,"agents":[
{"name":"bot2","provider":"codex","cwd":"/p","log_path":"/l",
"codex_session_id":"019e7157","created_at":"2026-05-26T00:00:00Z",
"status":"live","host_mode":"interactive"}]}"#;
let reg: Registry = serde_json::from_str(interactive).unwrap();
assert_eq!(reg.entries[0].host_mode_or_default(), HOST_MODE_INTERACTIVE);
assert!(reg.entries[0].is_interactive());
let mut exec_entry = sample_entry("w");
exec_entry.host_mode = None;
let mut reg = Registry::default();
reg.entries.push(exec_entry);
let out: serde_json::Value = serde_json::to_value(®).unwrap();
assert!(
out["agents"][0].get("host_mode").is_none(),
"exec row must omit host_mode (skip_serializing_if)"
);
let mut int_entry = sample_entry("bot2");
int_entry.host_mode = Some(HOST_MODE_INTERACTIVE.to_string());
let mut reg = Registry::default();
reg.entries.push(int_entry);
let out: serde_json::Value = serde_json::to_value(®).unwrap();
assert_eq!(out["agents"][0]["host_mode"], "interactive");
}
#[test]
fn screen_state_cross_language_round_trip_parity() {
let no_key = r#"{"schema_version":6,"agents":[
{"name":"legacy","provider":"codex","cwd":"/p","log_path":"/l",
"created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#;
let reg: Registry = serde_json::from_str(no_key).unwrap();
assert_eq!(reg.entries[0].screen_state, None);
let with_verdict = r#"{"schema_version":7,"agents":[
{"name":"pane","provider":"codex","cwd":"/p","log_path":"/l",
"created_at":"2026-05-26T00:00:00Z","status":"live",
"screen_state":{"state":"idle","rule":"idle_prompt","seq":3,
"at":"2026-07-02T00:00:00Z","ttl_ms":30000}}]}"#;
let reg: Registry = serde_json::from_str(with_verdict).unwrap();
let v = reg.entries[0].screen_state.as_ref().unwrap();
assert_eq!(v.state, "idle");
assert_eq!(v.rule, "idle_prompt");
assert_eq!(v.seq, 3);
assert_eq!(v.at, "2026-07-02T00:00:00Z");
assert_eq!(v.ttl_ms, Some(30000));
let mut reg = Registry::default();
reg.entries.push(sample_entry("w"));
let out: serde_json::Value = serde_json::to_value(®).unwrap();
assert!(
out["agents"][0].get("screen_state").is_none(),
"row without a verdict must omit screen_state (skip_serializing_if)"
);
let mut scraped = sample_entry("pane");
scraped.screen_state = Some(ScreenStateReport {
state: "blocked".into(),
rule: "permission_prompt".into(),
seq: 9,
at: "2026-07-02T01:00:00Z".into(),
ttl_ms: None,
answerable: None,
});
let mut reg = Registry::default();
reg.entries.push(scraped.clone());
let out: serde_json::Value = serde_json::to_value(®).unwrap();
assert!(
out["agents"][0]["screen_state"].get("ttl_ms").is_none(),
"absent ttl_ms omitted"
);
let reg2: Registry = serde_json::from_value(out).unwrap();
assert_eq!(reg2.entries[0].screen_state, scraped.screen_state);
}
#[test]
fn screen_state_report_ttl_ages_and_fails_closed() {
let now = rfc3339_like_to_secs("2026-07-02T00:01:00Z").unwrap();
let mk = |at: &str, ttl_ms: Option<u64>| ScreenStateReport {
state: "working".into(),
rule: "busy".into(),
seq: 1,
at: at.into(),
ttl_ms,
answerable: None,
};
assert!(mk("2026-07-02T00:00:00Z", None).is_live_at(now));
assert!(mk("2026-07-02T00:00:30Z", Some(60_000)).is_live_at(now));
assert!(!mk("2026-07-02T00:00:00Z", Some(5_000)).is_live_at(now));
assert!(!mk("garbage", Some(60_000)).is_live_at(now));
}
#[test]
fn inside_leg_cross_language_round_trip_parity() {
let no_key = r#"{"schema_version":5,"agents":[
{"name":"legacy","provider":"codex","cwd":"/p","log_path":"/l",
"created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#;
let reg: Registry = serde_json::from_str(no_key).unwrap();
assert_eq!(reg.entries[0].inside_leg, None);
let with_report = r#"{"schema_version":5,"agents":[
{"name":"pane","provider":"claude","cwd":"/p","log_path":"/l",
"created_at":"2026-05-26T00:00:00Z","status":"live",
"inside_leg":{"state":"working","seq":7,"reason":"running tests",
"received_at":"2026-06-27T00:00:00Z","ttl_ms":5000}}]}"#;
let reg: Registry = serde_json::from_str(with_report).unwrap();
let rep = reg.entries[0].inside_leg.as_ref().unwrap();
assert_eq!(rep.state, InsideLegState::Working);
assert_eq!(rep.seq, 7);
assert_eq!(rep.reason.as_deref(), Some("running tests"));
assert_eq!(rep.received_at, "2026-06-27T00:00:00Z");
assert_eq!(rep.ttl_ms, Some(5000));
let mut bare = sample_entry("w");
bare.inside_leg = None;
let mut reg = Registry::default();
reg.entries.push(bare);
let out: serde_json::Value = serde_json::to_value(®).unwrap();
assert!(
out["agents"][0].get("inside_leg").is_none(),
"row without a report must omit inside_leg (skip_serializing_if)"
);
let mut withrep = sample_entry("pane");
withrep.inside_leg = Some(InsideLegReport {
state: InsideLegState::Done,
seq: 12,
reason: None,
received_at: "2026-06-27T01:00:00Z".into(),
ttl_ms: None,
});
let mut reg = Registry::default();
reg.entries.push(withrep);
let out: serde_json::Value = serde_json::to_value(®).unwrap();
let badge = &out["agents"][0]["inside_leg"];
assert_eq!(badge["state"], "done");
assert_eq!(badge["seq"], 12);
assert!(badge.get("reason").is_none(), "absent reason omitted");
assert!(badge.get("ttl_ms").is_none(), "absent ttl_ms omitted");
let reg2: Registry = serde_json::from_value(out).unwrap();
assert_eq!(
reg2.entries[0].inside_leg,
Some(InsideLegReport {
state: InsideLegState::Done,
seq: 12,
reason: None,
received_at: "2026-06-27T01:00:00Z".into(),
ttl_ms: None,
})
);
}
#[test]
fn rfc3339_like_to_secs_round_trips_known_stamps() {
assert_eq!(rfc3339_like_to_secs("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(
rfc3339_like_to_secs("2026-06-27T00:00:00Z"),
Some(1_782_518_400)
);
assert_eq!(
rfc3339_like_to_secs("2026-06-27T00:00:05Z"),
Some(1_782_518_405)
);
}
#[test]
fn rfc3339_like_to_secs_rejects_malformed() {
for bad in [
"",
"2026-06-27",
"2026-06-27T00:00:00", "2026/06/27T00:00:00Z", "20260627T000000Z", "2026-13-27T00:00:00Z", "2026-06-27T24:00:00Z", "2026-06-27T00:00:00.5Z", "abcd-ef-ghTij:kl:mnZ", ] {
assert_eq!(rfc3339_like_to_secs(bad), None, "must reject {bad:?}");
}
}
#[test]
fn inside_leg_is_live_at_ttl_gate() {
let recv = "2026-06-27T00:00:00Z";
let recv_secs = rfc3339_like_to_secs(recv).unwrap();
let rep = |ttl| InsideLegReport {
state: InsideLegState::Working,
seq: 1,
reason: None,
received_at: recv.into(),
ttl_ms: ttl,
};
assert!(rep(None).is_live_at(recv_secs + 10_000));
assert!(rep(Some(5000)).is_live_at(recv_secs + 4));
assert!(rep(Some(5000)).is_live_at(recv_secs + 5));
assert!(!rep(Some(5000)).is_live_at(recv_secs + 6));
assert!(rep(Some(5000)).is_live_at(recv_secs.saturating_sub(100)));
let mut corrupt = rep(Some(5000));
corrupt.received_at = "not-a-stamp".into();
assert!(!corrupt.is_live_at(recv_secs));
}
#[test]
fn rust_reads_python_row_with_explicit_empty_and_null_fields() {
let python_json = r#"{"schema_version":4,"agents":[
{"name":"py-ask","provider":"codex","cwd":"/p","log_path":"/l",
"short_id":"","project_root":"",
"claude_short_id":null,"codex_session_id":"sid","gemini_session_id":null,
"claude_session_uuid":null,"messaging_socket_path":null,"cc_session_id":null,
"mcp_channel_id":null,"host_mode":"exec",
"created_at":"2026-05-26T00:00:00Z","status":"exited","last_message_at":null,
"pid":null,"pid_start_time":null,"last_reconciled_at":null}]}"#;
let reg: Registry = serde_json::from_str(python_json).unwrap();
let e = ®.entries[0];
assert_eq!(e.name, "py-ask");
assert_eq!(e.short_id, ""); assert_eq!(e.project_root, "");
assert_eq!(e.pid, None); assert_eq!(e.pid_start_time, None);
assert_eq!(e.cc_session_id, None);
assert_eq!(e.codex_session_id.as_deref(), Some("sid"));
assert!(e.is_one_shot_ask(), "empty short_id + no pid => ask row");
}
#[test]
fn pty_agent_still_serializes_its_short_id() {
let mut reg = Registry::default();
reg.entries.push(sample_entry("worker-A")); let out: serde_json::Value = serde_json::to_value(®).unwrap();
let row = &out["agents"][0];
assert_eq!(row["short_id"], "worker-A-id");
assert_eq!(row["pid"], 1234);
}
#[test]
fn empty_registry_file_loads_default_but_corrupt_file_errors() {
let dir = tmpdir("corrupt-registry");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("registry.json");
std::fs::write(&path, " \n").unwrap();
assert!(load_registry(&path).unwrap().entries.is_empty());
std::fs::write(&path, "{ this is not json").unwrap();
assert!(
load_registry(&path).is_err(),
"corrupt registry must surface an error"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn update_registry_refuses_to_wipe_a_corrupt_registry() {
let dir = tmpdir("no-wipe");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("registry.json");
let corrupt = "{\"schema_version\": 3, \"agents\": [ BROKEN";
std::fs::write(&path, corrupt).unwrap();
let result = update_registry(&path, |r| r.entries.push(sample_entry("new-A")));
assert!(result.is_err(), "update over corrupt registry must error");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
corrupt,
"corrupt registry must be left untouched, not overwritten"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn update_registry_upgrades_schema_version_on_write() {
let dir = tmpdir("upgrade-on-write");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("registry.json");
std::fs::write(
&path,
r#"{"schema_version":3,"agents":[{"name":"w","provider":"codex","cwd":"/p","log_path":"/l","created_at":"2026-05-26T00:00:00Z","status":"live"}]}"#,
)
.unwrap();
update_registry(&path, |r| r.entries.push(sample_entry("w2"))).unwrap();
let on_disk: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
on_disk["schema_version"], REGISTRY_SCHEMA_VERSION,
"Rust write must upgrade the on-disk schema_version"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_registry_rejects_unsupported_schema_version() {
let dir = tmpdir("version-guard");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("registry.json");
std::fs::write(&path, r#"{"schema_version":12,"agents":[]}"#).unwrap();
match load_registry(&path) {
Err(StateError::UnsupportedSchemaVersion { found, max }) => {
assert_eq!(found, 12);
assert_eq!(max, REGISTRY_SCHEMA_VERSION);
}
other => panic!("expected UnsupportedSchemaVersion, got {other:?}"),
}
std::fs::write(&path, r#"{"schema_version":1,"agents":[]}"#).unwrap();
assert!(
load_registry(&path).is_ok(),
"v1 must still read (back-compat)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn update_then_load_roundtrips_and_preserves_optionals() {
let dir = tmpdir("roundtrip");
let path = dir.join("registry.json");
update_registry(&path, |r| r.entries.push(sample_entry("worker-A"))).unwrap();
update_registry(&path, |r| {
r.find_mut("worker-A").unwrap().status = AgentStatus::Idle;
})
.unwrap();
let reg = load_registry(&path).unwrap();
let e = reg.find("worker-A").unwrap();
assert_eq!(e.status, AgentStatus::Idle);
assert_eq!(e.codex_session_id.as_deref(), Some("uuid-1"));
assert_eq!(e.pid, Some(1234));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn state_json_absent_is_none_present_roundtrips() {
let dir = tmpdir("state");
let path = dir.join("wkA/state.json");
assert!(load_state(&path).unwrap().is_none());
let st = AgentState::new_pty("wkA");
write_state_atomic(&path, &st).unwrap();
let back = load_state(&path).unwrap().unwrap();
assert_eq!(back.short_id, "wkA");
assert_eq!(back.status, AgentStatus::Spawning);
assert!(back.pty.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn empty_state_file_treated_as_absent() {
let dir = tmpdir("empty-state");
let path = dir.join("state.json");
std::fs::write(&path, b"").unwrap();
assert!(load_state(&path).unwrap().is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn take_active_drive_reads_before_clear() {
let mut pty = PtyState {
active: true,
drive: Some(DriveWindow {
session_id: Some("drive-uuid".into()),
mode: Some("interactive".into()),
last_heartbeat_at_monotonic_ns: Some(42),
}),
};
let taken = pty.take_active_drive().expect("a drive was active");
assert_eq!(taken.session_id.as_deref(), Some("drive-uuid"));
assert_eq!(taken.mode.as_deref(), Some("interactive"));
assert!(pty.drive.is_none());
assert!(pty.take_active_drive().is_none());
}
#[test]
fn take_active_drive_none_when_no_drive() {
let mut pty = PtyState::default();
assert!(pty.take_active_drive().is_none());
}
#[test]
fn pty_state_wire_shape_is_flat_and_stable() {
let no_drive = PtyState {
active: true,
drive: None,
};
assert_eq!(
serde_json::to_value(&no_drive).unwrap(),
serde_json::json!({"active": true, "drive_active": false})
);
let with_drive = PtyState {
active: true,
drive: Some(DriveWindow {
session_id: Some("d-1".into()),
mode: Some("interactive".into()),
last_heartbeat_at_monotonic_ns: Some(99),
}),
};
assert_eq!(
serde_json::to_value(&with_drive).unwrap(),
serde_json::json!({
"active": true,
"drive_active": true,
"drive_session_id": "d-1",
"drive_mode": "interactive",
"last_heartbeat_at_monotonic_ns": 99
})
);
let back: PtyState =
serde_json::from_value(serde_json::to_value(&with_drive).unwrap()).unwrap();
assert_eq!(back, with_drive);
}
#[test]
fn pty_state_collapses_inconsistent_legacy_shape() {
let legacy = serde_json::json!({
"active": true,
"drive_active": false,
"drive_session_id": "stray",
});
let pty: PtyState = serde_json::from_value(legacy).unwrap();
assert!(pty.drive.is_none());
}
}