1use 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
43pub(crate) static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
46
47extern "C" fn handle_sigint(_: libc::c_int) {
49 SIGINT_RECEIVED.store(true, Ordering::SeqCst);
50}
51
52pub(crate) fn install_sigint_handler() {
56 unsafe {
58 libc::signal(
59 libc::SIGINT,
60 handle_sigint as *const () as libc::sighandler_t,
61 );
62 }
63}
64
65struct TargetManifest {
69 session_id: String,
70 input: String,
71 plan_path: String,
72}
73
74fn 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 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
118pub struct TargetQueue {
134 unit: Option<Unit>,
135}
136
137impl TargetQueue {
138 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 fn close(&mut self, _unit: &Unit, _evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
181 Ok(CloseOutcome::Closed)
182 }
183}
184
185pub(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
207pub 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 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..]; 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 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 let mut mission: Option<String> = None;
271 let mut termination_key: Option<String> = None;
272
273 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 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 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 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 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 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 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 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; let lib_dir = match driver_lib_dir {
490 Some(d) => d,
491 None => {
492 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 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 let lib_path = match preflight(&dispatcher_name, &lib_dir, cli_alias.as_deref()) {
524 Ok(p) => p,
525 Err(LoopError::Dispatch(msg)) => {
526 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 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 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 env.push((
595 "FNO_CWD".to_string(),
596 cwd.to_str().unwrap_or(".").to_string(),
597 ));
598
599 install_sigint_handler();
601
602 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 let (session_id_display, input_display) = {
618 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 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 let dispatcher = ShelloutDispatcher::new(lib_path, env, cwd.clone());
638
639 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 let cancel_file = cwd.join(".fno").join(".target-cancelled");
650 let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();
651
652 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 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 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 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}