use crate::paths::AgentsHome;
use crate::state::{self, InsideLegState, RegistryEntry};
use crate::AgentStatus;
use serde_json::json;
use std::time::{Duration, Instant};
pub const WAIT_TIMEOUT_EXIT: i32 = 124;
const POLL_INTERVAL: Duration = Duration::from_millis(250);
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffState {
Working,
Blocked,
Done,
Idle,
}
impl EffState {
pub fn label(self) -> &'static str {
match self {
EffState::Working => "working",
EffState::Blocked => "blocked",
EffState::Done => "done",
EffState::Idle => "idle",
}
}
}
pub fn effective_state(e: &RegistryEntry, now_secs: u64) -> (EffState, &'static str) {
if matches!(e.status, AgentStatus::Exited | AgentStatus::PermanentDead) {
return (EffState::Done, "exit");
}
if let Some(leg) = &e.inside_leg {
if leg.is_live_at(now_secs) {
let st = match leg.state {
InsideLegState::Working => EffState::Working,
InsideLegState::Blocked => EffState::Blocked,
InsideLegState::Done => EffState::Done,
};
return (st, "hook");
}
return (EffState::Idle, "liveness");
}
if let Some(ss) = &e.screen_state {
if ss.is_live_at(now_secs) {
let st = match ss.state.as_str() {
"working" => EffState::Working,
"blocked" => EffState::Blocked,
_ => EffState::Idle,
};
return (st, "screen");
}
}
(EffState::Idle, "liveness")
}
fn parse_target(s: &str) -> Option<EffState> {
match s {
"idle" => Some(EffState::Idle),
"blocked" => Some(EffState::Blocked),
"done" => Some(EffState::Done),
_ => None,
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn find_effective(
home: &AgentsHome,
name: &str,
now: u64,
) -> Result<Option<(EffState, &'static str)>, String> {
let reg = state::load_registry(&home.registry_json()).map_err(|e| e.to_string())?;
Ok(reg
.entries
.iter()
.find(|e| e.name == name)
.map(|e| effective_state(e, now)))
}
pub async fn run_wait(rest: &[String], home: &AgentsHome) -> i32 {
let mut name: Option<String> = None;
let mut target: Option<String> = None;
let mut timeout_ms = DEFAULT_TIMEOUT_MS;
let mut json_out = false;
let mut it = rest.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--agent" => match it.next() {
Some(v) => name = Some(v.clone()),
None => {
eprintln!("fno-agents: --agent needs a value");
return 2;
}
},
"--state" => match it.next() {
Some(v) => target = Some(v.clone()),
None => {
eprintln!("fno-agents: --state needs a value");
return 2;
}
},
"--timeout-ms" => match it.next().and_then(|v| v.parse::<u64>().ok()) {
Some(n) => timeout_ms = n,
None => {
eprintln!("fno-agents: --timeout-ms needs a numeric value");
return 2;
}
},
"--json" | "-J" => json_out = true,
other if other.starts_with("--") => {
eprintln!("fno-agents: wait: unknown flag: {other}");
return 2;
}
_ if name.is_none() => name = Some(a.clone()),
_ => {
eprintln!("fno-agents: wait: unexpected argument: {a}");
return 2;
}
}
}
let name = match name {
Some(n) => n,
None => {
eprintln!("fno-agents: wait requires --agent <name>");
return 2;
}
};
let target_state = match target.as_deref().map(parse_target) {
Some(Some(t)) => t,
Some(None) => {
eprintln!("fno-agents: wait --state must be idle|blocked|done");
return 2;
}
None => {
eprintln!("fno-agents: wait requires --state idle|blocked|done");
return 2;
}
};
let deadline = Instant::now().checked_add(Duration::from_millis(timeout_ms));
loop {
match find_effective(home, &name, now_secs()) {
Ok(Some((st, authority))) => {
if st == target_state {
if json_out {
println!("{}", json!({"state": st.label(), "authority": authority}));
} else {
println!("{name} is {} (via {authority})", st.label());
}
return 0;
}
}
Ok(None) => {
eprintln!("fno-agents: no such agent: {name}");
return 13;
}
Err(e) => {
eprintln!("fno-agents: wait: {e}");
return 1;
}
}
if deadline.is_some_and(|d| Instant::now() >= d) {
let last = find_effective(home, &name, now_secs())
.ok()
.flatten()
.map(|(s, _)| s.label())
.unwrap_or("unknown");
eprintln!(
"fno-agents: wait timed out after {timeout_ms}ms \
(agent {name} last observed: {last}, wanted: {})",
target_state.label()
);
return WAIT_TIMEOUT_EXIT;
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Value};
fn entry(overrides: Value) -> RegistryEntry {
let mut base = json!({
"name": "a",
"provider": "claude",
"cwd": "/tmp",
"created_at": "2026-01-01T00:00:00Z",
"status": "live",
});
if let (Value::Object(b), Value::Object(o)) = (&mut base, overrides) {
b.extend(o);
}
serde_json::from_value(base).expect("fixture deserializes")
}
fn live_leg(state: &str) -> Value {
json!({"state": state, "seq": 1, "received_at": "2026-01-01T00:00:00Z"})
}
const NOW: u64 = 1_800_000_000;
#[test]
fn exited_row_is_done_via_exit() {
let e = entry(json!({"status": "exited"}));
assert_eq!(effective_state(&e, NOW), (EffState::Done, "exit"));
let e = entry(json!({"status": "permanent_dead"}));
assert_eq!(effective_state(&e, NOW), (EffState::Done, "exit"));
}
#[test]
fn live_hook_maps_state_to_badge() {
assert_eq!(
effective_state(&entry(json!({"inside_leg": live_leg("working")})), NOW),
(EffState::Working, "hook")
);
assert_eq!(
effective_state(&entry(json!({"inside_leg": live_leg("blocked")})), NOW),
(EffState::Blocked, "hook")
);
assert_eq!(
effective_state(&entry(json!({"inside_leg": live_leg("done")})), NOW),
(EffState::Done, "hook")
);
}
#[test]
fn lapsed_hook_is_idle_liveness_not_screen() {
let e = entry(json!({
"inside_leg": {"state": "working", "seq": 1,
"received_at": "2020-01-01T00:00:00Z", "ttl_ms": 1000},
"screen_state": {"state": "blocked", "rule": "r", "seq": 1,
"at": "2026-01-01T00:00:00Z"},
}));
assert_eq!(effective_state(&e, NOW), (EffState::Idle, "liveness"));
}
#[test]
fn hookless_row_uses_screen_verdict() {
let e = entry(json!({
"screen_state": {"state": "blocked", "rule": "r", "seq": 1,
"at": "2026-01-01T00:00:00Z"},
}));
assert_eq!(effective_state(&e, NOW), (EffState::Blocked, "screen"));
let e = entry(json!({
"screen_state": {"state": "idle", "rule": "r", "seq": 1,
"at": "2026-01-01T00:00:00Z"},
}));
assert_eq!(effective_state(&e, NOW), (EffState::Idle, "screen"));
}
#[test]
fn bare_row_is_idle_liveness() {
assert_eq!(
effective_state(&entry(json!({})), NOW),
(EffState::Idle, "liveness")
);
}
#[test]
fn parse_target_rejects_non_targets() {
assert_eq!(parse_target("idle"), Some(EffState::Idle));
assert_eq!(parse_target("blocked"), Some(EffState::Blocked));
assert_eq!(parse_target("done"), Some(EffState::Done));
assert_eq!(parse_target("working"), None); assert_eq!(parse_target("bogus"), None);
}
}