1use 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
46struct ProjectEntry {
51 project: String,
52 wave: u64,
53}
54
55pub struct MegatronQueue {
58 abi_bin: String,
60 mission_id: String,
62 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 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 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 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 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 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 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 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 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
293pub 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
310pub struct MegatronDispatcher {
314 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 for (k, v) in &unit.extra_env {
406 cmd.env(k, v);
407 }
408
409 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
418struct 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#[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 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 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 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 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 let claim_guard = FleetClaimGuard {
588 abi_bin: abi_bin.clone(),
589 key: fleet_key,
590 holder: fleet_holder,
591 };
592
593 install_sigint_handler();
595
596 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 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 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 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 let cancel_file = cwd.join(".fno").join(".target-cancelled");
648 let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();
649
650 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 drop(claim_guard);
673
674 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
697const DEFAULT_MISSION_ITERATIONS: u64 = 50;