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
65pub(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
75pub(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 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 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
131pub struct TargetQueue {
147 unit: Option<Unit>,
148}
149
150impl TargetQueue {
151 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 fn close(&mut self, _unit: &Unit, _evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
194 Ok(CloseOutcome::Closed)
195 }
196}
197
198pub(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 | TerminationReason::DoneAwaitingMerge
220 | TerminationReason::DonePlanned
223 | TerminationReason::NoWork => 0,
224 TerminationReason::Budget | TerminationReason::NoProgress | TerminationReason::Aborted => 1,
225 TerminationReason::Interrupted => 130,
226 }
227}
228
229pub 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 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..]; 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 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 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 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 let lib_dir = match driver_lib_dir {
378 Some(d) => d,
379 None => {
380 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 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 let lib_path = match preflight(&dispatcher_name, &lib_dir, cli_alias.as_deref()) {
412 Ok(p) => p,
413 Err(LoopError::Dispatch(msg)) => {
414 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 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 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 env.push((
483 "FNO_CWD".to_string(),
484 cwd.to_str().unwrap_or(".").to_string(),
485 ));
486
487 install_sigint_handler();
489
490 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 let (session_id_display, input_display) = {
506 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 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 let dispatcher = ShelloutDispatcher::new(lib_path, env, cwd.clone());
526
527 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 let cancel_file = cwd.join(".fno").join(".target-cancelled");
538 let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();
539
540 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 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 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 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}