use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use serde_json::json;
use super::contract::{evaluate_contract, OutcomeContract};
use super::native_loop::{LoopOutcome, TurnGenerator};
use super::session::{CancelFlag, CoderEventKind, EventSink};
use super::shell_tool::{tail, WorktreeExecutor};
#[derive(Debug)]
pub enum ForemanFallback {
SingleSessionPreferred,
PlanInvalid(String),
NothingAccepted(String),
IntegrationRejected(String),
}
impl ForemanFallback {
pub fn reason(&self) -> String {
match self {
Self::SingleSessionPreferred => {
"plan prefers a single session (no parallel speedup)".into()
}
Self::PlanInvalid(e) => format!("decomposition invalid: {e}"),
Self::NothingAccepted(e) => format!("no subtask passed the merge gate: {e}"),
Self::IntegrationRejected(e) => format!("union integration rejected: {e}"),
}
}
}
fn union_goal_command(contract: &OutcomeContract) -> Option<Vec<String>> {
let chain: Vec<&str> = contract
.checks
.iter()
.filter(|c| c.expect_exit_zero && c.output_contains.is_none())
.map(|c| c.command.as_str())
.collect();
if chain.is_empty() {
return None;
}
Some(vec!["sh".into(), "-lc".into(), chain.join(" && ")])
}
fn regression_command(worktree: &Path) -> Option<Vec<String>> {
let candidates: [(&str, &str); 3] = [
("Cargo.toml", "cargo check"),
("go.mod", "go build ./..."),
("Package.swift", "swift build"),
];
candidates
.iter()
.find(|(marker, _)| worktree.join(marker).exists())
.map(|(_, cmd)| vec!["sh".into(), "-lc".into(), (*cmd).into()])
}
fn apply_patch(worktree: &Path, subtask_id: &str, patch: &str) -> Result<(), String> {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new()
.map_err(|e| format!("temp patch file for {subtask_id}: {e}"))?;
file.write_all(patch.as_bytes())
.map_err(|e| format!("write patch {subtask_id}: {e}"))?;
let out = std::process::Command::new("git")
.arg("-C")
.arg(worktree)
.args(["apply", "--whitespace=nowarn"])
.arg(file.path())
.output()
.map_err(|e| format!("git apply {subtask_id}: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(format!(
"git apply {subtask_id} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
}
pub async fn run_foreman_loop(
adapter_id: &str,
intent: &str,
contract: &OutcomeContract,
executor: &WorktreeExecutor,
sink: &Arc<EventSink>,
cancel: &CancelFlag,
generator: &Arc<dyn TurnGenerator>,
mcp_endpoint: Option<&str>,
) -> Result<LoopOutcome, ForemanFallback> {
let worktree = executor.worktree().to_path_buf();
let cancelled = || LoopOutcome {
passed: false,
iterations: 0,
last_results: Vec::new(),
error: Some("cancelled".into()),
};
if cancel.load(Ordering::SeqCst) {
return Ok(cancelled());
}
sink.emit(CoderEventKind::ExternalEvent {
raw: json!({ "foreman": "planning", "adapter": adapter_id }),
});
let plan_generator = generator.clone();
let plan = car_multi::decompose(&worktree, intent, 3, move |prompt| {
let generator = plan_generator.clone();
async move {
generator
.generate(car_inference::GenerateRequest {
prompt,
..Default::default()
})
.await
.map(|r| r.text)
}
})
.await;
if !plan.is_valid() {
return Err(ForemanFallback::PlanInvalid(plan.issues.join("; ")));
}
sink.emit(CoderEventKind::ExternalEvent {
raw: json!({
"foreman": "planned",
"subtasks": plan.subtasks.len(),
"levels": plan.levels.len(),
"prefer_single_session": plan.prefer_single_session,
}),
});
if plan.prefer_single_session {
return Err(ForemanFallback::SingleSessionPreferred);
}
if cancel.load(Ordering::SeqCst) {
return Ok(cancelled());
}
let agent = car_external_agents::ForemanExternalAgent::new(adapter_id.to_string());
let infra = car_multi::SharedInfra::new();
let config = car_multi::FarmOutConfig {
verify_command: regression_command(&worktree),
union_verify_command: union_goal_command(contract),
mcp_endpoint: mcp_endpoint.map(String::from),
..Default::default()
};
let progress_sink: car_multi::ForemanProgressSink = {
let sink = Arc::clone(sink);
Arc::new(move |ev: car_multi::ForemanProgress| {
let raw = match ev {
car_multi::ForemanProgress::SubtaskStarted {
subtask_id,
index,
level,
total,
} => json!({
"foreman": "subtask_started",
"subtask_id": subtask_id,
"index": index,
"level": level,
"total": total,
}),
car_multi::ForemanProgress::SubtaskVerifying { subtask_id } => json!({
"foreman": "subtask_verifying",
"subtask_id": subtask_id,
}),
car_multi::ForemanProgress::SubtaskGated {
subtask_id,
accepted,
status,
} => json!({
"foreman": "subtask_gated",
"subtask_id": subtask_id,
"accepted": accepted,
"status": status,
}),
};
sink.emit(CoderEventKind::ExternalEvent { raw });
})
};
let farmed = car_multi::run_farm_out_with_progress(
&worktree,
&plan.subtasks,
&agent,
&config,
&infra,
progress_sink,
)
.await;
let accepted: Vec<(String, String)> = farmed
.outcomes
.iter()
.filter(|o| o.is_accepted())
.filter_map(|o| o.patch.clone().map(|p| (o.subtask_id.clone(), p)))
.collect();
sink.emit(CoderEventKind::ExternalEvent {
raw: json!({
"foreman": "farmed",
"accepted": accepted.len(),
"total": farmed.outcomes.len(),
}),
});
if accepted.is_empty() {
let detail = farmed
.outcomes
.iter()
.filter_map(|o| o.error.as_deref())
.take(3)
.collect::<Vec<_>>()
.join("; ");
return Err(ForemanFallback::NothingAccepted(if detail.is_empty() {
format!("{} subtask(s) all rejected or inconclusive", farmed.outcomes.len())
} else {
detail
}));
}
if cancel.load(Ordering::SeqCst) {
return Ok(cancelled());
}
let label = format!("coder-{}", sink_label(&worktree));
let integration =
car_multi::integrate_and_verify(&worktree, &label, &accepted, &config, &infra)
.await
.map_err(|e| ForemanFallback::IntegrationRejected(e.to_string()))?;
if !integration.integrated_cleanly() {
if let Some(blame) = &integration.blame {
let reason = if !blame.apply_conflicts.is_empty() {
"patch conflict"
} else if !blame.duplicate_conflicts.is_empty() {
"duplicate declaration"
} else if blame.build_test.is_some() {
"build/test failed"
} else {
"rejected"
};
let detail = blame
.apply_conflicts
.first()
.map(|c| format!("{} did not apply", c.subtask_id))
.or_else(|| {
blame
.duplicate_conflicts
.first()
.map(|d| format!("duplicate `{}` in {}", d.symbol, d.file))
})
.or_else(|| blame.build_test.as_ref().map(|b| tail(&b.output_tail, 200)));
let implicated: Vec<String> = blame.implicated_subtasks().into_iter().collect();
sink.emit(CoderEventKind::ExternalEvent {
raw: json!({
"foreman": "union_rejected",
"reason": reason,
"implicated": implicated,
"detail": detail,
}),
});
}
return Err(ForemanFallback::IntegrationRejected(format!(
"applied {}, conflicts: [{}], union verdict accepting: {}",
integration.applied,
integration.apply_conflicts.join(", "),
integration
.verdict
.as_ref()
.is_some_and(|v| v.is_accepted()),
)));
}
sink.emit(CoderEventKind::ExternalEvent {
raw: json!({ "foreman": "union_verified", "applied": integration.applied }),
});
for (subtask_id, patch) in &accepted {
apply_patch(&worktree, subtask_id, patch)
.map_err(ForemanFallback::IntegrationRejected)?;
sink.emit(CoderEventKind::ToolResult {
tool: "foreman.apply".into(),
ok: true,
preview: format!("applied {subtask_id}"),
});
}
let last_results = evaluate_contract(contract, executor, sink).await;
let passed = last_results.iter().all(|r| r.passed);
Ok(LoopOutcome {
passed,
iterations: 1,
last_results,
error: None,
})
}
fn sink_label(worktree: &Path) -> String {
worktree
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "session".into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coder::contract::ContractCheck;
fn check(name: &str, command: &str, exit_zero: bool, contains: Option<&str>) -> ContractCheck {
ContractCheck {
name: name.into(),
command: command.into(),
expect_exit_zero: exit_zero,
output_contains: contains.map(String::from),
timeout_secs: 60,
}
}
#[test]
fn union_goal_chains_plain_exit_zero_checks_only() {
let contract = OutcomeContract {
description: "d".into(),
checks: vec![
check("build", "cargo build", true, None),
check("tests", "cargo test", true, None),
check("output", "cat x.txt", true, Some("needle")), check("inverted", "grep -q bad src/", false, Some("x")), ],
};
let cmd = union_goal_command(&contract).unwrap();
assert_eq!(cmd[0], "sh");
assert_eq!(cmd[2], "cargo build && cargo test");
}
#[test]
fn no_expressible_checks_means_no_union_goal_command() {
let contract = OutcomeContract {
description: "d".into(),
checks: vec![check("output", "cat x.txt", true, Some("needle"))],
};
assert!(union_goal_command(&contract).is_none());
}
#[test]
fn regression_command_maps_known_build_systems_only() {
let dir = tempfile::tempdir().unwrap();
assert!(regression_command(dir.path()).is_none(), "unknown repo → None (fail-closed)");
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let cmd = regression_command(dir.path()).unwrap();
assert_eq!(cmd[2], "cargo check");
}
#[test]
fn apply_patch_lands_changes_in_worktree() {
let dir = tempfile::tempdir().unwrap();
for args in [
vec!["init", "-q", "-b", "main"],
vec!["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"],
] {
assert!(std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(&args)
.output()
.unwrap()
.status
.success());
}
let patch = "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1 @@\n+from foreman\n";
apply_patch(dir.path(), "s1", patch).unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("new.txt")).unwrap(),
"from foreman\n"
);
}
#[test]
fn apply_patch_conflict_is_reported_not_panicked() {
let dir = tempfile::tempdir().unwrap();
assert!(std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["init", "-q"])
.output()
.unwrap()
.status
.success());
let err = apply_patch(dir.path(), "s1", "not a patch").unwrap_err();
assert!(err.contains("git apply s1 failed"), "{err}");
}
#[test]
fn fallback_reasons_are_descriptive() {
assert!(ForemanFallback::SingleSessionPreferred.reason().contains("single session"));
assert!(ForemanFallback::PlanInvalid("x".into()).reason().contains("decomposition"));
assert!(ForemanFallback::NothingAccepted("y".into()).reason().contains("merge gate"));
assert!(ForemanFallback::IntegrationRejected("z".into()).reason().contains("integration"));
}
}