Skip to main content

fno_agents/
loop_target.rs

1//! Target driver: TargetQueue + the `loop run` CLI verb.
2//!
3//! ## Degenerate walk (one unit, re-dispatch until terminal event)
4//!
5//! A target session is a single unit of work: the active `target-state.md`
6//! manifest identifies the session. The walk loop is degenerate:
7//!
8//! ```text
9//! queue.next() -> Some(unit for session_id)
10//! inner dispatch loop:
11//!   run driver_invoke, wait
12//!   if termination event in journal -> close unit, break
13//!   else -> node_failed, re-dispatch
14//! queue.next() -> None (closed) -> NoWork -> exit 0
15//! ```
16//!
17//! The outer loop terminates with `NoWork` after the single unit is closed.
18//! The CLI maps `NoWork` from a single-unit walk to exit 0 and reports the
19//! unit's own termination reason as the headline (it is the news; the walk-level
20//! `NoWork` is plumbing).
21//!
22//! ## Why TargetQueue::close() is inert
23//!
24//! The target session's own loop-check stop hook already emitted the
25//! `termination` event before `close()` is called. The manifest is immutable
26//! (invariant from ab-d0337fbc). Closing the backlog graph node and stamping the
27//! plan belong to `reconcile` and `stamp-plan` respectively; calling them here
28//! would duplicate work and couple the loop runtime to concerns it must not own.
29//! The active-backlog daemon (the keep-set) is where `fno backlog done` runs.
30
31use crate::loop_dispatch::{
32    driver_default_max, preflight, resolve_driver_binary, ShelloutDispatcher,
33};
34use crate::loop_runtime::{
35    run_loop, CloseOutcome, Evidence, GlobalJournalPath, Journal, LoopBudget, LoopError,
36    ProjectJournalPath, Queue, Unit,
37};
38use crate::loopcheck::TerminationReason;
39use std::fs;
40use std::path::{Path, PathBuf};
41use std::sync::atomic::{AtomicBool, Ordering};
42
43// ── SIGINT handler ────────────────────────────────────────────────────────────
44
45pub(crate) static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
46
47// SAFETY: signal handler touches only an AtomicBool. No allocations, no locks.
48extern "C" fn handle_sigint(_: libc::c_int) {
49    SIGINT_RECEIVED.store(true, Ordering::SeqCst);
50}
51
52/// Install the SIGINT handler. The child process group receives SIGINT
53/// naturally (foreground process group); after the child exits, `cancel()`
54/// returns true and the loop terminates with Interrupted.
55pub(crate) fn install_sigint_handler() {
56    // SAFETY: handle_sigint is async-signal-safe (one atomic store).
57    unsafe {
58        libc::signal(
59            libc::SIGINT,
60            handle_sigint as *const () as libc::sighandler_t,
61        );
62    }
63}
64
65// ── TargetQueue ───────────────────────────────────────────────────────────────
66
67/// Parsed fields from target-state.md frontmatter needed by the loop runtime.
68pub(crate) struct TargetManifest {
69    pub(crate) session_id: String,
70    pub(crate) input: String,
71    pub(crate) harness_session_id: Option<String>,
72    plan_path: String,
73}
74
75/// Parse the minimal frontmatter fields needed by TargetQueue.
76/// Style mirrors loopcheck.rs `parse_manifest` but is a local copy per the spec:
77/// "write your own tiny local parser - do NOT modify loopcheck.rs".
78pub(crate) fn parse_target_manifest(content: &str) -> Option<TargetManifest> {
79    let content = content.trim_start();
80    if !content.starts_with("---") {
81        return None;
82    }
83    let after_first = &content[3..];
84    let end = after_first.find("\n---")?;
85    let body = &after_first[..end];
86
87    // fno_id is canonical; session_id is the one-release legacy fallback.
88    let mut fno_id = String::new();
89    let mut session_id = String::new();
90    let mut input = String::new();
91    let mut harness_session_id = String::new();
92    let mut plan_path = String::new();
93
94    for line in body.lines() {
95        let line = line.trim();
96        if line.is_empty() || line.starts_with('#') {
97            continue;
98        }
99        if let Some((k, v)) = line.split_once(':') {
100            let k = k.trim();
101            // Strip surrounding quotes from values.
102            let v = v.trim().trim_matches(|c: char| c == '"' || c == '\'');
103            match k {
104                "fno_id" => fno_id = v.to_string(),
105                "session_id" => session_id = v.to_string(),
106                "input" => input = v.to_string(),
107                "harness_session_id" => harness_session_id = v.to_string(),
108                "plan_path" => plan_path = v.to_string(),
109                _ => {}
110            }
111        }
112    }
113
114    let session_id = if fno_id.is_empty() {
115        session_id
116    } else {
117        fno_id
118    };
119    if session_id.is_empty() {
120        return None;
121    }
122    Some(TargetManifest {
123        session_id,
124        input,
125        harness_session_id: (!harness_session_id.is_empty() && harness_session_id != "null")
126            .then_some(harness_session_id),
127        plan_path,
128    })
129}
130
131/// A degenerate queue containing exactly one unit: the active target session.
132///
133/// `next()` returns the unit on the first call. After `close()` is called, or
134/// after the unit has been returned, subsequent `next()` calls return `None`.
135///
136/// `close()` is intentionally inert: the session's own stop hook already
137/// emitted the termination event; the manifest is immutable; graph-node closing
138/// and plan-stamping belong to `reconcile` / `stamp-plan`, not the loop runtime.
139/// The active-backlog daemon is where `fno backlog done` runs.
140/// See module documentation for why `close()` is inert.
141///
142/// ## Why Option<Unit> (not Mutex) (F8)
143///
144/// Queue::next/close take `&mut self` so no Mutex is needed here. The walk
145/// loop is single-threaded; interior mutability would add noise without benefit.
146pub struct TargetQueue {
147    unit: Option<Unit>,
148}
149
150impl TargetQueue {
151    /// Read `.fno/target-state.md` from `repo_root` and construct the queue.
152    pub fn from_manifest(repo_root: &Path) -> Result<Self, LoopError> {
153        let manifest_path = repo_root.join(".fno").join("target-state.md");
154        if !manifest_path.exists() {
155            return Err(LoopError::Queue(format!(
156                "No state file found at .fno/target-state.md - run /target first to initialize (looked in: {})",
157                manifest_path.display()
158            )));
159        }
160        let content = fs::read_to_string(&manifest_path).map_err(LoopError::Io)?;
161        let manifest = parse_target_manifest(&content).ok_or_else(|| {
162            LoopError::Queue(
163                "No state file found at .fno/target-state.md - run /target first to initialize (manifest missing required fields)".to_string()
164            )
165        })?;
166
167        let unit = Unit {
168            id: manifest.session_id.clone(),
169            title: manifest.input.clone(),
170            session_key: manifest.session_id,
171            plan_path: if manifest.plan_path.is_empty() {
172                None
173            } else {
174                Some(manifest.plan_path)
175            },
176            extra_env: vec![],
177        };
178        Ok(Self { unit: Some(unit) })
179    }
180}
181
182impl Queue for TargetQueue {
183    fn next(&mut self) -> Result<Option<Unit>, LoopError> {
184        Ok(self.unit.take())
185    }
186
187    /// Inert close: see module doc for why this does nothing.
188    ///
189    /// The session's loop-check stop hook already emitted the termination event.
190    /// The manifest is immutable (ab-d0337fbc invariant). Graph-node closing and
191    /// plan-stamping belong to reconcile / stamp-plan, not the loop runtime.
192    /// The active-backlog daemon is where `fno backlog done` runs.
193    fn close(&mut self, _unit: &Unit, _evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
194        Ok(CloseOutcome::Closed)
195    }
196}
197
198// ── exit-code mapping ─────────────────────────────────────────────────────────
199
200/// Map a LoopOutcome walk reason to a process exit code.
201///
202/// DonePRGreen | DoneAdvisory | DoneDelivery | NoWork -> 0  (success)
203/// Budget | NoProgress | Aborted       -> 1  (failed / budget)
204/// Interrupted                         -> 130 (SIGINT convention)
205///
206/// For the degenerate single-unit walk, NoWork is reported after the unit closes
207/// with a terminal reason. The headline exit code is derived from the unit's OWN
208/// evidence reason, not the walk-level NoWork, so the caller sees the actual
209/// outcome (DonePRGreen -> 0, Budget -> 1, etc.).
210pub(crate) fn exit_code_for_reason(reason: &TerminationReason) -> i32 {
211    match reason {
212        TerminationReason::DonePRGreen
213        | TerminationReason::DoneAdvisory
214        | TerminationReason::DoneDelivery
215        | TerminationReason::DoneBatched
216        // DoneAwaitingMerge: work complete, human-merge-gated past proven main
217        // red - a clean stop like the other Done* terminals. The reason string
218        // (not the exit code) is what a wrapper reads to distinguish it.
219        | TerminationReason::DoneAwaitingMerge
220        // DonePlanned: a plan-only thread finished cleanly. Not a delivery, but a
221        // clean stop (exit 0); the reason string distinguishes it from a ship.
222        | TerminationReason::DonePlanned
223        | TerminationReason::NoWork => 0,
224        TerminationReason::Budget | TerminationReason::NoProgress | TerminationReason::Aborted => 1,
225        TerminationReason::Interrupted => 130,
226    }
227}
228
229// ── CLI verb ──────────────────────────────────────────────────────────────────
230
231/// Entry point for `fno-agents loop run ...`.
232///
233/// Usage:
234/// ```text
235/// fno-agents loop run
236///   --driver target
237///   [--dispatcher claude-code|hermes|openclaw]
238///   [--max-iterations N]
239///   [--max-turns N]
240///   [--budget N]
241///   [--model NAME]
242///   [--prompt-file PATH]
243///   [--cli claude|opencode]
244///   [--driver-lib-dir DIR]
245///   [--cwd DIR]
246/// ```
247///
248/// Exit codes:
249/// - 0: DonePRGreen | DoneAdvisory | DoneDelivery | NoWork (unit terminated successfully)
250/// - 1: Budget | NoProgress | Aborted (walk failed or hit ceiling)
251/// - 2: usage error / internal error
252/// - 77: driver binary missing from PATH (preflight failure)
253/// - 130: Interrupted (SIGINT)
254pub fn run_loop_verb(args: &[String]) -> i32 {
255    match run_loop_verb_inner(args) {
256        Ok(code) => code,
257        Err(e) => {
258            eprintln!("fno-agents loop: {e}");
259            2
260        }
261    }
262}
263
264fn run_loop_verb_inner(args: &[String]) -> Result<i32, Box<dyn std::error::Error>> {
265    // ── subcommand check ──────────────────────────────────────────────────────
266    let subcommand = args.first().map(|s| s.as_str()).unwrap_or("");
267    if subcommand != "run" {
268        eprintln!("fno-agents loop: expected subcommand 'run', got '{subcommand}'");
269        eprintln!("Usage: fno-agents loop run --driver <name> [options]");
270        return Ok(2);
271    }
272    let args = &args[1..]; // skip "run"
273
274    // ── flag parsing ──────────────────────────────────────────────────────────
275    let mut driver: Option<String> = None;
276    let mut dispatcher_name = "claude-code".to_string();
277    let mut max_iterations: Option<u64> = None;
278    let mut max_turns: u64 = 15;
279    let mut budget_usd: f64 = 25.0;
280    let mut model: Option<String> = None;
281    let mut prompt_file: Option<String> = None;
282    let mut cli_alias: Option<String> = None;
283    let mut driver_lib_dir: Option<PathBuf> = None;
284    let mut cwd: PathBuf = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
285
286    // Helper: advance i and return the next argument, or emit a "missing value"
287    // usage error (exit 2) if the flag is trailing with no following value.
288    // Using a macro (not a closure) to allow `return Ok(2)` from the outer fn.
289    macro_rules! require_value {
290        ($flag:expr, $args:expr, $i:expr) => {{
291            $i += 1;
292            match $args.get($i) {
293                Some(v) => v.as_str(),
294                None => {
295                    eprintln!("fno-agents loop run: {}: missing value", $flag);
296                    return Ok(2);
297                }
298            }
299        }};
300    }
301
302    let mut i = 0;
303    while i < args.len() {
304        let flag = args[i].as_str();
305        match flag {
306            "--driver" => {
307                driver = Some(require_value!("--driver", args, i).to_string());
308            }
309            "--dispatcher" => {
310                dispatcher_name = require_value!("--dispatcher", args, i).to_string();
311            }
312            "--max-iterations" => {
313                let v = require_value!("--max-iterations", args, i);
314                max_iterations = Some(
315                    v.parse::<u64>()
316                        .map_err(|_| format!("--max-iterations: expected integer, got '{v}'"))?,
317                );
318            }
319            "--max-turns" => {
320                let v = require_value!("--max-turns", args, i);
321                max_turns = v
322                    .parse::<u64>()
323                    .map_err(|_| format!("--max-turns: expected integer, got '{v}'"))?;
324            }
325            "--budget" => {
326                let v = require_value!("--budget", args, i);
327                let parsed = v
328                    .parse::<f64>()
329                    .map_err(|_| format!("--budget: expected number, got '{v}'"))?;
330                // F3: reject zero/negative/NaN budget per plan Failure Mode.
331                if !parsed.is_finite() || parsed <= 0.0 {
332                    eprintln!(
333                        "fno-agents loop run: --budget must be a positive number, got '{v}' ({parsed})"
334                    );
335                    return Ok(2);
336                }
337                budget_usd = parsed;
338            }
339            "--model" => {
340                model = Some(require_value!("--model", args, i).to_string());
341            }
342            "--prompt-file" => {
343                prompt_file = Some(require_value!("--prompt-file", args, i).to_string());
344            }
345            "--cli" => {
346                cli_alias = Some(require_value!("--cli", args, i).to_string());
347            }
348            "--driver-lib-dir" => {
349                driver_lib_dir = Some(PathBuf::from(require_value!("--driver-lib-dir", args, i)));
350            }
351            "--cwd" => {
352                cwd = PathBuf::from(require_value!("--cwd", args, i));
353            }
354            _ => {
355                eprintln!("fno-agents loop run: unknown flag '{flag}'");
356                return Ok(2);
357            }
358        }
359        i += 1;
360    }
361
362    // ── driver validation ─────────────────────────────────────────────────────
363    match driver.as_deref() {
364        None => {
365            eprintln!("fno-agents loop run: --driver is required");
366            eprintln!("Usage: fno-agents loop run --driver target [options]");
367            return Ok(2);
368        }
369        Some("target") => {}
370        Some(other) => {
371            eprintln!("fno-agents loop run: unknown --driver '{other}'; supported: 'target'");
372            return Ok(2);
373        }
374    }
375
376    // ── resolve driver-lib-dir ────────────────────────────────────────────────
377    let lib_dir = match driver_lib_dir {
378        Some(d) => d,
379        None => {
380            // Try FNO_DRIVER_LIB_DIR env, then <cwd>/scripts/lib.
381            if let Ok(env_dir) = std::env::var("FNO_DRIVER_LIB_DIR") {
382                PathBuf::from(env_dir)
383            } else {
384                let candidate = cwd.join("scripts").join("lib");
385                if candidate.is_dir() {
386                    candidate
387                } else {
388                    eprintln!(
389                        "fno-agents loop run: cannot resolve driver lib directory. \
390                         Pass --driver-lib-dir <path> (the fno plugin's \
391                         scripts/lib directory) or set FNO_DRIVER_LIB_DIR env."
392                    );
393                    return Ok(2);
394                }
395            }
396        }
397    };
398
399    // ── preflight (all before any dispatch) ───────────────────────────────────
400    // 1. Manifest exists (exit 1 on missing).
401    let mut queue = match TargetQueue::from_manifest(&cwd) {
402        Ok(q) => q,
403        Err(e) => {
404            eprintln!("fno-agents loop run: {e}");
405            return Ok(1);
406        }
407    };
408
409    // 2. Driver whitelist + lib file + binary (exit 77 on missing binary).
410    // F2: pass cli_alias so preflight checks the same binary the dispatcher will use.
411    let lib_path = match preflight(&dispatcher_name, &lib_dir, cli_alias.as_deref()) {
412        Ok(p) => p,
413        Err(LoopError::Dispatch(msg)) => {
414            // Binary missing.
415            eprintln!("fno-agents loop run: {msg}");
416            return Ok(77);
417        }
418        Err(e) => {
419            eprintln!("fno-agents loop run: {e}");
420            return Ok(2);
421        }
422    };
423
424    // ── resolve max_iterations ────────────────────────────────────────────────
425    let max_iters = match max_iterations {
426        Some(n) => n,
427        None => match driver_default_max(&lib_path) {
428            Ok(n) => n,
429            Err(e) => {
430                eprintln!(
431                    "fno-agents loop run: could not query driver_default_max: {e}; \
432                     pass --max-iterations explicitly"
433                );
434                return Ok(2);
435            }
436        },
437    };
438
439    // ── build static env for the dispatcher ──────────────────────────────────
440    // Mirrors run-target-loop.sh:36-40.
441    let fno_dir = cwd.join(".fno");
442    let output_file = fno_dir.join("target-last-output.txt");
443    let history_file = fno_dir.join("target-history.txt");
444    let signal_file = fno_dir.join("target-promise.signal");
445
446    let mut env: Vec<(String, String)> = vec![
447        (
448            "OUTPUT_FILE".to_string(),
449            output_file.to_str().unwrap_or("").to_string(),
450        ),
451        (
452            "HISTORY_FILE".to_string(),
453            history_file.to_str().unwrap_or("").to_string(),
454        ),
455        (
456            "SIGNAL_FILE".to_string(),
457            signal_file.to_str().unwrap_or("").to_string(),
458        ),
459        ("MAX_TURNS".to_string(), max_turns.to_string()),
460        ("BUDGET_USD".to_string(), format!("{budget_usd}")),
461        (
462            "CONTINUE_PROMPT".to_string(),
463            "/target --resume".to_string(),
464        ),
465    ];
466
467    if let Some(m) = &model {
468        env.push(("MODEL_FLAG".to_string(), format!("--model {m}")));
469    } else {
470        env.push(("MODEL_FLAG".to_string(), String::new()));
471    }
472
473    if let Some(pf) = &prompt_file {
474        env.push(("PROMPT_FILE".to_string(), pf.clone()));
475    }
476
477    if let Some(cli) = &cli_alias {
478        env.push(("CLI".to_string(), cli.clone()));
479    }
480
481    // Pass FNO_CWD so driver stubs and real drivers can resolve paths.
482    env.push((
483        "FNO_CWD".to_string(),
484        cwd.to_str().unwrap_or(".").to_string(),
485    ));
486
487    // ── SIGINT handler ────────────────────────────────────────────────────────
488    install_sigint_handler();
489
490    // ── build journal ─────────────────────────────────────────────────────────
491    let project_events = fno_dir.join("events.jsonl");
492    let home_dir = std::env::var("HOME")
493        .map(PathBuf::from)
494        .unwrap_or_else(|_| PathBuf::from("/tmp"));
495    let global_events = home_dir.join(".fno").join("events.jsonl");
496    let journal = Journal::new(
497        ProjectJournalPath(project_events),
498        GlobalJournalPath(global_events),
499    );
500
501    // ── peek at the first unit for the header (F6: no TOCTOU re-read) ────────
502    // Read session_id/input from the already-constructed queue instead of
503    // re-reading the manifest (which avoids the TOCTOU double-read and the
504    // .unwrap().unwrap() panic path).
505    let (session_id_display, input_display) = {
506        // Peek without consuming: TargetQueue stores Option<Unit>, so we look
507        // at the inner unit via as_ref without taking it.
508        match queue.unit.as_ref() {
509            Some(u) => (u.id.clone(), u.title.clone()),
510            None => ("(none)".to_string(), "(none)".to_string()),
511        }
512    };
513
514    // Print header. resolve_driver_binary now reflects the cli_alias (F2).
515    let binary_name = resolve_driver_binary(&dispatcher_name, cli_alias.as_deref());
516    println!("fno-agents loop run");
517    println!("  driver:     target");
518    println!("  dispatcher: {dispatcher_name} (binary: {binary_name})");
519    println!("  session:    {session_id_display}");
520    println!("  input:      {input_display}");
521    println!("  iterations: {max_iters} max");
522    println!("  budget:     ${budget_usd} USD");
523
524    // ── build dispatcher ──────────────────────────────────────────────────────
525    let dispatcher = ShelloutDispatcher::new(lib_path, env, cwd.clone());
526
527    // ── build budget ──────────────────────────────────────────────────────────
528    let budget = match LoopBudget::new(max_iters) {
529        Ok(b) => b,
530        Err(e) => {
531            eprintln!("fno-agents loop run: {e}");
532            return Ok(2);
533        }
534    };
535
536    // ── cancel closure ────────────────────────────────────────────────────────
537    let cancel_file = cwd.join(".fno").join(".target-cancelled");
538    let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();
539
540    // ── run the loop ──────────────────────────────────────────────────────────
541    let outcome = match run_loop(&mut queue, &dispatcher, &budget, &journal, &cancel, None) {
542        Ok(o) => o,
543        Err(e) => {
544            eprintln!("fno-agents loop run: fatal loop error: {e}");
545            return Ok(2);
546        }
547    };
548
549    // ── report outcome ────────────────────────────────────────────────────────
550    // For the degenerate single-unit walk, report the unit's evidence reason as
551    // the headline; the walk-level NoWork is plumbing, not news.
552    let (headline_reason, exit_code) = if let Some(unit_result) = outcome.units.first() {
553        let r = &unit_result.evidence.reason;
554        let code = exit_code_for_reason(r);
555        (format!("{r:?}"), code)
556    } else {
557        // No units closed (Budget/Interrupted at walk level before close).
558        let code = exit_code_for_reason(&outcome.reason);
559        (format!("{:?}", outcome.reason), code)
560    };
561
562    println!(
563        "loop: {} ({} iterations used)",
564        headline_reason, outcome.iterations_used
565    );
566
567    // Emit a summary line for each unit.
568    for unit_result in &outcome.units {
569        println!(
570            "  unit {}: {:?} ({:?})",
571            unit_result.unit_id, unit_result.evidence.reason, unit_result.close
572        );
573    }
574
575    Ok(exit_code)
576}