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//! `megawalk`'s Queue (group 2, ab-7303e5d7) 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.
68struct TargetManifest {
69    session_id: String,
70    input: String,
71    plan_path: String,
72}
73
74/// Parse the minimal frontmatter fields needed by TargetQueue.
75/// Style mirrors loopcheck.rs `parse_manifest` but is a local copy per the spec:
76/// "write your own tiny local parser - do NOT modify loopcheck.rs".
77fn parse_target_manifest(content: &str) -> Option<TargetManifest> {
78    let content = content.trim_start();
79    if !content.starts_with("---") {
80        return None;
81    }
82    let after_first = &content[3..];
83    let end = after_first.find("\n---")?;
84    let body = &after_first[..end];
85
86    let mut session_id = String::new();
87    let mut input = String::new();
88    let mut plan_path = String::new();
89
90    for line in body.lines() {
91        let line = line.trim();
92        if line.is_empty() || line.starts_with('#') {
93            continue;
94        }
95        if let Some((k, v)) = line.split_once(':') {
96            let k = k.trim();
97            // Strip surrounding quotes from values.
98            let v = v.trim().trim_matches(|c: char| c == '"' || c == '\'');
99            match k {
100                "session_id" => session_id = v.to_string(),
101                "input" => input = v.to_string(),
102                "plan_path" => plan_path = v.to_string(),
103                _ => {}
104            }
105        }
106    }
107
108    if session_id.is_empty() {
109        return None;
110    }
111    Some(TargetManifest {
112        session_id,
113        input,
114        plan_path,
115    })
116}
117
118/// A degenerate queue containing exactly one unit: the active target session.
119///
120/// `next()` returns the unit on the first call. After `close()` is called, or
121/// after the unit has been returned, subsequent `next()` calls return `None`.
122///
123/// `close()` is intentionally inert: the session's own stop hook already
124/// emitted the termination event; the manifest is immutable; graph-node closing
125/// and plan-stamping belong to `reconcile` / `stamp-plan`, not the loop runtime.
126/// megawalk's Queue (group 2, ab-7303e5d7) is where `fno backlog done` runs.
127/// See module documentation for why `close()` is inert.
128///
129/// ## Why Option<Unit> (not Mutex) (F8)
130///
131/// Queue::next/close take `&mut self` so no Mutex is needed here. The walk
132/// loop is single-threaded; interior mutability would add noise without benefit.
133pub struct TargetQueue {
134    unit: Option<Unit>,
135}
136
137impl TargetQueue {
138    /// Read `.fno/target-state.md` from `repo_root` and construct the queue.
139    pub fn from_manifest(repo_root: &Path) -> Result<Self, LoopError> {
140        let manifest_path = repo_root.join(".fno").join("target-state.md");
141        if !manifest_path.exists() {
142            return Err(LoopError::Queue(format!(
143                "No state file found at .fno/target-state.md - run /target first to initialize (looked in: {})",
144                manifest_path.display()
145            )));
146        }
147        let content = fs::read_to_string(&manifest_path).map_err(LoopError::Io)?;
148        let manifest = parse_target_manifest(&content).ok_or_else(|| {
149            LoopError::Queue(
150                "No state file found at .fno/target-state.md - run /target first to initialize (manifest missing required fields)".to_string()
151            )
152        })?;
153
154        let unit = Unit {
155            id: manifest.session_id.clone(),
156            title: manifest.input.clone(),
157            session_key: manifest.session_id,
158            plan_path: if manifest.plan_path.is_empty() {
159                None
160            } else {
161                Some(manifest.plan_path)
162            },
163            extra_env: vec![],
164        };
165        Ok(Self { unit: Some(unit) })
166    }
167}
168
169impl Queue for TargetQueue {
170    fn next(&mut self) -> Result<Option<Unit>, LoopError> {
171        Ok(self.unit.take())
172    }
173
174    /// Inert close: see module doc for why this does nothing.
175    ///
176    /// The session's loop-check stop hook already emitted the termination event.
177    /// The manifest is immutable (ab-d0337fbc invariant). Graph-node closing and
178    /// plan-stamping belong to reconcile / stamp-plan, not the loop runtime.
179    /// megawalk's Queue (group 2, ab-7303e5d7) is where `fno backlog done` runs.
180    fn close(&mut self, _unit: &Unit, _evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
181        Ok(CloseOutcome::Closed)
182    }
183}
184
185// ── exit-code mapping ─────────────────────────────────────────────────────────
186
187/// Map a LoopOutcome walk reason to a process exit code.
188///
189/// DonePRGreen | DoneAdvisory | NoWork -> 0  (success)
190/// Budget | NoProgress | Aborted       -> 1  (failed / budget)
191/// Interrupted                         -> 130 (SIGINT convention)
192///
193/// For the degenerate single-unit walk, NoWork is reported after the unit closes
194/// with a terminal reason. The headline exit code is derived from the unit's OWN
195/// evidence reason, not the walk-level NoWork, so the caller sees the actual
196/// outcome (DonePRGreen -> 0, Budget -> 1, etc.).
197pub(crate) fn exit_code_for_reason(reason: &TerminationReason) -> i32 {
198    match reason {
199        TerminationReason::DonePRGreen
200        | TerminationReason::DoneAdvisory
201        | TerminationReason::NoWork => 0,
202        TerminationReason::Budget | TerminationReason::NoProgress | TerminationReason::Aborted => 1,
203        TerminationReason::Interrupted => 130,
204    }
205}
206
207// ── CLI verb ──────────────────────────────────────────────────────────────────
208
209/// Entry point for `fno-agents loop run ...`.
210///
211/// Usage:
212/// ```text
213/// fno-agents loop run
214///   --driver target
215///   [--dispatcher claude-code|hermes|openclaw]
216///   [--max-iterations N]
217///   [--max-turns N]
218///   [--budget N]
219///   [--model NAME]
220///   [--prompt-file PATH]
221///   [--cli claude|opencode]
222///   [--driver-lib-dir DIR]
223///   [--cwd DIR]
224/// ```
225///
226/// Exit codes:
227/// - 0: DonePRGreen | DoneAdvisory | NoWork (unit terminated successfully)
228/// - 1: Budget | NoProgress | Aborted (walk failed or hit ceiling)
229/// - 2: usage error / internal error
230/// - 77: driver binary missing from PATH (preflight failure)
231/// - 130: Interrupted (SIGINT)
232pub fn run_loop_verb(args: &[String]) -> i32 {
233    match run_loop_verb_inner(args) {
234        Ok(code) => code,
235        Err(e) => {
236            eprintln!("fno-agents loop: {e}");
237            2
238        }
239    }
240}
241
242fn run_loop_verb_inner(args: &[String]) -> Result<i32, Box<dyn std::error::Error>> {
243    // ── subcommand check ──────────────────────────────────────────────────────
244    let subcommand = args.first().map(|s| s.as_str()).unwrap_or("");
245    if subcommand != "run" {
246        eprintln!("fno-agents loop: expected subcommand 'run', got '{subcommand}'");
247        eprintln!("Usage: fno-agents loop run --driver <name> [options]");
248        return Ok(2);
249    }
250    let args = &args[1..]; // skip "run"
251
252    // ── flag parsing ──────────────────────────────────────────────────────────
253    let mut driver: Option<String> = None;
254    let mut dispatcher_name = "claude-code".to_string();
255    let mut max_iterations: Option<u64> = None;
256    let mut max_turns: u64 = 15;
257    let mut budget_usd: f64 = 25.0;
258    let mut model: Option<String> = None;
259    let mut prompt_file: Option<String> = None;
260    let mut cli_alias: Option<String> = None;
261    let mut driver_lib_dir: Option<PathBuf> = None;
262    let mut cwd: PathBuf = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
263    // Megawalk-only flags.
264    let mut project: Option<String> = None;
265    let mut all = false;
266    let mut allow_merge = false;
267    let mut parallel_cap: Option<u64> = None;
268    let mut max_units: Option<u64> = None;
269    // Megawalk/megatron flags (group 3, ab-9fd662c6).
270    let mut mission: Option<String> = None;
271    let mut termination_key: Option<String> = None;
272
273    // Helper: advance i and return the next argument, or emit a "missing value"
274    // usage error (exit 2) if the flag is trailing with no following value.
275    // Using a macro (not a closure) to allow `return Ok(2)` from the outer fn.
276    macro_rules! require_value {
277        ($flag:expr, $args:expr, $i:expr) => {{
278            $i += 1;
279            match $args.get($i) {
280                Some(v) => v.as_str(),
281                None => {
282                    eprintln!("fno-agents loop run: {}: missing value", $flag);
283                    return Ok(2);
284                }
285            }
286        }};
287    }
288
289    let mut i = 0;
290    while i < args.len() {
291        let flag = args[i].as_str();
292        match flag {
293            "--driver" => {
294                driver = Some(require_value!("--driver", args, i).to_string());
295            }
296            "--dispatcher" => {
297                dispatcher_name = require_value!("--dispatcher", args, i).to_string();
298            }
299            "--max-iterations" => {
300                let v = require_value!("--max-iterations", args, i);
301                max_iterations = Some(
302                    v.parse::<u64>()
303                        .map_err(|_| format!("--max-iterations: expected integer, got '{v}'"))?,
304                );
305            }
306            "--max-turns" => {
307                let v = require_value!("--max-turns", args, i);
308                max_turns = v
309                    .parse::<u64>()
310                    .map_err(|_| format!("--max-turns: expected integer, got '{v}'"))?;
311            }
312            "--budget" => {
313                let v = require_value!("--budget", args, i);
314                let parsed = v
315                    .parse::<f64>()
316                    .map_err(|_| format!("--budget: expected number, got '{v}'"))?;
317                // F3: reject zero/negative/NaN budget per plan Failure Mode.
318                if !parsed.is_finite() || parsed <= 0.0 {
319                    eprintln!(
320                        "fno-agents loop run: --budget must be a positive number, got '{v}' ({parsed})"
321                    );
322                    return Ok(2);
323                }
324                budget_usd = parsed;
325            }
326            "--model" => {
327                model = Some(require_value!("--model", args, i).to_string());
328            }
329            "--prompt-file" => {
330                prompt_file = Some(require_value!("--prompt-file", args, i).to_string());
331            }
332            "--cli" => {
333                cli_alias = Some(require_value!("--cli", args, i).to_string());
334            }
335            "--driver-lib-dir" => {
336                driver_lib_dir = Some(PathBuf::from(require_value!("--driver-lib-dir", args, i)));
337            }
338            "--cwd" => {
339                cwd = PathBuf::from(require_value!("--cwd", args, i));
340            }
341            "--project" => {
342                project = Some(require_value!("--project", args, i).to_string());
343            }
344            "--all" => {
345                all = true;
346            }
347            "--allow-merge" => {
348                allow_merge = true;
349            }
350            "--parallel-cap" => {
351                let v = require_value!("--parallel-cap", args, i);
352                let parsed = v
353                    .parse::<u64>()
354                    .map_err(|_| format!("--parallel-cap: expected integer, got '{v}'"))?;
355                parallel_cap = Some(crate::loop_megawalk::clamp_parallel_cap(parsed));
356                if parsed > 1 {
357                    eprintln!(
358                        "fno-agents loop megawalk: --parallel-cap {parsed} accepted; \
359                         execution remains SEQUENTIAL (collision-conservative default, \
360                         group-2 serializes regardless of cap)"
361                    );
362                }
363            }
364            "--max-units" => {
365                let v = require_value!("--max-units", args, i);
366                let parsed = v
367                    .parse::<u64>()
368                    .map_err(|_| format!("--max-units: expected integer >= 1, got '{v}'"))?;
369                if parsed == 0 {
370                    eprintln!(
371                        "fno-agents loop run: --max-units must be >= 1, got 0 \
372                         (use --max-units 1 for the /megawalk once modifier)"
373                    );
374                    return Ok(2);
375                }
376                max_units = Some(parsed);
377            }
378            "--mission" => {
379                mission = Some(require_value!("--mission", args, i).to_string());
380            }
381            "--termination-key" => {
382                termination_key = Some(require_value!("--termination-key", args, i).to_string());
383            }
384            _ => {
385                eprintln!("fno-agents loop run: unknown flag '{flag}'");
386                return Ok(2);
387            }
388        }
389        i += 1;
390    }
391
392    // ── driver validation ─────────────────────────────────────────────────────
393    let driver = match driver.as_deref() {
394        None => {
395            eprintln!("fno-agents loop run: --driver is required");
396            eprintln!("Usage: fno-agents loop run --driver <target|...> [options]");
397            return Ok(2);
398        }
399        Some("megawalk") => {
400            // Validate that --allow-merge is not passed with --driver target.
401            // (allow_merge is megawalk-only; if somehow we get here with target
402            //  the check below would fire.)
403            return Ok(crate::loop_megawalk::run(
404                &dispatcher_name,
405                max_iterations,
406                max_turns,
407                budget_usd,
408                model.as_deref(),
409                prompt_file.as_deref(),
410                cli_alias.as_deref(),
411                driver_lib_dir,
412                cwd,
413                project,
414                all,
415                allow_merge,
416                parallel_cap,
417                max_units,
418                mission,
419                termination_key,
420            ));
421        }
422        Some("megatron") => {
423            // Megatron recursion (group 3, ab-9fd662c6): Queue over a fleet
424            // mission's projects; each unit's work is a megawalk one altitude
425            // down. --termination-key is megawalk-only (the CHILD walks get
426            // theirs from the megatron dispatcher, not the operator).
427            if termination_key.is_some() {
428                eprintln!(
429                    "fno-agents loop run: --termination-key is only valid with --driver megawalk"
430                );
431                return Ok(2);
432            }
433            let Some(mission_id) = mission else {
434                eprintln!("fno-agents loop run: --driver megatron requires --mission <id>");
435                return Ok(2);
436            };
437            return Ok(crate::loop_megatron::run(
438                &dispatcher_name,
439                max_iterations,
440                max_turns,
441                budget_usd,
442                model.as_deref(),
443                cli_alias.as_deref(),
444                driver_lib_dir,
445                cwd,
446                allow_merge,
447                &mission_id,
448            ));
449        }
450        Some("target") => {
451            // --allow-merge is megawalk-only; reject with clear message.
452            if allow_merge {
453                eprintln!(
454                    "fno-agents loop run: --allow-merge is only valid with --driver megawalk"
455                );
456                return Ok(2);
457            }
458            // --max-units is megawalk-only; reject with clear message.
459            if max_units.is_some() {
460                eprintln!("fno-agents loop run: --max-units is only valid with --driver megawalk");
461                return Ok(2);
462            }
463            // --mission / --termination-key are megawalk/megatron flags.
464            if mission.is_some() {
465                eprintln!(
466                    "fno-agents loop run: --mission is only valid with --driver megawalk|megatron"
467                );
468                return Ok(2);
469            }
470            if termination_key.is_some() {
471                eprintln!(
472                    "fno-agents loop run: --termination-key is only valid with --driver megawalk"
473                );
474                return Ok(2);
475            }
476            "target"
477        }
478        Some(other) => {
479            eprintln!(
480                "fno-agents loop run: unknown --driver '{other}'; \
481                 supported: 'target', 'megawalk', 'megatron'"
482            );
483            return Ok(2);
484        }
485    };
486    let _ = driver; // "target" confirmed
487
488    // ── resolve driver-lib-dir ────────────────────────────────────────────────
489    let lib_dir = match driver_lib_dir {
490        Some(d) => d,
491        None => {
492            // Try FNO_DRIVER_LIB_DIR env, then <cwd>/scripts/lib.
493            if let Ok(env_dir) = std::env::var("FNO_DRIVER_LIB_DIR") {
494                PathBuf::from(env_dir)
495            } else {
496                let candidate = cwd.join("scripts").join("lib");
497                if candidate.is_dir() {
498                    candidate
499                } else {
500                    eprintln!(
501                        "fno-agents loop run: cannot resolve driver lib directory. \
502                         Pass --driver-lib-dir <path> (the abilities plugin's \
503                         scripts/lib directory) or set FNO_DRIVER_LIB_DIR env."
504                    );
505                    return Ok(2);
506                }
507            }
508        }
509    };
510
511    // ── preflight (all before any dispatch) ───────────────────────────────────
512    // 1. Manifest exists (exit 1 on missing).
513    let mut queue = match TargetQueue::from_manifest(&cwd) {
514        Ok(q) => q,
515        Err(e) => {
516            eprintln!("fno-agents loop run: {e}");
517            return Ok(1);
518        }
519    };
520
521    // 2. Driver whitelist + lib file + binary (exit 77 on missing binary).
522    // F2: pass cli_alias so preflight checks the same binary the dispatcher will use.
523    let lib_path = match preflight(&dispatcher_name, &lib_dir, cli_alias.as_deref()) {
524        Ok(p) => p,
525        Err(LoopError::Dispatch(msg)) => {
526            // Binary missing.
527            eprintln!("fno-agents loop run: {msg}");
528            return Ok(77);
529        }
530        Err(e) => {
531            eprintln!("fno-agents loop run: {e}");
532            return Ok(2);
533        }
534    };
535
536    // ── resolve max_iterations ────────────────────────────────────────────────
537    let max_iters = match max_iterations {
538        Some(n) => n,
539        None => match driver_default_max(&lib_path) {
540            Ok(n) => n,
541            Err(e) => {
542                eprintln!(
543                    "fno-agents loop run: could not query driver_default_max: {e}; \
544                     pass --max-iterations explicitly"
545                );
546                return Ok(2);
547            }
548        },
549    };
550
551    // ── build static env for the dispatcher ──────────────────────────────────
552    // Mirrors run-target-loop.sh:36-40.
553    let abilities_dir = cwd.join(".fno");
554    let output_file = abilities_dir.join("target-last-output.txt");
555    let history_file = abilities_dir.join("target-history.txt");
556    let signal_file = abilities_dir.join("target-promise.signal");
557
558    let mut env: Vec<(String, String)> = vec![
559        (
560            "OUTPUT_FILE".to_string(),
561            output_file.to_str().unwrap_or("").to_string(),
562        ),
563        (
564            "HISTORY_FILE".to_string(),
565            history_file.to_str().unwrap_or("").to_string(),
566        ),
567        (
568            "SIGNAL_FILE".to_string(),
569            signal_file.to_str().unwrap_or("").to_string(),
570        ),
571        ("MAX_TURNS".to_string(), max_turns.to_string()),
572        ("BUDGET_USD".to_string(), format!("{budget_usd}")),
573        (
574            "CONTINUE_PROMPT".to_string(),
575            "/target --resume".to_string(),
576        ),
577    ];
578
579    if let Some(m) = &model {
580        env.push(("MODEL_FLAG".to_string(), format!("--model {m}")));
581    } else {
582        env.push(("MODEL_FLAG".to_string(), String::new()));
583    }
584
585    if let Some(pf) = &prompt_file {
586        env.push(("PROMPT_FILE".to_string(), pf.clone()));
587    }
588
589    if let Some(cli) = &cli_alias {
590        env.push(("CLI".to_string(), cli.clone()));
591    }
592
593    // Pass FNO_CWD so driver stubs and real drivers can resolve paths.
594    env.push((
595        "FNO_CWD".to_string(),
596        cwd.to_str().unwrap_or(".").to_string(),
597    ));
598
599    // ── SIGINT handler ────────────────────────────────────────────────────────
600    install_sigint_handler();
601
602    // ── build journal ─────────────────────────────────────────────────────────
603    let project_events = abilities_dir.join("events.jsonl");
604    let home_dir = std::env::var("HOME")
605        .map(PathBuf::from)
606        .unwrap_or_else(|_| PathBuf::from("/tmp"));
607    let global_events = home_dir.join(".fno").join("events.jsonl");
608    let journal = Journal::new(
609        ProjectJournalPath(project_events),
610        GlobalJournalPath(global_events),
611    );
612
613    // ── peek at the first unit for the header (F6: no TOCTOU re-read) ────────
614    // Read session_id/input from the already-constructed queue instead of
615    // re-reading the manifest (which avoids the TOCTOU double-read and the
616    // .unwrap().unwrap() panic path).
617    let (session_id_display, input_display) = {
618        // Peek without consuming: TargetQueue stores Option<Unit>, so we look
619        // at the inner unit via as_ref without taking it.
620        match queue.unit.as_ref() {
621            Some(u) => (u.id.clone(), u.title.clone()),
622            None => ("(none)".to_string(), "(none)".to_string()),
623        }
624    };
625
626    // Print header. resolve_driver_binary now reflects the cli_alias (F2).
627    let binary_name = resolve_driver_binary(&dispatcher_name, cli_alias.as_deref());
628    println!("fno-agents loop run");
629    println!("  driver:     target");
630    println!("  dispatcher: {dispatcher_name} (binary: {binary_name})");
631    println!("  session:    {session_id_display}");
632    println!("  input:      {input_display}");
633    println!("  iterations: {max_iters} max");
634    println!("  budget:     ${budget_usd} USD");
635
636    // ── build dispatcher ──────────────────────────────────────────────────────
637    let dispatcher = ShelloutDispatcher::new(lib_path, env, cwd.clone());
638
639    // ── build budget ──────────────────────────────────────────────────────────
640    let budget = match LoopBudget::new(max_iters) {
641        Ok(b) => b,
642        Err(e) => {
643            eprintln!("fno-agents loop run: {e}");
644            return Ok(2);
645        }
646    };
647
648    // ── cancel closure ────────────────────────────────────────────────────────
649    let cancel_file = cwd.join(".fno").join(".target-cancelled");
650    let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();
651
652    // ── run the loop ──────────────────────────────────────────────────────────
653    let outcome = match run_loop(&mut queue, &dispatcher, &budget, &journal, &cancel, None) {
654        Ok(o) => o,
655        Err(e) => {
656            eprintln!("fno-agents loop run: fatal loop error: {e}");
657            return Ok(2);
658        }
659    };
660
661    // ── report outcome ────────────────────────────────────────────────────────
662    // For the degenerate single-unit walk, report the unit's evidence reason as
663    // the headline; the walk-level NoWork is plumbing, not news.
664    let (headline_reason, exit_code) = if let Some(unit_result) = outcome.units.first() {
665        let r = &unit_result.evidence.reason;
666        let code = exit_code_for_reason(r);
667        (format!("{r:?}"), code)
668    } else {
669        // No units closed (Budget/Interrupted at walk level before close).
670        let code = exit_code_for_reason(&outcome.reason);
671        (format!("{:?}", outcome.reason), code)
672    };
673
674    println!(
675        "loop: {} ({} iterations used)",
676        headline_reason, outcome.iterations_used
677    );
678
679    // Emit a summary line for each unit.
680    for unit_result in &outcome.units {
681        println!(
682            "  unit {}: {:?} ({:?})",
683            unit_result.unit_id, unit_result.evidence.reason, unit_result.close
684        );
685    }
686
687    Ok(exit_code)
688}