Skip to main content

fno_agents/
wait.rs

1//! `fno-agents wait` -- block until a named agent reaches a target state.
2//!
3//! Client-side and daemon-free by design. The daemon writes `registry.json`
4//! atomically (tempfile + rename), so a plain shared-lock read always sees a
5//! coherent snapshot; we poll that file and fold each row's effective state
6//! through the same 3-tier lattice the badge uses (in-TTL `inside_leg` > fresh
7//! `screen_state` > liveness), reusing [`InsideLegReport::is_live_at`] /
8//! [`ScreenStateReport::is_live_at`] rather than forking crate `fno`'s
9//! `derive_rows` (which the daemon crate cannot import -- the dependency runs
10//! the other way). Terminal `Exited`/`PermanentDead` tops the lattice as `done`.
11//!
12//! Exit codes: `0` match, [`WAIT_TIMEOUT_EXIT`] (124, the GNU `timeout(1)`
13//! convention) on timeout, `13` unknown agent, `2` usage, `1` read error.
14
15use crate::paths::AgentsHome;
16use crate::state::{self, InsideLegState, RegistryEntry};
17use crate::AgentStatus;
18use serde_json::json;
19use std::time::{Duration, Instant};
20
21/// Exit code when `wait` times out before the agent reaches the target state.
22/// 124 is the code GNU `timeout(1)` uses, so scripts already special-case it.
23pub const WAIT_TIMEOUT_EXIT: i32 = 124;
24
25/// Registry poll interval. The registry is one local file the daemon writes
26/// atomically, so a bounded poll is fine (the plan's stated v1 approach); no
27/// fs-watch dependency for a file that changes on the order of seconds.
28const POLL_INTERVAL: Duration = Duration::from_millis(250);
29
30/// Default wait budget when `--timeout-ms` is omitted.
31const DEFAULT_TIMEOUT_MS: u64 = 30_000;
32
33/// The effective state a `wait` observes, folded from a registry row. Mirrors
34/// the badge lattice: `Working`/`Blocked`/`Done` are live verdicts; `Idle` is
35/// "alive but no live working/blocked/done badge" (badge `None` in `derive_rows`).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum EffState {
38    Working,
39    Blocked,
40    Done,
41    Idle,
42}
43
44impl EffState {
45    /// Lowercase wire label (matches the inside-leg / screen-state vocabulary).
46    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
56/// Fold one registry row to its effective state + the authority that decided it
57/// (`"exit"` | `"hook"` | `"screen"` | `"liveness"`), at `now_secs` epoch
58/// seconds. This is the daemon-side re-expression of crate `fno`'s `derive_rows`
59/// lattice over the typed row: pane-exit > in-TTL hook > fresh screen > liveness.
60pub fn effective_state(e: &RegistryEntry, now_secs: u64) -> (EffState, &'static str) {
61    // Pane exit tops the lattice: a dead pane is `done`, never resurrected by a
62    // stale badge.
63    if matches!(e.status, AgentStatus::Exited | AgentStatus::PermanentDead) {
64        return (EffState::Done, "exit");
65    }
66    // Hook (inside_leg) is senior and TTL-gated. A lapsed hook row does NOT fall
67    // through to screen_state -- a hook-capable row is never scraped, so it goes
68    // straight to liveness-only (mirrors derive_rows: the screen rung is reached
69    // only for rows with no inside_leg at all).
70    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    // Screen-manifest fallback, only for hook-less rows.
82    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                // "idle" and any unknown verdict read as idle.
88                _ => EffState::Idle,
89            };
90            return (st, "screen");
91        }
92    }
93    (EffState::Idle, "liveness")
94}
95
96/// Parse a `--state` target into the `EffState` it names. Only the three
97/// documented targets are accepted (`working` is a transient, not a wait goal).
98fn 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
114/// Read the registry and fold the named row's effective state.
115/// `Ok(None)` == no such agent (a fast, non-retryable miss).
116fn 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
129/// `fno-agents wait --agent <name> --state idle|blocked|done [--timeout-ms N] [--json]`
130pub 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            // A bare positional is accepted as the agent name (parity with `logs`).
166            _ 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    // checked_add so an astronomically large --timeout-ms (which would overflow
194    // the Instant) is treated as "wait indefinitely" rather than panicking.
195    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            // Unknown agent: an immediate, non-retryable miss (AC edge).
209            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        // A `None` deadline (overflow above) never fires -> effectively infinite.
219        if deadline.is_some_and(|d| Instant::now() >= d) {
220            // Report the last-observed state (one read; the timeout path is rare).
221            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    /// Deserialize a `RegistryEntry` from the minimal wire shape plus overrides.
243    /// Building via serde (not the struct literal) keeps the fixture robust to
244    /// the row's many daemon-set fields and exercises the real read path.
245    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    // A report with no ttl_ms never ages out -> always live at any `now`.
260    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; // well past any fixture stamp
265
266    #[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        // A hook-capable row whose report aged out drops to liveness-only; it
293        // must NOT fall through to a screen verdict (per-capability arbitration).
294        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); // transient, not a goal
332        assert_eq!(parse_target("bogus"), None);
333    }
334}