1use crate::paths::AgentsHome;
16use crate::state::{self, InsideLegState, RegistryEntry};
17use crate::AgentStatus;
18use serde_json::json;
19use std::time::{Duration, Instant};
20
21pub const WAIT_TIMEOUT_EXIT: i32 = 124;
24
25const POLL_INTERVAL: Duration = Duration::from_millis(250);
29
30const DEFAULT_TIMEOUT_MS: u64 = 30_000;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum EffState {
38 Working,
39 Blocked,
40 Done,
41 Idle,
42}
43
44impl EffState {
45 pub fn label(self) -> &'static str {
47 match self {
48 EffState::Working => "working",
49 EffState::Blocked => "blocked",
50 EffState::Done => "done",
51 EffState::Idle => "idle",
52 }
53 }
54}
55
56pub fn effective_state(e: &RegistryEntry, now_secs: u64) -> (EffState, &'static str) {
61 if matches!(e.status, AgentStatus::Exited | AgentStatus::PermanentDead) {
64 return (EffState::Done, "exit");
65 }
66 if let Some(leg) = &e.inside_leg {
71 if leg.is_live_at(now_secs) {
72 let st = match leg.state {
73 InsideLegState::Working => EffState::Working,
74 InsideLegState::Blocked => EffState::Blocked,
75 InsideLegState::Done => EffState::Done,
76 };
77 return (st, "hook");
78 }
79 return (EffState::Idle, "liveness");
80 }
81 if let Some(ss) = &e.screen_state {
83 if ss.is_live_at(now_secs) {
84 let st = match ss.state.as_str() {
85 "working" => EffState::Working,
86 "blocked" => EffState::Blocked,
87 _ => EffState::Idle,
89 };
90 return (st, "screen");
91 }
92 }
93 (EffState::Idle, "liveness")
94}
95
96fn parse_target(s: &str) -> Option<EffState> {
99 match s {
100 "idle" => Some(EffState::Idle),
101 "blocked" => Some(EffState::Blocked),
102 "done" => Some(EffState::Done),
103 _ => None,
104 }
105}
106
107fn now_secs() -> u64 {
108 std::time::SystemTime::now()
109 .duration_since(std::time::UNIX_EPOCH)
110 .map(|d| d.as_secs())
111 .unwrap_or(0)
112}
113
114fn find_effective(
117 home: &AgentsHome,
118 name: &str,
119 now: u64,
120) -> Result<Option<(EffState, &'static str)>, String> {
121 let reg = state::load_registry(&home.registry_json()).map_err(|e| e.to_string())?;
122 Ok(reg
123 .entries
124 .iter()
125 .find(|e| e.name == name)
126 .map(|e| effective_state(e, now)))
127}
128
129pub async fn run_wait(rest: &[String], home: &AgentsHome) -> i32 {
131 let mut name: Option<String> = None;
132 let mut target: Option<String> = None;
133 let mut timeout_ms = DEFAULT_TIMEOUT_MS;
134 let mut json_out = false;
135
136 let mut it = rest.iter();
137 while let Some(a) = it.next() {
138 match a.as_str() {
139 "--agent" => match it.next() {
140 Some(v) => name = Some(v.clone()),
141 None => {
142 eprintln!("fno-agents: --agent needs a value");
143 return 2;
144 }
145 },
146 "--state" => match it.next() {
147 Some(v) => target = Some(v.clone()),
148 None => {
149 eprintln!("fno-agents: --state needs a value");
150 return 2;
151 }
152 },
153 "--timeout-ms" => match it.next().and_then(|v| v.parse::<u64>().ok()) {
154 Some(n) => timeout_ms = n,
155 None => {
156 eprintln!("fno-agents: --timeout-ms needs a numeric value");
157 return 2;
158 }
159 },
160 "--json" | "-J" => json_out = true,
161 other if other.starts_with("--") => {
162 eprintln!("fno-agents: wait: unknown flag: {other}");
163 return 2;
164 }
165 _ if name.is_none() => name = Some(a.clone()),
167 _ => {
168 eprintln!("fno-agents: wait: unexpected argument: {a}");
169 return 2;
170 }
171 }
172 }
173
174 let name = match name {
175 Some(n) => n,
176 None => {
177 eprintln!("fno-agents: wait requires --agent <name>");
178 return 2;
179 }
180 };
181 let target_state = match target.as_deref().map(parse_target) {
182 Some(Some(t)) => t,
183 Some(None) => {
184 eprintln!("fno-agents: wait --state must be idle|blocked|done");
185 return 2;
186 }
187 None => {
188 eprintln!("fno-agents: wait requires --state idle|blocked|done");
189 return 2;
190 }
191 };
192
193 let deadline = Instant::now().checked_add(Duration::from_millis(timeout_ms));
196 loop {
197 match find_effective(home, &name, now_secs()) {
198 Ok(Some((st, authority))) => {
199 if st == target_state {
200 if json_out {
201 println!("{}", json!({"state": st.label(), "authority": authority}));
202 } else {
203 println!("{name} is {} (via {authority})", st.label());
204 }
205 return 0;
206 }
207 }
208 Ok(None) => {
210 eprintln!("fno-agents: no such agent: {name}");
211 return 13;
212 }
213 Err(e) => {
214 eprintln!("fno-agents: wait: {e}");
215 return 1;
216 }
217 }
218 if deadline.is_some_and(|d| Instant::now() >= d) {
220 let last = find_effective(home, &name, now_secs())
222 .ok()
223 .flatten()
224 .map(|(s, _)| s.label())
225 .unwrap_or("unknown");
226 eprintln!(
227 "fno-agents: wait timed out after {timeout_ms}ms \
228 (agent {name} last observed: {last}, wanted: {})",
229 target_state.label()
230 );
231 return WAIT_TIMEOUT_EXIT;
232 }
233 tokio::time::sleep(POLL_INTERVAL).await;
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use serde_json::{json, Value};
241
242 fn entry(overrides: Value) -> RegistryEntry {
246 let mut base = json!({
247 "name": "a",
248 "provider": "claude",
249 "cwd": "/tmp",
250 "created_at": "2026-01-01T00:00:00Z",
251 "status": "live",
252 });
253 if let (Value::Object(b), Value::Object(o)) = (&mut base, overrides) {
254 b.extend(o);
255 }
256 serde_json::from_value(base).expect("fixture deserializes")
257 }
258
259 fn live_leg(state: &str) -> Value {
261 json!({"state": state, "seq": 1, "received_at": "2026-01-01T00:00:00Z"})
262 }
263
264 const NOW: u64 = 1_800_000_000; #[test]
267 fn exited_row_is_done_via_exit() {
268 let e = entry(json!({"status": "exited"}));
269 assert_eq!(effective_state(&e, NOW), (EffState::Done, "exit"));
270 let e = entry(json!({"status": "permanent_dead"}));
271 assert_eq!(effective_state(&e, NOW), (EffState::Done, "exit"));
272 }
273
274 #[test]
275 fn live_hook_maps_state_to_badge() {
276 assert_eq!(
277 effective_state(&entry(json!({"inside_leg": live_leg("working")})), NOW),
278 (EffState::Working, "hook")
279 );
280 assert_eq!(
281 effective_state(&entry(json!({"inside_leg": live_leg("blocked")})), NOW),
282 (EffState::Blocked, "hook")
283 );
284 assert_eq!(
285 effective_state(&entry(json!({"inside_leg": live_leg("done")})), NOW),
286 (EffState::Done, "hook")
287 );
288 }
289
290 #[test]
291 fn lapsed_hook_is_idle_liveness_not_screen() {
292 let e = entry(json!({
295 "inside_leg": {"state": "working", "seq": 1,
296 "received_at": "2020-01-01T00:00:00Z", "ttl_ms": 1000},
297 "screen_state": {"state": "blocked", "rule": "r", "seq": 1,
298 "at": "2026-01-01T00:00:00Z"},
299 }));
300 assert_eq!(effective_state(&e, NOW), (EffState::Idle, "liveness"));
301 }
302
303 #[test]
304 fn hookless_row_uses_screen_verdict() {
305 let e = entry(json!({
306 "screen_state": {"state": "blocked", "rule": "r", "seq": 1,
307 "at": "2026-01-01T00:00:00Z"},
308 }));
309 assert_eq!(effective_state(&e, NOW), (EffState::Blocked, "screen"));
310
311 let e = entry(json!({
312 "screen_state": {"state": "idle", "rule": "r", "seq": 1,
313 "at": "2026-01-01T00:00:00Z"},
314 }));
315 assert_eq!(effective_state(&e, NOW), (EffState::Idle, "screen"));
316 }
317
318 #[test]
319 fn bare_row_is_idle_liveness() {
320 assert_eq!(
321 effective_state(&entry(json!({})), NOW),
322 (EffState::Idle, "liveness")
323 );
324 }
325
326 #[test]
327 fn parse_target_rejects_non_targets() {
328 assert_eq!(parse_target("idle"), Some(EffState::Idle));
329 assert_eq!(parse_target("blocked"), Some(EffState::Blocked));
330 assert_eq!(parse_target("done"), Some(EffState::Done));
331 assert_eq!(parse_target("working"), None); assert_eq!(parse_target("bogus"), None);
333 }
334}