use crate::loop_dispatch::ShelloutDispatcher;
use crate::loop_runtime::{
CloseOutcome, DispatchCtx, Dispatcher, Evidence, Journal, LoopError, Queue, Session, Unit,
};
use crate::loopcheck::TerminationReason;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
pub fn clamp_parallel_cap(cap: u64) -> u64 {
if cap < 1 {
eprintln!(
"loop-megawalk: WARNING: --parallel-cap {cap} < 1; clamped to 1 \
(boundary: cap must be >= 1)"
);
1
} else {
cap
}
}
struct PolicyUnitEntry {
unit: Unit,
}
fn is_done_reason(r: &TerminationReason) -> bool {
matches!(
r,
TerminationReason::DonePRGreen | TerminationReason::DoneAdvisory
)
}
pub struct MegawalkPolicyQueue {
pending: std::collections::VecDeque<PolicyUnitEntry>,
p0_failure: Option<String>, consecutive_failures: usize,
streak_ids: Vec<String>,
}
impl MegawalkPolicyQueue {
pub fn new() -> Self {
Self {
pending: std::collections::VecDeque::new(),
p0_failure: None,
consecutive_failures: 0,
streak_ids: vec![],
}
}
pub fn push_unit(&mut self, unit: Unit, is_p0: bool) {
let _ = is_p0;
self.pending.push_back(PolicyUnitEntry { unit });
}
pub fn record_close(&mut self, unit: &Unit, evidence: &Evidence, is_p0: bool) {
let is_success = is_done_reason(&evidence.reason);
if is_success {
self.consecutive_failures = 0;
self.streak_ids.clear();
self.p0_failure = None;
} else {
self.consecutive_failures += 1;
self.streak_ids.push(unit.id.clone());
if is_p0 {
self.p0_failure = Some(unit.id.clone());
}
}
}
pub fn should_pause(&self) -> Option<(String, String)> {
if let Some(ref uid) = self.p0_failure {
return Some(("p0_failed".to_string(), uid.clone()));
}
if self.consecutive_failures >= 3 {
let detail = self.streak_ids.join(" ");
return Some(("consecutive_failures".to_string(), detail));
}
None
}
}
impl Default for MegawalkPolicyQueue {
fn default() -> Self {
Self::new()
}
}
impl Queue for MegawalkPolicyQueue {
fn next(&mut self) -> Result<Option<Unit>, LoopError> {
if let Some((policy, detail)) = self.should_pause() {
return Err(LoopError::Pause { policy, detail });
}
match self.pending.pop_front() {
None => Ok(None),
Some(entry) => Ok(Some(entry.unit)),
}
}
fn close(&mut self, unit: &Unit, evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
let is_success = is_done_reason(&evidence.reason);
if is_success {
self.consecutive_failures = 0;
self.streak_ids.clear();
self.p0_failure = None;
} else {
self.consecutive_failures += 1;
self.streak_ids.push(unit.id.clone());
}
let outcome = if is_success {
CloseOutcome::Closed
} else {
CloseOutcome::Parked(format!("policy-park: {:?}", evidence.reason))
};
Ok(outcome)
}
}
const MAX_CLAIM_RETRIES: usize = 5;
pub(crate) fn abi_cmd(abi_bin: &str) -> Command {
let binary = std::env::var("FNO_BIN")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| abi_bin.to_string());
Command::new(binary)
}
pub(crate) fn retry_etxtbsy<T>(
mut spawn: impl FnMut() -> std::io::Result<T>,
) -> std::io::Result<T> {
const MAX_RETRIES: u32 = 5;
let mut attempt: u32 = 0;
loop {
match spawn() {
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) && attempt < MAX_RETRIES => {
attempt += 1;
std::thread::sleep(std::time::Duration::from_millis(2 * u64::from(attempt)));
}
other => return other,
}
}
}
fn is_abi_stale(abi_bin: &str) -> bool {
let out = match abi_cmd(abi_bin).args(["doctor", "--json"]).output() {
Ok(o) => o,
Err(_) => return false,
};
let stdout = String::from_utf8_lossy(&out.stdout);
if let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) {
return v["status"].as_str() == Some("stale");
}
false
}
pub(crate) fn maybe_stale_hint(msg: String, abi_bin: &str) -> String {
if is_abi_stale(abi_bin) {
format!("{msg}; installed fno may be stale - run `fno update`")
} else {
msg
}
}
pub(crate) fn gen_session_key_with_infix(infix: &str) -> String {
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let pid = std::process::id();
let entropy: u32 = {
let mut buf = [0u8; 3];
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
use std::io::Read;
let _ = f.read_exact(&mut buf);
} else {
buf[0] = (pid & 0xFF) as u8;
buf[1] = ((pid >> 8) & 0xFF) as u8;
buf[2] = (chrono::Utc::now().timestamp_subsec_nanos() & 0xFF) as u8;
}
u32::from_le_bytes([buf[0], buf[1], buf[2], 0])
};
format!("{ts}-{infix}{pid}-{entropy:06x}")
}
fn gen_session_key() -> String {
gen_session_key_with_infix("mw")
}
fn extract_mission_env(
v: &serde_json::Value,
node_id: &str,
) -> Result<Vec<(String, String)>, LoopError> {
let mission_id = match v["mission_id"].as_str() {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
return Ok(vec![]);
}
};
let mission_wave = match &v["mission_wave"] {
serde_json::Value::Null => {
return Err(LoopError::Queue(format!(
"node {node_id:?} has mission_id={mission_id:?} but mission_wave is missing; \
dispatcher bug (corrupted fleet metadata)"
)));
}
w => w.to_string().trim_matches('"').to_string(),
};
if mission_wave.is_empty() || mission_wave == "null" {
return Err(LoopError::Queue(format!(
"node {node_id:?} has mission_id={mission_id:?} but mission_wave is null; \
dispatcher bug (corrupted fleet metadata)"
)));
}
let mission_slug = match v["mission_slug"].as_str() {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
return Err(LoopError::Queue(format!(
"node {node_id:?} has mission_id={mission_id:?} but mission_slug is missing \
or empty; dispatcher bug (corrupted fleet metadata)"
)));
}
};
let mission_from_msg_id = v["mission_from_msg_id"].as_str().unwrap_or("").to_string();
Ok(vec![
("TARGET_MISSION_ID".to_string(), mission_id),
("TARGET_MISSION_WAVE".to_string(), mission_wave),
("TARGET_MISSION_SLUG".to_string(), mission_slug),
(
"TARGET_MISSION_FROM_MSG_ID".to_string(),
mission_from_msg_id,
),
])
}
struct ClaimEntry {
session_key: String,
is_p0: bool,
}
pub struct MegawalkQueue {
abi_bin: String,
project: Option<String>,
all: bool,
active_claims: HashMap<String, ClaimEntry>,
policy_p0_failure: Option<String>,
policy_consecutive_failures: usize,
policy_streak_ids: Vec<String>,
max_units: Option<u64>,
units_closed: u64,
mission: Option<String>,
}
impl MegawalkQueue {
pub fn new(abi_bin: String, project: Option<String>, all: bool) -> Self {
Self::new_with_max_units(abi_bin, project, all, None)
}
pub fn new_with_max_units(
abi_bin: String,
project: Option<String>,
all: bool,
max_units: Option<u64>,
) -> Self {
Self {
abi_bin,
project,
all,
active_claims: HashMap::new(),
policy_p0_failure: None,
policy_consecutive_failures: 0,
policy_streak_ids: vec![],
max_units,
units_closed: 0,
mission: None,
}
}
pub fn with_mission(mut self, mission: Option<String>) -> Self {
self.mission = mission;
self
}
fn policy_check(&self) -> Option<LoopError> {
if let Some(ref uid) = self.policy_p0_failure {
return Some(LoopError::Pause {
policy: "p0_failed".to_string(),
detail: uid.clone(),
});
}
if self.policy_consecutive_failures >= 3 {
let detail = self.policy_streak_ids.join(" ");
return Some(LoopError::Pause {
policy: "consecutive_failures".to_string(),
detail,
});
}
None
}
fn policy_record_close(&mut self, unit_id: &str, is_success: bool, is_p0: bool) {
if is_success {
self.policy_consecutive_failures = 0;
self.policy_streak_ids.clear();
self.policy_p0_failure = None;
} else {
self.policy_consecutive_failures += 1;
self.policy_streak_ids.push(unit_id.to_string());
if is_p0 {
self.policy_p0_failure = Some(unit_id.to_string());
}
}
}
}
impl Queue for MegawalkQueue {
fn next(&mut self) -> Result<Option<Unit>, LoopError> {
if let Some(cap) = self.max_units {
if self.units_closed >= cap {
return Ok(None);
}
}
if let Some(err) = self.policy_check() {
return Err(err);
}
for attempt in 0..MAX_CLAIM_RETRIES {
let _ = attempt;
let mut cmd = abi_cmd(&self.abi_bin);
cmd.args(["backlog", "next"]);
if self.all {
cmd.arg("--all");
} else if let Some(ref p) = self.project {
cmd.args(["--project", p]);
}
if let Some(ref m) = self.mission {
cmd.args(["--mission", m]);
}
let out = retry_etxtbsy(|| cmd.output()).map_err(|e| {
LoopError::Queue(maybe_stale_hint(
format!("fno backlog next: spawn failed: {e}"),
&self.abi_bin,
))
})?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(LoopError::Queue(maybe_stale_hint(
format!("fno backlog next: exit {}: {stderr}", out.status),
&self.abi_bin,
)));
}
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if stdout == "null" || stdout.is_empty() {
return Ok(None);
}
let v: serde_json::Value = serde_json::from_str(&stdout).map_err(|e| {
LoopError::Queue(maybe_stale_hint(
format!("fno backlog next: JSON parse error: {e} (stdout: {stdout:?})"),
&self.abi_bin,
))
})?;
let id = match v["id"].as_str() {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
return Err(LoopError::Queue(maybe_stale_hint(
format!("fno backlog next: missing or empty 'id' field in: {stdout:?}"),
&self.abi_bin,
)));
}
};
let title = v["title"].as_str().unwrap_or("(untitled)").to_string();
let plan_path = v["plan_path"].as_str().map(|s| s.to_string());
let is_p0 = v["priority"].as_str() == Some("p0");
let extra_env = extract_mission_env(&v, &id)?;
let session_key = gen_session_key();
let claim_key = format!("node:{id}");
let claim_holder = format!("target-session:{session_key}");
let claim_out = retry_etxtbsy(|| {
abi_cmd(&self.abi_bin)
.args([
"claim",
"acquire",
&claim_key,
"--holder",
&claim_holder,
"--ttl",
"2h",
"--reason",
"megawalk walker dispatch",
])
.env(
"FNO_CLAIMS_ROOT",
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
)
.output()
})
.map_err(|e| {
LoopError::Queue(maybe_stale_hint(
format!("fno claim acquire: spawn failed: {e}"),
&self.abi_bin,
))
})?;
if claim_out.status.success() {
self.active_claims.insert(
id.clone(),
ClaimEntry {
session_key: session_key.clone(),
is_p0,
},
);
return Ok(Some(Unit {
id,
title,
session_key,
plan_path,
extra_env,
}));
}
match claim_out.status.code() {
Some(1) => {
}
_ => {
let stderr = String::from_utf8_lossy(&claim_out.stderr)
.trim()
.to_string();
let code = claim_out.status.code().unwrap_or(-1);
return Err(LoopError::Queue(maybe_stale_hint(
format!("fno claim acquire {claim_key}: exit {code}: {stderr}"),
&self.abi_bin,
)));
}
}
}
Err(LoopError::Queue(maybe_stale_hint(
format!(
"fno backlog next: exhausted {MAX_CLAIM_RETRIES} attempts; every ready node \
is claimed by another session (last node may be stuck)"
),
&self.abi_bin,
)))
}
fn close(&mut self, unit: &Unit, evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
let should_done = is_done_reason(&evidence.reason);
let outcome = if should_done {
let done_out = retry_etxtbsy(|| {
abi_cmd(&self.abi_bin)
.args(["backlog", "done", &unit.id])
.output()
})
.map_err(|e| LoopError::Queue(format!("fno backlog done: spawn failed: {e}")))?;
if done_out.status.success() {
CloseOutcome::Closed
} else {
let stderr = String::from_utf8_lossy(&done_out.stderr).trim().to_string();
CloseOutcome::Parked(if stderr.is_empty() {
format!(
"fno backlog done {} failed (exit {})",
unit.id, done_out.status
)
} else {
stderr
})
}
} else {
let detail = if evidence.message.is_empty() {
format!("session terminated: {:?}", evidence.reason)
} else {
format!(
"session terminated: {:?}: {}",
evidence.reason, evidence.message
)
};
CloseOutcome::Parked(detail)
};
let is_p0 = self
.active_claims
.get(&unit.id)
.map(|e| e.is_p0)
.unwrap_or(false);
self.policy_record_close(&unit.id, should_done, is_p0);
match &outcome {
CloseOutcome::Closed => {
let session_key = self
.active_claims
.remove(&unit.id)
.map(|e| e.session_key)
.unwrap_or_else(|| unit.session_key.clone());
let claim_key = format!("node:{}", unit.id);
let claim_holder = format!("target-session:{session_key}");
let release_result = retry_etxtbsy(|| {
abi_cmd(&self.abi_bin)
.args(["claim", "release", &claim_key, "--holder", &claim_holder])
.env(
"FNO_CLAIMS_ROOT",
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
)
.output()
});
match release_result {
Ok(o) if !o.status.success() => {
eprintln!(
"loop-megawalk: WARNING: claim release {} failed (exit {}): {}",
claim_key,
o.status,
String::from_utf8_lossy(&o.stderr).trim()
);
}
Err(e) => {
eprintln!(
"loop-megawalk: WARNING: claim release {} spawn failed: {e}",
claim_key
);
}
Ok(_) => {}
}
}
CloseOutcome::Parked(_) | CloseOutcome::Refused(_) => {
let session_key = self
.active_claims
.get(&unit.id)
.map(|e| e.session_key.clone())
.unwrap_or_else(|| unit.session_key.clone());
let claim_key = format!("node:{}", unit.id);
let claim_holder = format!("target-session:{session_key}");
let refresh_result = retry_etxtbsy(|| {
abi_cmd(&self.abi_bin)
.args([
"claim",
"acquire",
&claim_key,
"--holder",
&claim_holder,
"--ttl",
"2h",
"--reason",
"megawalk park-exclusion hold",
])
.env(
"FNO_CLAIMS_ROOT",
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
)
.output()
});
match refresh_result {
Ok(o) if !o.status.success() => {
eprintln!(
"loop-megawalk: WARNING: claim refresh (park-hold) {} failed (exit {}): {}",
claim_key,
o.status,
String::from_utf8_lossy(&o.stderr).trim()
);
}
Err(e) => {
eprintln!(
"loop-megawalk: WARNING: claim refresh (park-hold) {} spawn failed: {e}",
claim_key
);
}
Ok(_) => {}
}
}
}
self.units_closed += 1;
Ok(outcome)
}
}
pub struct MegawalkDispatcher {
driver_lib: PathBuf,
static_env: Vec<(String, String)>,
cwd: PathBuf,
_abi_bin: String,
allow_merge: bool,
}
impl MegawalkDispatcher {
pub fn new(
driver_lib: PathBuf,
static_env: Vec<(String, String)>,
cwd: PathBuf,
abi_bin: String,
allow_merge: bool,
) -> Self {
Self {
driver_lib,
static_env,
cwd: crate::paths::canonical_repo_root(&cwd).unwrap_or(cwd),
_abi_bin: abi_bin,
allow_merge,
}
}
}
impl Dispatcher for MegawalkDispatcher {
fn run(&self, unit: &Unit, ctx: &DispatchCtx) -> Result<Box<dyn Session>, LoopError> {
let continue_prompt = if self.allow_merge {
format!("/target {}", unit.id)
} else {
format!("/target no-merge {}", unit.id)
};
let mut env = self.static_env.clone();
env.retain(|(k, _)| k != "CONTINUE_PROMPT" && k != "TARGET_SESSION_ID");
env.push(("CONTINUE_PROMPT".to_string(), continue_prompt));
env.push(("TARGET_SESSION_ID".to_string(), unit.session_key.clone()));
env.extend(unit.extra_env.iter().cloned());
let dispatcher = ShelloutDispatcher::new(self.driver_lib.clone(), env, self.cwd.clone());
dispatcher.run(unit, ctx)
}
}
pub fn emit_walk_termination(
journal: &Journal,
session_key: &str,
reason: &TerminationReason,
iterations_used: u64,
units_closed: usize,
) -> Result<(), crate::loop_runtime::LoopError> {
let reason_str = match serde_json::to_value(reason) {
Ok(serde_json::Value::String(s)) => s,
other => {
return Err(crate::loop_runtime::LoopError::Journal(format!(
"walk termination reason did not serialize to a string \
(got {other:?}); refusing to journal an unparseable reason"
)));
}
};
journal.append(
"termination",
serde_json::json!({
"session_id": session_key,
"reason": reason_str,
"message": format!(
"megawalk walk terminated: {reason_str} ({iterations_used} iterations, {units_closed} units closed)"
),
}),
)
}
#[allow(clippy::too_many_arguments)]
pub fn run(
dispatcher_name: &str,
max_iterations: Option<u64>,
max_turns: u64,
budget_usd: f64,
model: Option<&str>,
prompt_file: Option<&str>,
cli_alias: Option<&str>,
driver_lib_dir: Option<PathBuf>,
cwd: PathBuf,
project: Option<String>,
all: bool,
allow_merge: bool,
parallel_cap: Option<u64>,
max_units: Option<u64>,
mission: Option<String>,
termination_key: Option<String>,
) -> i32 {
match run_inner(
dispatcher_name,
max_iterations,
max_turns,
budget_usd,
model,
prompt_file,
cli_alias,
driver_lib_dir,
cwd,
project,
all,
allow_merge,
parallel_cap,
max_units,
mission,
termination_key,
) {
Ok(code) => code,
Err(e) => {
eprintln!("fno-agents loop megawalk: {e}");
2
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_inner(
dispatcher_name: &str,
max_iterations: Option<u64>,
max_turns: u64,
budget_usd: f64,
model: Option<&str>,
prompt_file: Option<&str>,
cli_alias: Option<&str>,
driver_lib_dir: Option<PathBuf>,
cwd: PathBuf,
project: Option<String>,
all: bool,
allow_merge: bool,
parallel_cap: Option<u64>,
max_units: Option<u64>,
mission: Option<String>,
termination_key: Option<String>,
) -> Result<i32, Box<dyn std::error::Error>> {
use crate::loop_dispatch::{driver_default_max, preflight, resolve_driver_binary};
use crate::loop_runtime::{
run_loop, GlobalJournalPath, Journal, LoopBudget, ProjectJournalPath,
};
use crate::loop_target::{exit_code_for_reason, install_sigint_handler, SIGINT_RECEIVED};
use std::sync::atomic::Ordering;
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 megawalk: cannot resolve driver lib directory. \
Pass --driver-lib-dir <path> or set FNO_DRIVER_LIB_DIR env."
);
return Ok(2);
}
}
}
};
let lib_path = match preflight(dispatcher_name, &lib_dir, cli_alias) {
Ok(p) => p,
Err(crate::loop_runtime::LoopError::Dispatch(msg)) => {
eprintln!("fno-agents loop megawalk: {msg}");
return Ok(77);
}
Err(e) => {
eprintln!("fno-agents loop megawalk: {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 megawalk: could not query driver_default_max: {e}; \
pass --max-iterations explicitly"
);
return Ok(2);
}
},
};
let walker_root = crate::paths::canonical_repo_root(&cwd).unwrap_or_else(|| cwd.clone());
let walker_key = format!("walker:{}", walker_root.display());
let walker_holder = format!("megawalk-loop:{}", std::process::id());
let abi_bin = std::env::var("FNO_BIN").unwrap_or_else(|_| "fno".to_string());
let walker_claim_result = abi_cmd(&abi_bin)
.args([
"claim",
"acquire",
&walker_key,
"--holder",
&walker_holder,
"--ttl",
"24h",
"--reason",
"megawalk walker singleton",
])
.output();
match walker_claim_result {
Ok(o) if !o.status.success() => {
let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
eprintln!(
"fno-agents loop megawalk: walker singleton already running: {stderr}; \
another megawalk is active for this project (holder in claim file)"
);
return Ok(1);
}
Err(e) => {
eprintln!(
"fno-agents loop megawalk: WARNING: walker claim acquire failed: {e} (continuing)"
);
}
Ok(_) => {}
}
let abilities_dir = cwd.join(".fno");
let output_file = abilities_dir.join("target-last-output.txt");
let history_file = abilities_dir.join("target-history.txt");
let signal_file = abilities_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(), String::new()),
];
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.to_string()));
}
if let Some(cli) = cli_alias {
env.push(("CLI".to_string(), cli.to_string()));
}
env.push((
"FNO_CWD".to_string(),
cwd.to_str().unwrap_or(".").to_string(),
));
install_sigint_handler();
let project_events = abilities_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 binary_name = resolve_driver_binary(dispatcher_name, cli_alias);
let scope = if all {
"all projects".to_string()
} else if let Some(ref p) = project {
format!("project={p}")
} else {
"auto-detected project".to_string()
};
println!("fno-agents loop megawalk");
println!(" driver: megawalk");
println!(" dispatcher: {dispatcher_name} (binary: {binary_name})");
println!(" scope: {scope}");
println!(" iterations: {max_iters} max");
println!(" budget: ${budget_usd} USD");
{
let claim_out = abi_cmd(&abi_bin)
.args([
"claim",
"list",
"--prefix",
"node:",
"--include-stale",
"--json",
])
.output();
if let Ok(o) = claim_out {
if o.status.success() {
let stdout = String::from_utf8_lossy(&o.stdout);
if let Ok(arr) = serde_json::from_str::<serde_json::Value>(stdout.trim()) {
let stale_count = arr
.as_array()
.map(|a| {
a.iter()
.filter(|v| v["status"].as_str() == Some("stale"))
.count()
})
.unwrap_or(0);
if stale_count > 0 {
println!(
"resume: {stale_count} stale node claim(s) from a prior walk \
will be recovered on contact"
);
}
}
}
}
}
let mut queue = MegawalkQueue::new_with_max_units(abi_bin.clone(), project, all, max_units)
.with_mission(mission.clone());
let dispatcher =
MegawalkDispatcher::new(lib_path, env, cwd.clone(), abi_bin.clone(), allow_merge);
if let Some(cap) = parallel_cap {
if cap > 1 {
println!(
"megawalk: --parallel-cap {cap} accepted; execution is SEQUENTIAL \
(collision-conservative default; group-2 serializes regardless of cap)"
);
}
}
if let Some(n) = max_units {
println!("megawalk: --max-units {n} (walk stops after {n} unit(s) closed)");
}
let release_walker_claim = || {
let _ = abi_cmd(&abi_bin)
.args(["claim", "release", &walker_key, "--holder", &walker_holder])
.output();
};
let budget = match LoopBudget::new(max_iters) {
Ok(b) => b,
Err(e) => {
eprintln!("fno-agents loop megawalk: {e}");
release_walker_claim();
return Ok(2);
}
};
let cancel_file = cwd.join(".fno").join(".target-cancelled");
let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();
const PER_UNIT_MAX_DISPATCHES: u64 = 15;
let outcome = match run_loop(
&mut queue,
&dispatcher,
&budget,
&journal,
&cancel,
Some(PER_UNIT_MAX_DISPATCHES),
) {
Ok(o) => o,
Err(e) => {
eprintln!("fno-agents loop megawalk: fatal loop error: {e}");
release_walker_claim();
return Ok(2);
}
};
release_walker_claim();
if let Some(ref key) = termination_key {
if let Err(e) = emit_walk_termination(
&journal,
key,
&outcome.reason,
outcome.iterations_used,
outcome.units.len(),
) {
eprintln!("fno-agents loop megawalk: failed to journal walk termination: {e}");
return Ok(2);
}
}
let exit_code = exit_code_for_reason(&outcome.reason);
println!(
"megawalk: {:?} ({} iterations used, {} units closed)",
outcome.reason,
outcome.iterations_used,
outcome.units.len()
);
for unit_result in &outcome.units {
println!(
" unit {}: {:?} ({:?})",
unit_result.unit_id, unit_result.evidence.reason, unit_result.close
);
}
Ok(exit_code)
}
#[cfg(test)]
mod fresh_tests {
use super::*;
fn git(dir: &std::path::Path, args: &[&str]) -> bool {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn dispatcher_roots_worker_cwd_at_canonical_from_worktree() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let tmp = tempfile::tempdir().unwrap();
let main = tmp.path().join("main");
std::fs::create_dir(&main).unwrap();
assert!(git(&main, &["init", "-q"]));
assert!(git(&main, &["config", "user.email", "t@t"]));
assert!(git(&main, &["config", "user.name", "t"]));
assert!(git(&main, &["commit", "-q", "--allow-empty", "-m", "init"]));
let linked = tmp.path().join("wt");
assert!(git(
&main,
&[
"worktree",
"add",
"-q",
linked.to_str().unwrap(),
"-b",
"feat"
]
));
let d = MegawalkDispatcher::new(
std::path::PathBuf::from("/driver/lib.sh"),
vec![],
linked.clone(),
"fno".to_string(),
false,
);
let want = std::fs::canonicalize(&main).unwrap();
assert_eq!(
d.cwd, want,
"megawalk worker cwd must be rooted at canonical main, not the worktree"
);
}
#[test]
fn dispatcher_keeps_cwd_when_not_a_worktree() {
let tmp = tempfile::tempdir().unwrap();
let d = MegawalkDispatcher::new(
std::path::PathBuf::from("/driver/lib.sh"),
vec![],
tmp.path().to_path_buf(),
"fno".to_string(),
false,
);
assert_eq!(d.cwd, tmp.path());
}
#[test]
fn retry_etxtbsy_passes_success_through_without_retry() {
let mut calls = 0u32;
let r: std::io::Result<u8> = retry_etxtbsy(|| {
calls += 1;
Ok(7)
});
assert_eq!(r.unwrap(), 7);
assert_eq!(calls, 1, "a successful spawn must not retry");
}
#[test]
fn retry_etxtbsy_retries_then_succeeds() {
let mut calls = 0u32;
let r: std::io::Result<u8> = retry_etxtbsy(|| {
calls += 1;
if calls < 3 {
Err(std::io::Error::from_raw_os_error(libc::ETXTBSY))
} else {
Ok(42)
}
});
assert_eq!(r.unwrap(), 42);
assert_eq!(calls, 3, "must retry past transient ETXTBSY");
}
#[test]
fn retry_etxtbsy_does_not_swallow_other_errors() {
let mut calls = 0u32;
let r: std::io::Result<u8> = retry_etxtbsy(|| {
calls += 1;
Err(std::io::Error::from_raw_os_error(libc::ENOENT))
});
assert_eq!(r.unwrap_err().raw_os_error(), Some(libc::ENOENT));
assert_eq!(calls, 1, "a non-ETXTBSY error must not retry");
}
#[test]
fn retry_etxtbsy_gives_up_after_max_retries() {
let mut calls = 0u32;
let r: std::io::Result<u8> = retry_etxtbsy(|| {
calls += 1;
Err(std::io::Error::from_raw_os_error(libc::ETXTBSY))
});
assert_eq!(r.unwrap_err().raw_os_error(), Some(libc::ETXTBSY));
assert_eq!(calls, 6, "1 initial attempt + MAX_RETRIES(5)");
}
}