use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use tokio::process::{Child, Command};
use crate::agents::AgentContext;
pub fn effective_timeout(timeout_secs: Option<u64>, ctx: &AgentContext) -> Duration {
let secs = timeout_secs.unwrap_or_else(|| {
let budget = ctx.phase_budget_remaining_secs;
if budget > 0.0 {
(budget.ceil() as u64).max(1)
} else {
300
}
});
Duration::from_secs(secs)
}
pub fn spawn_child(
kind: &str,
agent_name: &str,
command: &[String],
working_dir: Option<&Path>,
env: &HashMap<String, String>,
extra_env: &[(&str, String)],
) -> Result<Child> {
if command.is_empty() {
bail!("{kind} agent '{agent_name}': command is empty");
}
let mut cmd = Command::new(&command[0]);
cmd.args(&command[1..])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
for (key, value) in env {
cmd.env(key, value);
}
for (key, value) in extra_env {
cmd.env(key, value);
}
cmd.spawn()
.with_context(|| format!("{kind} agent '{agent_name}': failed to spawn {command:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_with_budget(budget: f64) -> AgentContext {
AgentContext {
phase_budget_remaining_secs: budget,
..Default::default()
}
}
#[test]
fn explicit_timeout_wins_over_budget() {
let ctx = ctx_with_budget(999.0);
assert_eq!(effective_timeout(Some(42), &ctx), Duration::from_secs(42));
}
#[test]
fn budget_used_when_no_explicit_timeout() {
let ctx = ctx_with_budget(12.3);
assert_eq!(effective_timeout(None, &ctx), Duration::from_secs(13));
}
#[test]
fn zero_budget_falls_back_to_300s() {
let ctx = ctx_with_budget(0.0);
assert_eq!(effective_timeout(None, &ctx), Duration::from_secs(300));
}
#[test]
fn tiny_positive_budget_floors_at_1s() {
let ctx = ctx_with_budget(0.1);
assert_eq!(effective_timeout(None, &ctx), Duration::from_secs(1));
}
#[test]
fn empty_command_is_rejected() {
let env = HashMap::new();
let err = spawn_child("exec", "broken", &[], None, &env, &[]).unwrap_err();
assert!(err.to_string().contains("command is empty"), "got: {err}");
}
#[tokio::test]
async fn spawns_with_env_and_extra_env() {
let mut env = HashMap::new();
env.insert("BASE_VAR".to_string(), "base".to_string());
let extra = [("EXTRA_VAR", "extra".to_string())];
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
"printf '%s-%s' \"$BASE_VAR\" \"$EXTRA_VAR\"".to_string(),
];
let mut child = spawn_child("exec", "echoer", &command, None, &env, &extra)
.expect("spawn must succeed");
let mut stdout = child.stdout.take().expect("stdout piped");
let mut out = String::new();
tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut out)
.await
.unwrap();
let status = child.wait().await.unwrap();
assert!(status.success());
assert_eq!(out, "base-extra");
}
}