use crate::loop_dispatch::{
driver_default_max, preflight, resolve_driver_binary, ShelloutDispatcher,
};
use crate::loop_runtime::{
run_loop, CloseOutcome, Evidence, GlobalJournalPath, Journal, LoopBudget, LoopError,
ProjectJournalPath, Queue, Unit,
};
use crate::loopcheck::TerminationReason;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_sigint(_: libc::c_int) {
SIGINT_RECEIVED.store(true, Ordering::SeqCst);
}
pub(crate) fn install_sigint_handler() {
unsafe {
libc::signal(
libc::SIGINT,
handle_sigint as *const () as libc::sighandler_t,
);
}
}
pub(crate) struct TargetManifest {
pub(crate) session_id: String,
pub(crate) input: String,
pub(crate) harness_session_id: Option<String>,
plan_path: String,
}
pub(crate) fn parse_target_manifest(content: &str) -> Option<TargetManifest> {
let content = content.trim_start();
if !content.starts_with("---") {
return None;
}
let after_first = &content[3..];
let end = after_first.find("\n---")?;
let body = &after_first[..end];
let mut fno_id = String::new();
let mut session_id = String::new();
let mut input = String::new();
let mut harness_session_id = String::new();
let mut plan_path = String::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once(':') {
let k = k.trim();
let v = v.trim().trim_matches(|c: char| c == '"' || c == '\'');
match k {
"fno_id" => fno_id = v.to_string(),
"session_id" => session_id = v.to_string(),
"input" => input = v.to_string(),
"harness_session_id" => harness_session_id = v.to_string(),
"plan_path" => plan_path = v.to_string(),
_ => {}
}
}
}
let session_id = if fno_id.is_empty() {
session_id
} else {
fno_id
};
if session_id.is_empty() {
return None;
}
Some(TargetManifest {
session_id,
input,
harness_session_id: (!harness_session_id.is_empty() && harness_session_id != "null")
.then_some(harness_session_id),
plan_path,
})
}
pub struct TargetQueue {
unit: Option<Unit>,
}
impl TargetQueue {
pub fn from_manifest(repo_root: &Path) -> Result<Self, LoopError> {
let manifest_path = repo_root.join(".fno").join("target-state.md");
if !manifest_path.exists() {
return Err(LoopError::Queue(format!(
"No state file found at .fno/target-state.md - run /target first to initialize (looked in: {})",
manifest_path.display()
)));
}
let content = fs::read_to_string(&manifest_path).map_err(LoopError::Io)?;
let manifest = parse_target_manifest(&content).ok_or_else(|| {
LoopError::Queue(
"No state file found at .fno/target-state.md - run /target first to initialize (manifest missing required fields)".to_string()
)
})?;
let unit = Unit {
id: manifest.session_id.clone(),
title: manifest.input.clone(),
session_key: manifest.session_id,
plan_path: if manifest.plan_path.is_empty() {
None
} else {
Some(manifest.plan_path)
},
extra_env: vec![],
};
Ok(Self { unit: Some(unit) })
}
}
impl Queue for TargetQueue {
fn next(&mut self) -> Result<Option<Unit>, LoopError> {
Ok(self.unit.take())
}
fn close(&mut self, _unit: &Unit, _evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
Ok(CloseOutcome::Closed)
}
}
pub(crate) fn exit_code_for_reason(reason: &TerminationReason) -> i32 {
match reason {
TerminationReason::DonePRGreen
| TerminationReason::DoneAdvisory
| TerminationReason::DoneDelivery
| TerminationReason::DoneBatched
| TerminationReason::DoneAwaitingMerge
| TerminationReason::DonePlanned
| TerminationReason::NoWork => 0,
TerminationReason::Budget | TerminationReason::NoProgress | TerminationReason::Aborted => 1,
TerminationReason::Interrupted => 130,
}
}
pub fn run_loop_verb(args: &[String]) -> i32 {
match run_loop_verb_inner(args) {
Ok(code) => code,
Err(e) => {
eprintln!("fno-agents loop: {e}");
2
}
}
}
fn run_loop_verb_inner(args: &[String]) -> Result<i32, Box<dyn std::error::Error>> {
let subcommand = args.first().map(|s| s.as_str()).unwrap_or("");
if subcommand != "run" {
eprintln!("fno-agents loop: expected subcommand 'run', got '{subcommand}'");
eprintln!("Usage: fno-agents loop run --driver <name> [options]");
return Ok(2);
}
let args = &args[1..];
let mut driver: Option<String> = None;
let mut dispatcher_name = "claude-code".to_string();
let mut max_iterations: Option<u64> = None;
let mut max_turns: u64 = 15;
let mut budget_usd: f64 = 25.0;
let mut model: Option<String> = None;
let mut prompt_file: Option<String> = None;
let mut cli_alias: Option<String> = None;
let mut driver_lib_dir: Option<PathBuf> = None;
let mut cwd: PathBuf = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
macro_rules! require_value {
($flag:expr, $args:expr, $i:expr) => {{
$i += 1;
match $args.get($i) {
Some(v) => v.as_str(),
None => {
eprintln!("fno-agents loop run: {}: missing value", $flag);
return Ok(2);
}
}
}};
}
let mut i = 0;
while i < args.len() {
let flag = args[i].as_str();
match flag {
"--driver" => {
driver = Some(require_value!("--driver", args, i).to_string());
}
"--dispatcher" => {
dispatcher_name = require_value!("--dispatcher", args, i).to_string();
}
"--max-iterations" => {
let v = require_value!("--max-iterations", args, i);
max_iterations = Some(
v.parse::<u64>()
.map_err(|_| format!("--max-iterations: expected integer, got '{v}'"))?,
);
}
"--max-turns" => {
let v = require_value!("--max-turns", args, i);
max_turns = v
.parse::<u64>()
.map_err(|_| format!("--max-turns: expected integer, got '{v}'"))?;
}
"--budget" => {
let v = require_value!("--budget", args, i);
let parsed = v
.parse::<f64>()
.map_err(|_| format!("--budget: expected number, got '{v}'"))?;
if !parsed.is_finite() || parsed <= 0.0 {
eprintln!(
"fno-agents loop run: --budget must be a positive number, got '{v}' ({parsed})"
);
return Ok(2);
}
budget_usd = parsed;
}
"--model" => {
model = Some(require_value!("--model", args, i).to_string());
}
"--prompt-file" => {
prompt_file = Some(require_value!("--prompt-file", args, i).to_string());
}
"--cli" => {
cli_alias = Some(require_value!("--cli", args, i).to_string());
}
"--driver-lib-dir" => {
driver_lib_dir = Some(PathBuf::from(require_value!("--driver-lib-dir", args, i)));
}
"--cwd" => {
cwd = PathBuf::from(require_value!("--cwd", args, i));
}
_ => {
eprintln!("fno-agents loop run: unknown flag '{flag}'");
return Ok(2);
}
}
i += 1;
}
match driver.as_deref() {
None => {
eprintln!("fno-agents loop run: --driver is required");
eprintln!("Usage: fno-agents loop run --driver target [options]");
return Ok(2);
}
Some("target") => {}
Some(other) => {
eprintln!("fno-agents loop run: unknown --driver '{other}'; supported: 'target'");
return Ok(2);
}
}
let lib_dir = match driver_lib_dir {
Some(d) => d,
None => {
if let Ok(env_dir) = std::env::var("FNO_DRIVER_LIB_DIR") {
PathBuf::from(env_dir)
} else {
let candidate = cwd.join("scripts").join("lib");
if candidate.is_dir() {
candidate
} else {
eprintln!(
"fno-agents loop run: cannot resolve driver lib directory. \
Pass --driver-lib-dir <path> (the fno plugin's \
scripts/lib directory) or set FNO_DRIVER_LIB_DIR env."
);
return Ok(2);
}
}
}
};
let mut queue = match TargetQueue::from_manifest(&cwd) {
Ok(q) => q,
Err(e) => {
eprintln!("fno-agents loop run: {e}");
return Ok(1);
}
};
let lib_path = match preflight(&dispatcher_name, &lib_dir, cli_alias.as_deref()) {
Ok(p) => p,
Err(LoopError::Dispatch(msg)) => {
eprintln!("fno-agents loop run: {msg}");
return Ok(77);
}
Err(e) => {
eprintln!("fno-agents loop run: {e}");
return Ok(2);
}
};
let max_iters = match max_iterations {
Some(n) => n,
None => match driver_default_max(&lib_path) {
Ok(n) => n,
Err(e) => {
eprintln!(
"fno-agents loop run: could not query driver_default_max: {e}; \
pass --max-iterations explicitly"
);
return Ok(2);
}
},
};
let fno_dir = cwd.join(".fno");
let output_file = fno_dir.join("target-last-output.txt");
let history_file = fno_dir.join("target-history.txt");
let signal_file = fno_dir.join("target-promise.signal");
let mut env: Vec<(String, String)> = vec![
(
"OUTPUT_FILE".to_string(),
output_file.to_str().unwrap_or("").to_string(),
),
(
"HISTORY_FILE".to_string(),
history_file.to_str().unwrap_or("").to_string(),
),
(
"SIGNAL_FILE".to_string(),
signal_file.to_str().unwrap_or("").to_string(),
),
("MAX_TURNS".to_string(), max_turns.to_string()),
("BUDGET_USD".to_string(), format!("{budget_usd}")),
(
"CONTINUE_PROMPT".to_string(),
"/target --resume".to_string(),
),
];
if let Some(m) = &model {
env.push(("MODEL_FLAG".to_string(), format!("--model {m}")));
} else {
env.push(("MODEL_FLAG".to_string(), String::new()));
}
if let Some(pf) = &prompt_file {
env.push(("PROMPT_FILE".to_string(), pf.clone()));
}
if let Some(cli) = &cli_alias {
env.push(("CLI".to_string(), cli.clone()));
}
env.push((
"FNO_CWD".to_string(),
cwd.to_str().unwrap_or(".").to_string(),
));
install_sigint_handler();
let project_events = fno_dir.join("events.jsonl");
let home_dir = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"));
let global_events = home_dir.join(".fno").join("events.jsonl");
let journal = Journal::new(
ProjectJournalPath(project_events),
GlobalJournalPath(global_events),
);
let (session_id_display, input_display) = {
match queue.unit.as_ref() {
Some(u) => (u.id.clone(), u.title.clone()),
None => ("(none)".to_string(), "(none)".to_string()),
}
};
let binary_name = resolve_driver_binary(&dispatcher_name, cli_alias.as_deref());
println!("fno-agents loop run");
println!(" driver: target");
println!(" dispatcher: {dispatcher_name} (binary: {binary_name})");
println!(" session: {session_id_display}");
println!(" input: {input_display}");
println!(" iterations: {max_iters} max");
println!(" budget: ${budget_usd} USD");
let dispatcher = ShelloutDispatcher::new(lib_path, env, cwd.clone());
let budget = match LoopBudget::new(max_iters) {
Ok(b) => b,
Err(e) => {
eprintln!("fno-agents loop run: {e}");
return Ok(2);
}
};
let cancel_file = cwd.join(".fno").join(".target-cancelled");
let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();
let outcome = match run_loop(&mut queue, &dispatcher, &budget, &journal, &cancel, None) {
Ok(o) => o,
Err(e) => {
eprintln!("fno-agents loop run: fatal loop error: {e}");
return Ok(2);
}
};
let (headline_reason, exit_code) = if let Some(unit_result) = outcome.units.first() {
let r = &unit_result.evidence.reason;
let code = exit_code_for_reason(r);
(format!("{r:?}"), code)
} else {
let code = exit_code_for_reason(&outcome.reason);
(format!("{:?}", outcome.reason), code)
};
println!(
"loop: {} ({} iterations used)",
headline_reason, outcome.iterations_used
);
for unit_result in &outcome.units {
println!(
" unit {}: {:?} ({:?})",
unit_result.unit_id, unit_result.evidence.reason, unit_result.close
);
}
Ok(exit_code)
}