Skip to main content

fno_agents/
loop_megatron.rs

1//! Megatron driver: MegatronQueue + MegatronDispatcher + the
2//! `loop run --driver megatron` verb glue (group 3 of ab-ed61946d,
3//! node ab-9fd662c6).
4//!
5//! ## The recursion (collapse doc promise)
6//!
7//! `work(unit)` at the mission altitude IS `loop()` one altitude below:
8//! MegatronQueue dequeues fleet PROJECTS (via `fno megatron next`), and the
9//! dispatcher runs each project as a full megawalk - this same binary,
10//! re-invoked with `--driver megawalk --cwd <project_path> --mission <id>
11//! --termination-key <session_key>`. The child walk journals a `termination`
12//! event keyed by the session key when it finishes (see
13//! `loop_megawalk::emit_walk_termination`), which the UNCHANGED `run_loop`
14//! runtime observes through `Journal::find_termination`'s global-mirror
15//! fallback (the child runs in a different cwd). Zero runtime changes -
16//! the fifth-driver test holds.
17//!
18//! ## What replaced the commander poll loop
19//!
20//! The Python commander (`cli/src/fno/megatron/loop.py`, deleted in
21//! this group) POLLED `~/.fno/fleet/{slug}/completions/wave-N/*.json`
22//! on a sleep cycle. Here the completion evidence is the child walk's
23//! journaled termination event; `MegatronQueue::close` records the outcome
24//! through `fno megatron complete` (which idempotently writes the same
25//! completion JSON ledger the worker ship gates write - the FILES survive
26//! as the mission record; the polling died).
27//!
28//! ## Verb seam (grilled 7 applied at the fleet altitude)
29//!
30//! The queue never reads manifests, mission state, or the fleet directory.
31//! All mission logic (wave advancement, manifest sha guard, dispatch-on-
32//! demand plan+intake, completion records) lives behind `fno megatron
33//! next` / `fno megatron complete` in Python, where the well-tested
34//! manifest/state/dispatch code already lives.
35
36use crate::loop_megawalk::{abi_cmd, gen_session_key_with_infix, maybe_stale_hint, retry_etxtbsy};
37use crate::loop_runtime::{
38    run_loop, CloseOutcome, DispatchCtx, Dispatcher, Evidence, GlobalJournalPath, Journal,
39    LoopBudget, LoopError, ProjectJournalPath, Queue, Session, Unit,
40};
41use crate::loopcheck::TerminationReason;
42use std::collections::HashMap;
43use std::path::PathBuf;
44use std::process::{Child, Command};
45
46// ── MegatronQueue ─────────────────────────────────────────────────────────────
47
48/// Per-unit bookkeeping recorded at dequeue time so `close()` can name the
49/// (project, wave) pair without parsing it back out of the unit id.
50struct ProjectEntry {
51    project: String,
52    wave: u64,
53}
54
55/// A Queue over a fleet mission's projects. Shells `fno megatron next` /
56/// `fno megatron complete`; never touches the fleet directory itself.
57pub struct MegatronQueue {
58    /// Path or name of the fno binary. `$FNO_BIN` env overrides for tests.
59    abi_bin: String,
60    /// Full mission id (`ab-XXXXXXXX`).
61    mission_id: String,
62    /// unit.id -> (project, wave) for close().
63    active: HashMap<String, ProjectEntry>,
64}
65
66impl MegatronQueue {
67    pub fn new(abi_bin: String, mission_id: String) -> Self {
68        Self {
69            abi_bin,
70            mission_id,
71            active: HashMap::new(),
72        }
73    }
74}
75
76impl Queue for MegatronQueue {
77    /// Dequeue the next incomplete project of the mission.
78    ///
79    /// `fno megatron next <mission> --json` contract:
80    /// - `null`                       -> mission complete -> Ok(None)
81    /// - `{"pause": {"policy","detail"}}` -> Err(LoopError::Pause{policy, detail})
82    ///   (run_loop maps this to walk_paused + NoProgress)
83    /// - unit object                  -> Some(Unit)
84    fn next(&mut self) -> Result<Option<Unit>, LoopError> {
85        let out = retry_etxtbsy(|| {
86            abi_cmd(&self.abi_bin)
87                .args(["megatron", "next", &self.mission_id, "--json"])
88                .output()
89        })
90        .map_err(|e| {
91            LoopError::Queue(maybe_stale_hint(
92                format!("fno megatron next: spawn failed: {e}"),
93                &self.abi_bin,
94            ))
95        })?;
96
97        if !out.status.success() {
98            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
99            return Err(LoopError::Queue(maybe_stale_hint(
100                format!("fno megatron next: exit {}: {stderr}", out.status),
101                &self.abi_bin,
102            )));
103        }
104
105        let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
106        if stdout == "null" || stdout.is_empty() {
107            return Ok(None);
108        }
109
110        let v: serde_json::Value = serde_json::from_str(&stdout).map_err(|e| {
111            LoopError::Queue(maybe_stale_hint(
112                format!("fno megatron next: JSON parse error: {e} (stdout: {stdout:?})"),
113                &self.abi_bin,
114            ))
115        })?;
116
117        // Pause shape: {"pause": {"policy": ..., "detail": ...}}
118        if let Some(p) = v.get("pause") {
119            let policy = p["policy"].as_str().unwrap_or("unknown");
120            let detail = p["detail"].as_str().unwrap_or("");
121            return Err(LoopError::Pause {
122                policy: policy.to_string(),
123                detail: detail.to_string(),
124            });
125        }
126
127        let project = match v["project"].as_str() {
128            Some(s) if !s.is_empty() => s.to_string(),
129            _ => {
130                return Err(LoopError::Queue(maybe_stale_hint(
131                    format!("fno megatron next: missing 'project' field in: {stdout:?}"),
132                    &self.abi_bin,
133                )));
134            }
135        };
136        let wave = match v["wave"].as_u64() {
137            Some(w) => w,
138            None => {
139                return Err(LoopError::Queue(maybe_stale_hint(
140                    format!("fno megatron next: missing 'wave' field in: {stdout:?}"),
141                    &self.abi_bin,
142                )));
143            }
144        };
145        // project_path is REQUIRED: without it there is no cwd to walk in.
146        // A missing path means the project is not declared in settings
147        // workspaces - fail loudly naming the project (Failure Mode: "a unit
148        // whose plan_path is missing at dispatch time" analog).
149        let project_path = match v["project_path"].as_str() {
150            Some(s) if !s.is_empty() => s.to_string(),
151            _ => {
152                return Err(LoopError::Queue(format!(
153                    "fno megatron next: project {project:?} has no project_path \
154                     (not found in settings workspaces); cannot dispatch a walk"
155                )));
156            }
157        };
158        let title = v["title"]
159            .as_str()
160            .map(|s| s.to_string())
161            .unwrap_or_else(|| format!("Mission {} wave {wave} - {project}", self.mission_id));
162
163        let session_key = gen_session_key_with_infix("mt");
164        let unit_id = format!("{project}@wave-{wave}");
165
166        self.active.insert(
167            unit_id.clone(),
168            ProjectEntry {
169                project: project.clone(),
170                wave,
171            },
172        );
173
174        Ok(Some(Unit {
175            id: unit_id,
176            title,
177            session_key,
178            plan_path: None,
179            // The dispatcher reads the project path from extra_env; the env
180            // vars also reach the child walk's environment for observability.
181            extra_env: vec![
182                ("MEGATRON_PROJECT_PATH".to_string(), project_path),
183                ("MEGATRON_PROJECT".to_string(), project),
184                ("MEGATRON_WAVE".to_string(), wave.to_string()),
185                ("MEGATRON_MISSION_ID".to_string(), self.mission_id.clone()),
186            ],
187        }))
188    }
189
190    /// Record the child walk's outcome against the mission.
191    ///
192    /// NoWork (walk drained) | DonePRGreen | DoneAdvisory -> `complete
193    /// --outcome done` -> Closed. Anything else -> `complete --outcome
194    /// failed` (the verb pauses the mission) -> Parked; the NEXT next() call
195    /// returns the pause and run_loop terminates NoProgress.
196    fn close(&mut self, unit: &Unit, evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
197        let (project, wave) = match self.active.remove(&unit.id) {
198            Some(e) => (e.project, e.wave),
199            None => {
200                // Unit not dequeued by this queue instance (e.g. a caller
201                // bypassing next()). Recover from the unit id shape - but
202                // LOUDLY: a malformed wave must not silently become
203                // `--wave 0` (a record no manifest matches; the mission
204                // would stall instead of failing - sigma-review).
205                match unit.id.split_once("@wave-") {
206                    Some((p, w)) => {
207                        let parsed = w.parse::<u64>().map_err(|_| {
208                            LoopError::Queue(format!(
209                                "megatron close: malformed unit id {:?} (wave is not an integer)",
210                                unit.id
211                            ))
212                        })?;
213                        (p.to_string(), parsed)
214                    }
215                    None => {
216                        return Err(LoopError::Queue(format!(
217                            "megatron close: unknown unit {:?} (no active entry)",
218                            unit.id
219                        )));
220                    }
221                }
222            }
223        };
224
225        let done = matches!(
226            evidence.reason,
227            TerminationReason::NoWork
228                | TerminationReason::DonePRGreen
229                | TerminationReason::DoneAdvisory
230        );
231        let outcome_flag = if done { "done" } else { "failed" };
232        let reason_str = format!("{:?}", evidence.reason);
233
234        let out = retry_etxtbsy(|| {
235            abi_cmd(&self.abi_bin)
236                .args([
237                    "megatron",
238                    "complete",
239                    &self.mission_id,
240                    "--project",
241                    &project,
242                    "--wave",
243                    &wave.to_string(),
244                    "--outcome",
245                    outcome_flag,
246                    "--reason",
247                    &reason_str,
248                ])
249                .output()
250        })
251        .map_err(|e| LoopError::Queue(format!("fno megatron complete: spawn failed: {e}")))?;
252
253        if !out.status.success() {
254            // Fail closed: an unrecordable close must not advance the walk.
255            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
256            return Err(LoopError::Queue(maybe_stale_hint(
257                format!("fno megatron complete: exit {}: {stderr}", out.status),
258                &self.abi_bin,
259            )));
260        }
261
262        // The verb can REFUSE a done outcome: a drained child walk is not
263        // proof the project's mission node is done (a prior child's live
264        // claim hides the node from `backlog next` - codex P1). The verb
265        // pauses the mission and answers {"result": "incomplete"}; map it to
266        // Parked so the walk never records a false completion.
267        if done {
268            let stdout = String::from_utf8_lossy(&out.stdout);
269            if let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) {
270                if v["result"].as_str() == Some("incomplete") {
271                    let detail = v["detail"].as_str().unwrap_or("project incomplete");
272                    return Ok(CloseOutcome::Parked(detail.to_string()));
273                }
274            }
275        }
276
277        if done {
278            Ok(CloseOutcome::Closed)
279        } else {
280            let detail = if evidence.message.is_empty() {
281                format!("project walk terminated: {reason_str}")
282            } else {
283                format!(
284                    "project walk terminated: {reason_str}: {}",
285                    evidence.message
286                )
287            };
288            Ok(CloseOutcome::Parked(detail))
289        }
290    }
291}
292
293// ── MegatronDispatcher ────────────────────────────────────────────────────────
294
295/// A live child megawalk process.
296pub struct MegatronSession {
297    child: Child,
298}
299
300impl Session for MegatronSession {
301    fn wait(&mut self) -> Result<i32, LoopError> {
302        let status = self.child.wait().map_err(LoopError::Io)?;
303        use std::os::unix::process::ExitStatusExt;
304        Ok(status
305            .code()
306            .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)))
307    }
308}
309
310/// Dispatches each project unit as a megawalk one altitude down: spawns this
311/// same binary with `loop run --driver megawalk --cwd <project_path>
312/// --mission <id> --termination-key <session_key>`.
313pub struct MegatronDispatcher {
314    /// Path to the fno-agents binary to re-invoke. `current_exe()` in
315    /// production; injectable for tests.
316    fno_agents_bin: PathBuf,
317    dispatcher_name: String,
318    driver_lib_dir: PathBuf,
319    mission_id: String,
320    max_turns: u64,
321    budget_usd: f64,
322    model: Option<String>,
323    cli_alias: Option<String>,
324    allow_merge: bool,
325}
326
327impl MegatronDispatcher {
328    #[allow(clippy::too_many_arguments)]
329    pub fn new(
330        fno_agents_bin: PathBuf,
331        dispatcher_name: String,
332        driver_lib_dir: PathBuf,
333        mission_id: String,
334        max_turns: u64,
335        budget_usd: f64,
336        model: Option<String>,
337        cli_alias: Option<String>,
338        allow_merge: bool,
339    ) -> Self {
340        Self {
341            fno_agents_bin,
342            dispatcher_name,
343            driver_lib_dir,
344            mission_id,
345            max_turns,
346            budget_usd,
347            model,
348            cli_alias,
349            allow_merge,
350        }
351    }
352}
353
354impl Dispatcher for MegatronDispatcher {
355    fn run(&self, unit: &Unit, _ctx: &DispatchCtx) -> Result<Box<dyn Session>, LoopError> {
356        let project_path = unit
357            .extra_env
358            .iter()
359            .find(|(k, _)| k == "MEGATRON_PROJECT_PATH")
360            .map(|(_, v)| v.clone())
361            .ok_or_else(|| {
362                LoopError::Dispatch(format!(
363                    "megatron dispatch: unit {:?} carries no MEGATRON_PROJECT_PATH",
364                    unit.id
365                ))
366            })?;
367
368        let mut cmd = Command::new(&self.fno_agents_bin);
369        cmd.args([
370            "loop",
371            "run",
372            "--driver",
373            "megawalk",
374            "--cwd",
375            &project_path,
376            "--mission",
377            &self.mission_id,
378            "--termination-key",
379            &unit.session_key,
380            "--dispatcher",
381            &self.dispatcher_name,
382            "--max-turns",
383            &self.max_turns.to_string(),
384            "--budget",
385            &self.budget_usd.to_string(),
386        ]);
387        cmd.args([
388            "--driver-lib-dir",
389            self.driver_lib_dir.to_str().ok_or_else(|| {
390                LoopError::Dispatch("driver lib dir path is not valid UTF-8".to_string())
391            })?,
392        ]);
393        if let Some(ref m) = self.model {
394            cmd.args(["--model", m]);
395        }
396        if let Some(ref c) = self.cli_alias {
397            cmd.args(["--cli", c]);
398        }
399        if self.allow_merge {
400            cmd.arg("--allow-merge");
401        }
402
403        // Pass the unit env through so the child walk (and its target
404        // sessions) can see which mission/project dispatched it.
405        for (k, v) in &unit.extra_env {
406            cmd.env(k, v);
407        }
408
409        // Inherit stdio so the child walk's progress lines stream through
410        // the commander's terminal (AC2-UI analog at the fleet altitude).
411        let child = retry_etxtbsy(|| cmd.spawn())
412            .map_err(|e| LoopError::Dispatch(format!("spawn child megawalk: {e}")))?;
413
414        Ok(Box::new(MegatronSession { child }))
415    }
416}
417
418// ── fleet claim RAII guard ────────────────────────────────────────────────────
419
420/// Releases the `fleet:<mission_id>` singleton claim on drop, so EVERY exit
421/// path - early `?` returns (e.g. `current_exe()` failure), fatal loop
422/// errors, and panics - releases the claim instead of leaking it for the
423/// 24h TTL (gemini HIGH on PR #458: the manual release calls missed the
424/// `?` propagation path between acquire and the first release site).
425struct FleetClaimGuard {
426    abi_bin: String,
427    key: String,
428    holder: String,
429}
430
431impl Drop for FleetClaimGuard {
432    fn drop(&mut self) {
433        let _ = abi_cmd(&self.abi_bin)
434            .args(["claim", "release", &self.key, "--holder", &self.holder])
435            .output();
436    }
437}
438
439// ── verb glue: pub fn run() ───────────────────────────────────────────────────
440
441/// Entry point for `fno-agents loop run --driver megatron --mission <id>`.
442///
443/// Exit codes (preserving the `fno megatron run` CLI contract):
444/// - 0:   mission complete (NoWork)
445/// - 1:   Budget / failure
446/// - 2:   usage / configuration error
447/// - 3:   another commander holds the fleet claim (CommanderAlreadyRunning)
448/// - 4:   mission paused (NoProgress via pause policy)
449/// - 77:  driver binary missing (preflight failure, child walks would fail)
450/// - 130: Interrupted (SIGINT)
451#[allow(clippy::too_many_arguments)]
452pub fn run(
453    dispatcher_name: &str,
454    max_iterations: Option<u64>,
455    max_turns: u64,
456    budget_usd: f64,
457    model: Option<&str>,
458    cli_alias: Option<&str>,
459    driver_lib_dir: Option<PathBuf>,
460    cwd: PathBuf,
461    allow_merge: bool,
462    mission_id: &str,
463) -> i32 {
464    match run_inner(
465        dispatcher_name,
466        max_iterations,
467        max_turns,
468        budget_usd,
469        model,
470        cli_alias,
471        driver_lib_dir,
472        cwd,
473        allow_merge,
474        mission_id,
475    ) {
476        Ok(code) => code,
477        Err(e) => {
478            eprintln!("fno-agents loop megatron: {e}");
479            2
480        }
481    }
482}
483
484#[allow(clippy::too_many_arguments)]
485fn run_inner(
486    dispatcher_name: &str,
487    max_iterations: Option<u64>,
488    max_turns: u64,
489    budget_usd: f64,
490    model: Option<&str>,
491    cli_alias: Option<&str>,
492    driver_lib_dir: Option<PathBuf>,
493    cwd: PathBuf,
494    allow_merge: bool,
495    mission_id: &str,
496) -> Result<i32, Box<dyn std::error::Error>> {
497    use crate::loop_dispatch::{preflight, resolve_driver_binary};
498    use crate::loop_target::{exit_code_for_reason, install_sigint_handler, SIGINT_RECEIVED};
499    use std::sync::atomic::Ordering;
500
501    // ── resolve driver-lib-dir (passed through to every child walk) ──────────
502    let lib_dir = match driver_lib_dir {
503        Some(d) => d,
504        None => {
505            if let Ok(env_dir) = std::env::var("FNO_DRIVER_LIB_DIR") {
506                PathBuf::from(env_dir)
507            } else {
508                let candidate = cwd.join("scripts").join("lib");
509                if candidate.is_dir() {
510                    candidate
511                } else {
512                    eprintln!(
513                        "fno-agents loop megatron: cannot resolve driver lib directory. \
514                         Pass --driver-lib-dir <path> or set FNO_DRIVER_LIB_DIR env."
515                    );
516                    return Ok(2);
517                }
518            }
519        }
520    };
521
522    // ── preflight: the CHILD walks need the driver binary; fail before claims ─
523    if let Err(e) = preflight(dispatcher_name, &lib_dir, cli_alias) {
524        match e {
525            LoopError::Dispatch(msg) => {
526                eprintln!("fno-agents loop megatron: {msg}");
527                return Ok(77);
528            }
529            other => {
530                eprintln!("fno-agents loop megatron: {other}");
531                return Ok(2);
532            }
533        }
534    }
535
536    // ── acquire the fleet singleton claim ─────────────────────────────────────
537    // Preserves the CommanderAlreadyRunning contract (old loop.py PR1 claim):
538    // a second commander on the same mission exits 3, never racing dispatch.
539    // TTL liveness (not PID): the claim subprocess's PID is short-lived, so
540    // 24h TTL is the model. Assumption: no single mission run exceeds 24h
541    // (each project walk is itself budget-capped); a marathon mission would
542    // need a TTL refresh between iterations - conscious gap, not an accident.
543    let abi_bin = std::env::var("FNO_BIN").unwrap_or_else(|_| "fno".to_string());
544    let fleet_key = format!("fleet:{mission_id}");
545    let fleet_holder = format!("megatron-loop:{}", std::process::id());
546
547    let claim_out = abi_cmd(&abi_bin)
548        .args([
549            "claim",
550            "acquire",
551            &fleet_key,
552            "--holder",
553            &fleet_holder,
554            "--ttl",
555            "24h",
556            "--reason",
557            "megatron commander singleton",
558        ])
559        .output();
560
561    match claim_out {
562        Ok(o) if !o.status.success() => {
563            let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
564            eprintln!(
565                "fno-agents loop megatron: another commander is already running \
566                 mission {mission_id}: {stderr}"
567            );
568            return Ok(3);
569        }
570        Err(e) => {
571            // Fail closed (unlike megawalk's best-effort walker claim): the
572            // queue verbs shell the SAME fno binary, so a commander that
573            // cannot spawn it could never make progress anyway - and a
574            // claimless commander racing a healthy one would race wave
575            // advancement and completion-record writes (sigma-review).
576            eprintln!(
577                "fno-agents loop megatron: cannot spawn '{abi_bin}' to acquire the fleet \
578                 claim: {e}; refusing to run without the commander singleton"
579            );
580            return Ok(2);
581        }
582        Ok(_) => {}
583    }
584
585    // RAII: releases on every exit path from here on (early returns, `?`,
586    // panics). Explicitly dropped after run_loop to preserve release timing.
587    let claim_guard = FleetClaimGuard {
588        abi_bin: abi_bin.clone(),
589        key: fleet_key,
590        holder: fleet_holder,
591    };
592
593    // ── SIGINT handler ────────────────────────────────────────────────────────
594    install_sigint_handler();
595
596    // ── journal (commander cwd project journal + global mirror) ───────────────
597    let abilities_dir = cwd.join(".fno");
598    let project_events = abilities_dir.join("events.jsonl");
599    let home_dir = std::env::var("HOME")
600        .map(PathBuf::from)
601        .unwrap_or_else(|_| PathBuf::from("/tmp"));
602    let global_events = home_dir.join(".fno").join("events.jsonl");
603    let journal = Journal::new(
604        ProjectJournalPath(project_events),
605        GlobalJournalPath(global_events),
606    );
607
608    // ── header ────────────────────────────────────────────────────────────────
609    let binary_name = resolve_driver_binary(dispatcher_name, cli_alias);
610    let max_iters = max_iterations.unwrap_or(DEFAULT_MISSION_ITERATIONS);
611    println!("fno-agents loop megatron");
612    println!("  driver:     megatron (projects walk via --driver megawalk)");
613    println!("  dispatcher: {dispatcher_name} (binary: {binary_name})");
614    println!("  mission:    {mission_id}");
615    println!("  iterations: {max_iters} max");
616    println!("  budget:     ${budget_usd} USD per project session");
617
618    // ── queue + dispatcher ────────────────────────────────────────────────────
619    let mut queue = MegatronQueue::new(abi_bin.clone(), mission_id.to_string());
620    let self_bin = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
621    let dispatcher = MegatronDispatcher::new(
622        self_bin,
623        dispatcher_name.to_string(),
624        lib_dir,
625        mission_id.to_string(),
626        max_turns,
627        budget_usd,
628        model.map(|s| s.to_string()),
629        cli_alias.map(|s| s.to_string()),
630        allow_merge,
631    );
632
633    // ── budget ────────────────────────────────────────────────────────────────
634    let budget = match LoopBudget::new(max_iters) {
635        Ok(b) => b,
636        Err(e) => {
637            eprintln!("fno-agents loop megatron: {e}");
638            return Ok(2);
639        }
640    };
641
642    // ── cancel closure ────────────────────────────────────────────────────────
643    // Mission-level cancel (`fno megatron cancel`) propagates through the
644    // verb seam: it flips status to cancelled, and the next `fno megatron
645    // next` returns a terminal pause. The sentinel here covers the local
646    // commander process only.
647    let cancel_file = cwd.join(".fno").join(".target-cancelled");
648    let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();
649
650    // ── run the loop ──────────────────────────────────────────────────────────
651    // A child walk that exits without journaling a termination event is
652    // abnormal (the walk has its own internal re-dispatch); cap re-dispatch
653    // attempts low so a crash-looping walk parks rather than burning budget.
654    const PER_PROJECT_MAX_DISPATCHES: u64 = 3;
655    let outcome = match run_loop(
656        &mut queue,
657        &dispatcher,
658        &budget,
659        &journal,
660        &cancel,
661        Some(PER_PROJECT_MAX_DISPATCHES),
662    ) {
663        Ok(o) => o,
664        Err(e) => {
665            eprintln!("fno-agents loop megatron: fatal loop error: {e}");
666            return Ok(2);
667        }
668    };
669
670    // Release before the final report so the singleton frees the moment the
671    // walk is over (same timing as the previous manual release site).
672    drop(claim_guard);
673
674    // ── report + exit-code mapping ────────────────────────────────────────────
675    // NoProgress here means a pause policy fired (mission paused / manifest
676    // mutated / project failed); map to exit 4 per the megatron CLI contract.
677    let exit_code = match outcome.reason {
678        TerminationReason::NoProgress => 4,
679        ref r => exit_code_for_reason(r),
680    };
681    println!(
682        "megatron: {:?} ({} iterations used, {} project walks closed)",
683        outcome.reason,
684        outcome.iterations_used,
685        outcome.units.len()
686    );
687    for unit_result in &outcome.units {
688        println!(
689            "  project {}: {:?} ({:?})",
690            unit_result.unit_id, unit_result.evidence.reason, unit_result.close
691        );
692    }
693
694    Ok(exit_code)
695}
696
697/// Default mission-level iteration ceiling when --max-iterations is absent.
698/// Each project dispatch consumes one iteration; re-dispatch of a crashed
699/// walk consumes more. 50 covers a large mission (waves x projects x retries)
700/// while still bounding a runaway commander.
701const DEFAULT_MISSION_ITERATIONS: u64 = 50;