1use std::collections::{BTreeSet, HashSet};
24use std::io::Write;
25use std::path::{Path, PathBuf};
26use std::process::{Command, Stdio};
27use std::sync::Arc;
28
29use async_trait::async_trait;
30
31use super::gate::{
32 verify_changes, BuildTestStatus, DeclaredFootprint, FileChange, GateConfig, MergeVerdict,
33 NoVerifyWaiver,
34};
35use crate::shared::SharedInfra;
36use crate::workspace::{AgentWorkspace, WorkspaceConfig};
37
38#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ForemanProgress {
46 SubtaskStarted {
51 subtask_id: String,
52 index: usize,
53 level: usize,
54 total: usize,
55 },
56 SubtaskVerifying { subtask_id: String },
61 SubtaskGated {
66 subtask_id: String,
67 accepted: bool,
68 status: String,
69 },
70}
71
72pub type ForemanProgressSink = Arc<dyn Fn(ForemanProgress) + Send + Sync>;
75
76fn verdict_status(verdict: &MergeVerdict) -> &'static str {
79 match verdict {
80 MergeVerdict::Accepted { .. } => "accepted",
81 MergeVerdict::Rejected { .. } => "rejected",
82 MergeVerdict::Inconclusive { .. } => "inconclusive",
83 }
84}
85
86#[inline]
87fn emit(sink: &Option<ForemanProgressSink>, event: ForemanProgress) {
88 if let Some(sink) = sink {
89 sink(event);
90 }
91}
92
93#[derive(Debug, thiserror::Error)]
95pub enum ForemanError {
96 #[error("workspace provisioning failed: {0}")]
97 Workspace(String),
98 #[error("agent execution failed: {0}")]
99 Agent(String),
100 #[error("worker cannot serve this run: {0}")]
111 Worker(String),
112 #[error("git error: {0}")]
113 Git(String),
114}
115
116#[derive(Debug, Clone)]
120pub struct Subtask {
121 pub id: String,
122 pub prompt: String,
123 pub files: Vec<String>,
126 pub footprint: Option<car_ast::SymbolFootprint>,
133}
134
135impl Subtask {
136 pub fn files_only(
139 id: impl Into<String>,
140 prompt: impl Into<String>,
141 files: Vec<String>,
142 ) -> Self {
143 Self {
144 id: id.into(),
145 prompt: prompt.into(),
146 files,
147 footprint: None,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Default)]
154pub struct AgentRunSummary {
155 pub answer: String,
156}
157
158#[derive(Debug)]
163pub struct WorktreeAgentRequest<'a> {
164 pub subtask: &'a Subtask,
165 pub cwd: &'a Path,
166 pub allowed_tools: Option<Vec<String>>,
167 pub mcp_endpoint: Option<String>,
168}
169
170#[async_trait]
175pub trait WorktreeAgent: Send + Sync {
176 async fn run_in(&self, req: &WorktreeAgentRequest<'_>)
177 -> Result<AgentRunSummary, ForemanError>;
178}
179
180#[derive(Debug, Clone, Default)]
182pub struct FarmOutConfig {
183 pub verify_command: Option<Vec<String>>,
188 pub union_verify_command: Option<Vec<String>>,
194 pub allowed_tools: Option<Vec<String>>,
196 pub mcp_endpoint: Option<String>,
198 pub recover_via_single_session: bool,
205 pub worktree_base: Option<PathBuf>,
213 pub no_verify_waiver: Option<NoVerifyWaiver>,
222}
223
224fn default_worktree_base(repo_root: &Path) -> PathBuf {
230 use std::collections::hash_map::DefaultHasher;
231 use std::hash::{Hash, Hasher};
232 let mut hasher = DefaultHasher::new();
233 repo_root.hash(&mut hasher);
234 std::env::temp_dir()
235 .join("car-foreman-worktrees")
236 .join(format!("{:016x}", hasher.finish()))
237}
238
239fn worktree_workspace_config(repo_root: &Path, config: &FarmOutConfig) -> WorkspaceConfig {
244 let base = config
245 .worktree_base
246 .clone()
247 .unwrap_or_else(|| default_worktree_base(repo_root));
248 WorkspaceConfig::git_worktree_at(repo_root, base)
249}
250
251pub fn partition_by_files(subtasks: &[Subtask]) -> Vec<Vec<usize>> {
260 struct Level {
261 ids: Vec<usize>,
262 claimed: HashSet<String>,
263 open: bool,
265 }
266 let mut levels: Vec<Level> = Vec::new();
267
268 for (i, st) in subtasks.iter().enumerate() {
269 let files: HashSet<String> = st.files.iter().cloned().collect();
270 if files.is_empty() {
271 levels.push(Level {
272 ids: vec![i],
273 claimed: HashSet::new(),
274 open: false,
275 });
276 continue;
277 }
278 match levels
279 .iter_mut()
280 .find(|l| l.open && l.claimed.is_disjoint(&files))
281 {
282 Some(level) => {
283 level.ids.push(i);
284 level.claimed.extend(files);
285 }
286 None => levels.push(Level {
287 ids: vec![i],
288 claimed: files,
289 open: true,
290 }),
291 }
292 }
293 levels.into_iter().map(|l| l.ids).collect()
294}
295
296pub(crate) const FOOTPRINT_BLAST_DEPTH: usize = 3;
299
300fn schedule(repo_root: &Path, subtasks: &[Subtask]) -> Vec<Vec<usize>> {
306 if subtasks.is_empty() || !subtasks.iter().all(|s| s.footprint.is_some()) {
307 return partition_by_files(subtasks);
308 }
309 let mut seen = HashSet::new();
312 if !subtasks.iter().all(|s| seen.insert(s.id.as_str())) {
313 return partition_by_files(subtasks);
314 }
315 let index = car_ast::ProjectIndex::build(repo_root);
316 let fsubs: Vec<car_ast::FootprintSubtask> = subtasks
317 .iter()
318 .map(|s| car_ast::FootprintSubtask {
319 id: s.id.clone(),
320 footprint: car_ast::expand_footprint(
323 &index,
324 s.footprint.as_ref().expect("all footprints present"),
325 FOOTPRINT_BLAST_DEPTH,
326 ),
327 })
328 .collect();
329 let plan = car_ast::analyze(&fsubs);
330 plan.levels
331 .iter()
332 .map(|level| {
333 level
334 .iter()
335 .map(|id| subtasks.iter().position(|s| &s.id == id).unwrap())
336 .collect()
337 })
338 .collect()
339}
340
341#[derive(Debug)]
347pub struct SubtaskOutcome {
348 pub subtask_id: String,
349 pub verdict: Option<MergeVerdict>,
350 pub changes: Vec<FileChange>,
351 pub patch: Option<String>,
352 pub error: Option<String>,
353}
354
355impl SubtaskOutcome {
356 pub fn is_accepted(&self) -> bool {
357 self.verdict.as_ref().is_some_and(|v| v.is_accepted())
358 }
359}
360
361#[derive(Debug)]
363pub struct FarmOutResult {
364 pub levels: Vec<Vec<usize>>,
366 pub outcomes: Vec<SubtaskOutcome>,
367}
368
369impl FarmOutResult {
370 pub fn accepted_count(&self) -> usize {
371 self.outcomes.iter().filter(|o| o.is_accepted()).count()
372 }
373}
374
375pub async fn run_farm_out(
381 repo_root: &Path,
382 subtasks: &[Subtask],
383 agent: &dyn WorktreeAgent,
384 config: &FarmOutConfig,
385 infra: &SharedInfra,
386) -> FarmOutResult {
387 run_farm_out_inner(repo_root, subtasks, agent, config, infra, None).await
388}
389
390pub async fn run_farm_out_with_progress(
395 repo_root: &Path,
396 subtasks: &[Subtask],
397 agent: &dyn WorktreeAgent,
398 config: &FarmOutConfig,
399 infra: &SharedInfra,
400 progress: ForemanProgressSink,
401) -> FarmOutResult {
402 run_farm_out_inner(repo_root, subtasks, agent, config, infra, Some(progress)).await
403}
404
405async fn run_farm_out_inner(
406 repo_root: &Path,
407 subtasks: &[Subtask],
408 agent: &dyn WorktreeAgent,
409 config: &FarmOutConfig,
410 infra: &SharedInfra,
411 progress: Option<ForemanProgressSink>,
412) -> FarmOutResult {
413 let levels = schedule(repo_root, subtasks);
414 let total = subtasks.len();
415 let mut outcomes = Vec::with_capacity(subtasks.len());
416
417 for (level_idx, level) in levels.iter().enumerate() {
418 let level_futs = level.iter().map(|&i| {
419 run_one_subtask(
420 repo_root,
421 i,
422 &subtasks[i],
423 agent,
424 config,
425 infra,
426 level_idx,
427 total,
428 progress.as_ref(),
429 )
430 });
431 outcomes.extend(futures::future::join_all(level_futs).await);
432 }
433
434 FarmOutResult { levels, outcomes }
435}
436
437#[allow(clippy::too_many_arguments)]
438async fn run_one_subtask(
439 repo_root: &Path,
440 index: usize,
441 subtask: &Subtask,
442 agent: &dyn WorktreeAgent,
443 config: &FarmOutConfig,
444 infra: &SharedInfra,
445 level: usize,
446 total: usize,
447 progress: Option<&ForemanProgressSink>,
448) -> SubtaskOutcome {
449 let progress = progress.cloned();
452 emit(
453 &progress,
454 ForemanProgress::SubtaskStarted {
455 subtask_id: subtask.id.clone(),
456 index,
457 level,
458 total,
459 },
460 );
461 let fail = |error: ForemanError| {
462 emit(
463 &progress,
464 ForemanProgress::SubtaskGated {
465 subtask_id: subtask.id.clone(),
466 accepted: false,
467 status: "error".into(),
468 },
469 );
470 SubtaskOutcome {
471 subtask_id: subtask.id.clone(),
472 verdict: None,
473 changes: Vec::new(),
474 patch: None,
475 error: Some(error.to_string()),
476 }
477 };
478
479 let ws_name = format!("{index:04}-{}", subtask.id);
482 let workspace =
483 match AgentWorkspace::provision(&worktree_workspace_config(repo_root, config), &ws_name) {
484 Ok(ws) => ws,
485 Err(e) => return fail(ForemanError::Workspace(e)),
486 };
487 let cwd = workspace.path().to_path_buf();
488
489 let req = WorktreeAgentRequest {
490 subtask,
491 cwd: &cwd,
492 allowed_tools: config.allowed_tools.clone(),
493 mcp_endpoint: config.mcp_endpoint.clone(),
494 };
495 if let Err(e) = agent.run_in(&req).await {
496 return fail(e);
497 }
498
499 let cwd_for_blocking = cwd.clone();
500 let (changes, patch) = match tokio::task::spawn_blocking(move || {
501 let changes = collect_file_changes(&cwd_for_blocking)?;
502 let patch = capture_patch(&cwd_for_blocking)?;
503 Ok::<_, ForemanError>((changes, patch))
504 })
505 .await
506 {
507 Ok(Ok(v)) => v,
508 Ok(Err(e)) => return fail(e),
509 Err(e) => return fail(ForemanError::Git(format!("collect task panicked: {e}"))),
510 };
511
512 emit(
513 &progress,
514 ForemanProgress::SubtaskVerifying {
515 subtask_id: subtask.id.clone(),
516 },
517 );
518 let mut gate_config = GateConfig::new(subtask.id.clone(), &cwd);
519 gate_config.verify_command = config.verify_command.clone();
520 gate_config.no_verify_waiver = config.no_verify_waiver.clone();
521 let footprint = match &subtask.footprint {
524 Some(fp) => DeclaredFootprint::from_refs(fp.writes.iter().cloned()),
525 None => DeclaredFootprint::unconstrained(),
526 };
527 let verdict = verify_changes(&gate_config, &changes, &footprint, infra).await;
528 emit(
529 &progress,
530 ForemanProgress::SubtaskGated {
531 subtask_id: subtask.id.clone(),
532 accepted: verdict.is_accepted(),
533 status: verdict_status(&verdict).into(),
534 },
535 );
536
537 SubtaskOutcome {
538 subtask_id: subtask.id.clone(),
539 verdict: Some(verdict),
540 changes,
541 patch: Some(patch),
542 error: None,
543 }
544}
545
546#[derive(Debug)]
548pub struct IntegrationResult {
549 pub applied: usize,
550 pub apply_conflicts: Vec<String>,
556 pub verdict: Option<MergeVerdict>,
559 pub blame: Option<IntegrationBlame>,
562}
563
564impl IntegrationResult {
565 pub fn integrated_cleanly(&self) -> bool {
568 self.apply_conflicts.is_empty() && self.verdict.as_ref().is_some_and(|v| v.is_accepted())
569 }
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct ApplyConflict {
576 pub subtask_id: String,
577 pub files: Vec<String>,
578 pub detail: String,
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
587pub struct DuplicateBlame {
588 pub file: String,
589 pub symbol: String,
590 pub candidate_subtask_ids: Vec<String>,
591}
592
593#[derive(Debug, Clone, PartialEq, Eq)]
600pub struct BuildTestFailure {
601 pub code: Option<i32>,
602 pub output_tail: String,
603 pub candidate_subtask_ids: Vec<String>,
604}
605
606#[derive(Debug, Clone, Default, PartialEq, Eq)]
618pub struct IntegrationBlame {
619 pub apply_conflicts: Vec<ApplyConflict>,
621 pub duplicate_conflicts: Vec<DuplicateBlame>,
623 pub build_test: Option<BuildTestFailure>,
625}
626
627impl IntegrationBlame {
628 pub fn is_empty(&self) -> bool {
629 self.apply_conflicts.is_empty()
630 && self.duplicate_conflicts.is_empty()
631 && self.build_test.is_none()
632 }
633
634 pub fn implicated_subtasks(&self) -> BTreeSet<String> {
642 let mut ids = BTreeSet::new();
643 for c in &self.apply_conflicts {
644 ids.insert(c.subtask_id.clone());
645 }
646 for d in &self.duplicate_conflicts {
647 ids.extend(d.candidate_subtask_ids.iter().cloned());
648 }
649 if let Some(bt) = &self.build_test {
650 ids.extend(bt.candidate_subtask_ids.iter().cloned());
651 }
652 ids
653 }
654}
655
656fn localize_build_failure(
664 output: &str,
665 file_to_subtasks: &std::collections::HashMap<String, Vec<String>>,
666) -> Vec<String> {
667 let mut ids = BTreeSet::new();
668 for (file, subtasks) in file_to_subtasks {
669 if output.contains(file.as_str()) {
675 ids.extend(subtasks.iter().cloned());
676 }
677 }
678 ids.into_iter().collect()
679}
680
681pub fn files_in_patch(patch: &str) -> Vec<String> {
693 let mut files = Vec::new();
694 for line in patch.lines() {
695 if let Some(rest) = line.strip_prefix("diff --git ") {
696 if let Some(pos) = rest.rfind(" b/") {
697 let file = &rest[pos + 3..];
698 if !file.is_empty() && !files.iter().any(|f| f == file) {
699 files.push(file.to_string());
700 }
701 }
702 }
703 }
704 files
705}
706
707pub async fn integrate_and_verify(
715 repo_root: &Path,
716 subtask_label: &str,
717 accepted_patches: &[(String, String)], config: &FarmOutConfig,
719 infra: &SharedInfra,
720) -> Result<IntegrationResult, ForemanError> {
721 let staging = AgentWorkspace::provision(
722 &worktree_workspace_config(repo_root, config),
723 &format!("integrate-{subtask_label}"),
724 )
725 .map_err(ForemanError::Workspace)?;
726 let staging_path = staging.path().to_path_buf();
727 let patches: Vec<(String, String)> = accepted_patches.to_vec();
728 let patch_count = patches.len();
729 let verify_command = config
732 .union_verify_command
733 .clone()
734 .or_else(|| config.verify_command.clone());
735 let label = subtask_label.to_string();
736
737 let mut file_to_subtasks: std::collections::HashMap<String, Vec<String>> =
743 std::collections::HashMap::new();
744 let mut union_members: Vec<String> = Vec::new();
745 for (id, patch) in &patches {
746 if !union_members.contains(id) {
747 union_members.push(id.clone());
748 }
749 for file in files_in_patch(patch) {
750 file_to_subtasks.entry(file).or_default().push(id.clone());
751 }
752 }
753
754 let staging_for_blocking = staging_path.clone();
756 let (conflicts, changes) = tokio::task::spawn_blocking(move || {
757 let mut conflicts: Vec<ApplyConflict> = Vec::new();
758 for (id, patch) in &patches {
759 if patch.trim().is_empty() {
760 continue;
761 }
762 if let Err(e) = git_apply(&staging_for_blocking, patch) {
763 conflicts.push(ApplyConflict {
764 subtask_id: id.clone(),
765 files: files_in_patch(patch),
766 detail: e.to_string(),
767 });
768 }
769 }
770 let changes = collect_file_changes(&staging_for_blocking)?;
771 Ok::<_, ForemanError>((conflicts, changes))
772 })
773 .await
774 .map_err(|e| ForemanError::Git(format!("integrate task panicked: {e}")))??;
775
776 if !conflicts.is_empty() {
779 let apply_conflicts: Vec<String> = conflicts
780 .iter()
781 .map(|c| format!("{}: {}", c.subtask_id, c.detail))
782 .collect();
783 return Ok(IntegrationResult {
784 applied: patch_count - conflicts.len(),
785 apply_conflicts,
786 verdict: None,
787 blame: Some(IntegrationBlame {
788 apply_conflicts: conflicts,
789 ..Default::default()
790 }),
791 });
792 }
793
794 let mut gate_config = GateConfig::new(format!("union:{label}"), &staging_path);
795 gate_config.verify_command = verify_command;
796 gate_config.no_verify_waiver = config.no_verify_waiver.clone();
797 let verdict = verify_changes(
798 &gate_config,
799 &changes,
800 &DeclaredFootprint::unconstrained(),
801 infra,
802 )
803 .await;
804
805 let blame = if verdict.is_accepted() {
809 None
810 } else {
811 let ev = verdict.evidence();
812 let duplicate_conflicts = ev
813 .semantic_conflicts
814 .iter()
815 .map(|d| DuplicateBlame {
816 file: d.file.clone(),
817 symbol: d.symbol.clone(),
818 candidate_subtask_ids: file_to_subtasks.get(&d.file).cloned().unwrap_or_default(),
819 })
820 .collect();
821 let build_test = match &ev.build_test {
822 BuildTestStatus::Failed { code, output } => {
823 let localized = localize_build_failure(output, &file_to_subtasks);
828 let candidate_subtask_ids = if localized.is_empty() {
829 union_members.clone()
830 } else {
831 localized
832 };
833 Some(BuildTestFailure {
834 code: *code,
835 output_tail: output.clone(), candidate_subtask_ids,
837 })
838 }
839 _ => None,
840 };
841 Some(IntegrationBlame {
842 apply_conflicts: Vec::new(),
843 duplicate_conflicts,
844 build_test,
845 })
846 };
847
848 Ok(IntegrationResult {
849 applied: patch_count,
850 apply_conflicts: Vec::new(),
851 verdict: Some(verdict),
852 blame,
853 })
854}
855
856pub async fn regional_replan(
873 repo_root: &Path,
874 goal: &str,
875 clean_patches: &[(String, String)],
876 agent: &dyn WorktreeAgent,
877 config: &FarmOutConfig,
878 infra: &SharedInfra,
879) -> Option<SubtaskOutcome> {
880 let workspace = AgentWorkspace::provision(
881 &worktree_workspace_config(repo_root, config),
882 "regional-replan",
883 )
884 .ok()?;
885 let cwd = workspace.path().to_path_buf();
886
887 let cwd_for_apply = cwd.clone();
891 let clean = clean_patches.to_vec();
892 let staged = tokio::task::spawn_blocking(move || {
893 for (_id, patch) in &clean {
894 if patch.trim().is_empty() {
895 continue;
896 }
897 if git_apply(&cwd_for_apply, patch).is_err() {
898 return false;
899 }
900 }
901 true
902 })
903 .await
904 .ok()?;
905 if !staged {
906 return None;
907 }
908
909 let subtask = Subtask::files_only("__regional_replan__", goal.to_string(), Vec::new());
913 let req = WorktreeAgentRequest {
914 subtask: &subtask,
915 cwd: &cwd,
916 allowed_tools: config.allowed_tools.clone(),
917 mcp_endpoint: config.mcp_endpoint.clone(),
918 };
919 if agent.run_in(&req).await.is_err() {
920 return None;
921 }
922
923 let cwd_for_blocking = cwd.clone();
924 let (changes, patch) = tokio::task::spawn_blocking(move || {
925 let changes = collect_file_changes(&cwd_for_blocking)?;
926 let patch = capture_patch(&cwd_for_blocking)?;
927 Ok::<_, ForemanError>((changes, patch))
928 })
929 .await
930 .ok()?
931 .ok()?;
932
933 let goal_check = config
936 .union_verify_command
937 .clone()
938 .or_else(|| config.verify_command.clone());
939 let mut gate_config = GateConfig::new("__regional_replan__", &cwd);
940 gate_config.verify_command = goal_check;
941 gate_config.no_verify_waiver = config.no_verify_waiver.clone();
942 let verdict = verify_changes(
943 &gate_config,
944 &changes,
945 &DeclaredFootprint::unconstrained(),
946 infra,
947 )
948 .await;
949
950 Some(SubtaskOutcome {
951 subtask_id: "__regional_replan__".into(),
952 verdict: Some(verdict),
953 changes,
954 patch: Some(patch),
955 error: None,
956 })
957}
958
959fn collect_file_changes(worktree: &Path) -> Result<Vec<FileChange>, ForemanError> {
964 let porcelain = git(
965 worktree,
966 &["status", "--porcelain", "-z", "--untracked-files=all"],
967 )?;
968
969 let mut changes = Vec::new();
970 let mut fields = porcelain.split('\0');
971 while let Some(entry) = fields.next() {
972 if entry.len() < 4 {
974 continue;
975 }
976 let x = entry.as_bytes()[0];
979 let path = entry[3..].to_string();
980
981 if x == b'R' || x == b'C' {
989 if let Some(old) = fields.next() {
990 if !old.is_empty() {
991 changes.push(FileChange {
992 path: old.to_string(),
993 before: read_head(worktree, old),
994 after: None,
995 });
996 }
997 }
998 changes.push(FileChange {
999 path: path.clone(),
1000 before: None,
1001 after: read_worktree(worktree, &path),
1002 });
1003 continue;
1004 }
1005
1006 let before = read_head(worktree, &path);
1007 let after = read_worktree(worktree, &path);
1008 if before.is_none() && after.is_none() {
1009 continue;
1010 }
1011 changes.push(FileChange {
1012 path,
1013 before,
1014 after,
1015 });
1016 }
1017 Ok(changes)
1018}
1019
1020fn read_head(worktree: &Path, path: &str) -> Option<String> {
1023 git(worktree, &["show", &format!("HEAD:./{path}")]).ok()
1024}
1025
1026fn read_worktree(worktree: &Path, path: &str) -> Option<String> {
1029 std::fs::read(worktree.join(path))
1030 .ok()
1031 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1032}
1033
1034pub fn capture_patch(worktree: &Path) -> Result<String, ForemanError> {
1042 git(worktree, &["add", "-AN"])?;
1045 git(
1049 worktree,
1050 &[
1051 "-c",
1052 "core.autocrlf=false",
1053 "diff",
1054 "HEAD",
1055 "--binary",
1056 "--no-textconv",
1057 ],
1058 )
1059}
1060
1061fn git(cwd: &Path, args: &[&str]) -> Result<String, ForemanError> {
1062 let out = Command::new("git")
1063 .args(args)
1064 .current_dir(cwd)
1065 .output()
1066 .map_err(|e| ForemanError::Git(format!("spawn git: {e}")))?;
1067 if !out.status.success() {
1068 return Err(ForemanError::Git(
1069 String::from_utf8_lossy(&out.stderr).trim().to_string(),
1070 ));
1071 }
1072 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
1073}
1074
1075pub fn git_apply(cwd: &Path, patch: &str) -> Result<(), ForemanError> {
1084 let mut child = Command::new("git")
1085 .args(["apply", "--whitespace=nowarn"])
1086 .current_dir(cwd)
1087 .stdin(Stdio::piped())
1088 .stdout(Stdio::piped())
1089 .stderr(Stdio::piped())
1090 .spawn()
1091 .map_err(|e| ForemanError::Git(format!("spawn git apply: {e}")))?;
1092 child
1093 .stdin
1094 .take()
1095 .ok_or_else(|| ForemanError::Git("no stdin for git apply".into()))?
1096 .write_all(patch.as_bytes())
1097 .map_err(|e| ForemanError::Git(format!("write patch: {e}")))?;
1098 let out = child
1099 .wait_with_output()
1100 .map_err(|e| ForemanError::Git(format!("git apply: {e}")))?;
1101 if out.status.success() {
1102 Ok(())
1103 } else {
1104 Err(ForemanError::Git(
1105 String::from_utf8_lossy(&out.stderr).trim().to_string(),
1106 ))
1107 }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::*;
1113
1114 fn st(id: &str, files: &[&str]) -> Subtask {
1115 Subtask::files_only(
1116 id,
1117 format!("do {id}"),
1118 files.iter().map(|s| s.to_string()).collect(),
1119 )
1120 }
1121
1122 #[test]
1125 fn disjoint_files_pack_into_one_level() {
1126 let levels = partition_by_files(&[st("a", &["src/a.rs"]), st("b", &["src/b.rs"])]);
1127 assert_eq!(levels.len(), 1);
1128 assert_eq!(levels[0].len(), 2);
1129 }
1130
1131 #[test]
1132 fn shared_file_forces_separate_levels() {
1133 let levels =
1134 partition_by_files(&[st("a", &["src/shared.rs"]), st("b", &["src/shared.rs"])]);
1135 assert_eq!(levels.len(), 2);
1136 }
1137
1138 #[test]
1139 fn no_files_subtask_gets_its_own_isolated_level() {
1140 let levels =
1142 partition_by_files(&[st("a", &["x.rs"]), st("nofiles", &[]), st("b", &["y.rs"])]);
1143 let nofiles_level = levels.iter().find(|l| l.contains(&1)).unwrap();
1144 assert_eq!(nofiles_level, &vec![1], "no-files subtask is isolated");
1145 }
1146
1147 fn git_ok(cwd: &Path, args: &[&str]) {
1150 let out = Command::new("git")
1151 .args(args)
1152 .current_dir(cwd)
1153 .output()
1154 .unwrap();
1155 assert!(
1156 out.status.success(),
1157 "git {args:?}: {}",
1158 String::from_utf8_lossy(&out.stderr)
1159 );
1160 }
1161
1162 fn init_repo() -> tempfile::TempDir {
1163 let dir = tempfile::tempdir().unwrap();
1164 let root = dir.path();
1165 git_ok(root, &["init", "-q", "-b", "main"]);
1166 git_ok(root, &["config", "user.email", "t@t.t"]);
1167 git_ok(root, &["config", "user.name", "t"]);
1168 std::fs::create_dir_all(root.join("src")).unwrap();
1169 std::fs::write(root.join("src/lib.rs"), "pub fn original() {}\n").unwrap();
1170 git_ok(root, &["add", "-A"]);
1171 git_ok(root, &["commit", "-q", "-m", "init"]);
1172 dir
1173 }
1174
1175 #[test]
1176 fn collect_changes_handles_rename_with_spaces() {
1177 let repo = init_repo();
1178 let root = repo.path();
1179 std::fs::write(root.join("old name.rs"), "pub fn moved() {}\n").unwrap();
1180 git_ok(root, &["add", "-A"]);
1181 git_ok(root, &["commit", "-q", "-m", "add"]);
1182 git_ok(root, &["mv", "old name.rs", "new name.rs"]);
1184
1185 let changes = collect_file_changes(root).unwrap();
1186 let paths: Vec<_> = changes.iter().map(|c| c.path.as_str()).collect();
1187 assert!(
1188 paths.contains(&"old name.rs"),
1189 "rename deletion side present: {paths:?}"
1190 );
1191 assert!(
1192 paths.contains(&"new name.rs"),
1193 "rename addition side present: {paths:?}"
1194 );
1195 let old = changes.iter().find(|c| c.path == "old name.rs").unwrap();
1196 assert!(
1197 old.before.is_some() && old.after.is_none(),
1198 "old path is a deletion"
1199 );
1200 }
1201
1202 struct WriteAgent {
1203 path: String,
1204 content: String,
1205 }
1206
1207 #[async_trait]
1208 impl WorktreeAgent for WriteAgent {
1209 async fn run_in(
1210 &self,
1211 req: &WorktreeAgentRequest<'_>,
1212 ) -> Result<AgentRunSummary, ForemanError> {
1213 let target = req.cwd.join(&self.path);
1214 if let Some(parent) = target.parent() {
1215 std::fs::create_dir_all(parent).ok();
1216 }
1217 std::fs::write(target, &self.content)
1218 .map_err(|e| ForemanError::Agent(e.to_string()))?;
1219 Ok(AgentRunSummary::default())
1220 }
1221 }
1222
1223 fn cfg(verify: &[&str]) -> FarmOutConfig {
1224 let cmd = match verify {
1228 ["true"] => crate::patterns::foreman::test_verify::pass(),
1229 ["false"] => crate::patterns::foreman::test_verify::fail(),
1230 other => other.iter().map(|s| s.to_string()).collect(),
1231 };
1232 FarmOutConfig {
1233 verify_command: Some(cmd),
1234 ..Default::default()
1235 }
1236 }
1237
1238 #[derive(Clone, Default)]
1241 struct SawUpstream(std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, String>>>);
1242
1243 #[async_trait]
1244 impl WorktreeAgent for SawUpstream {
1245 async fn run_in(
1246 &self,
1247 req: &WorktreeAgentRequest<'_>,
1248 ) -> Result<AgentRunSummary, ForemanError> {
1249 let seen = std::fs::read_to_string(req.cwd.join("src/upstream.rs")).unwrap_or_default();
1250 self.0.lock().unwrap().insert(req.subtask.id.clone(), seen);
1251 let own = if req.subtask.id == "upstream" {
1252 ("src/upstream.rs", "pub fn provided() -> u32 { 42 }\n")
1253 } else {
1254 ("src/downstream.rs", "pub fn consumes() -> u32 { 0 }\n")
1255 };
1256 std::fs::write(req.cwd.join(own.0), own.1)
1257 .map_err(|e| ForemanError::Agent(e.to_string()))?;
1258 Ok(AgentRunSummary::default())
1259 }
1260 }
1261
1262 #[tokio::test]
1296 async fn a_dependent_subtask_does_not_see_upstream_work_and_must_not() {
1297 let repo = init_repo();
1298 let root = repo.path();
1299 std::fs::write(
1300 root.join("src/upstream.rs"),
1301 "pub fn provided() -> u32 { 0 }\n",
1302 )
1303 .unwrap();
1304 std::fs::write(
1305 root.join("src/downstream.rs"),
1306 "pub fn consumes() -> u32 { 0 }\n",
1307 )
1308 .unwrap();
1309 git_ok(root, &["add", "-A"]);
1310 git_ok(root, &["commit", "-q", "-m", "seed"]);
1311
1312 let upstream = Subtask {
1313 id: "upstream".into(),
1314 prompt: "implement provided".into(),
1315 files: vec!["src/upstream.rs".into()],
1316 footprint: Some(car_ast::SymbolFootprint::writing([
1317 car_ast::SymbolRef::new("src/upstream.rs", "provided"),
1318 ])),
1319 };
1320 let downstream = Subtask {
1321 id: "downstream".into(),
1322 prompt: "implement consumes using provided".into(),
1323 files: vec!["src/downstream.rs".into()],
1324 footprint: Some(car_ast::SymbolFootprint {
1325 writes: [car_ast::SymbolRef::new("src/downstream.rs", "consumes")]
1326 .into_iter()
1327 .collect(),
1328 reads: [car_ast::SymbolRef::new("src/upstream.rs", "provided")]
1329 .into_iter()
1330 .collect(),
1331 uncertain: false,
1332 }),
1333 };
1334
1335 let agent = SawUpstream::default();
1336 let infra = SharedInfra::new();
1337 let result = run_farm_out(
1338 root,
1339 &[upstream, downstream],
1340 &agent,
1341 &cfg(&["true"]),
1342 &infra,
1343 )
1344 .await;
1345
1346 assert_eq!(
1349 result.levels.len(),
1350 2,
1351 "a declared read must place the dependent subtask in a later level"
1352 );
1353 let seen = agent.0.lock().unwrap().clone();
1354 assert!(
1355 seen["downstream"].contains("{ 0 }"),
1356 "downstream must see BASE content: patches have to stay independent \
1357 diffs from one base or the union gate cannot detect conflicts. Got {:?}",
1358 seen["downstream"]
1359 );
1360 }
1361
1362 #[tokio::test]
1363 async fn clean_edit_is_verified_through_harness() {
1364 let repo = init_repo();
1365 let agent = WriteAgent {
1366 path: "src/lib.rs".into(),
1367 content: "pub fn original() {}\npub fn added() {}\n".into(),
1368 };
1369 let infra = SharedInfra::new();
1370 let result = run_farm_out(
1371 repo.path(),
1372 &[st("edit", &["src/lib.rs"])],
1373 &agent,
1374 &cfg(&["true"]),
1375 &infra,
1376 )
1377 .await;
1378 let o = &result.outcomes[0];
1379 assert!(o.error.is_none(), "{o:?}");
1380 assert!(o.verdict.as_ref().unwrap().is_verified());
1381 assert!(
1382 o.patch.as_ref().unwrap().contains("added"),
1383 "patch retained"
1384 );
1385 }
1386
1387 #[test]
1388 fn default_worktree_base_is_outside_the_repo() {
1389 let repo = init_repo();
1390 let root = repo.path();
1391 let base = default_worktree_base(root);
1392 assert!(
1393 !base.starts_with(root),
1394 "worktree base {base:?} must not be inside repo {root:?}"
1395 );
1396 assert!(base.starts_with(std::env::temp_dir()));
1397 assert_eq!(base, default_worktree_base(root));
1399 let cfg = FarmOutConfig::default();
1401 let _ = worktree_workspace_config(root, &cfg); }
1403
1404 #[tokio::test]
1405 async fn worktrees_are_provisioned_outside_the_repo() {
1406 struct CwdProbe {
1409 seen: Arc<std::sync::Mutex<Option<PathBuf>>>,
1410 }
1411 #[async_trait]
1412 impl WorktreeAgent for CwdProbe {
1413 async fn run_in(
1414 &self,
1415 req: &WorktreeAgentRequest<'_>,
1416 ) -> Result<AgentRunSummary, ForemanError> {
1417 *self.seen.lock().unwrap() = Some(req.cwd.to_path_buf());
1418 std::fs::write(req.cwd.join("src/added.rs"), "pub fn a() {}\n")
1419 .map_err(|e| ForemanError::Agent(e.to_string()))?;
1420 Ok(AgentRunSummary::default())
1421 }
1422 }
1423
1424 let repo = init_repo();
1425 let repo_root = repo.path().canonicalize().unwrap();
1426 let seen = Arc::new(std::sync::Mutex::new(None));
1427 let agent = CwdProbe {
1428 seen: Arc::clone(&seen),
1429 };
1430 let infra = SharedInfra::new();
1431 let _ = run_farm_out(
1432 repo.path(),
1433 &[st("edit", &["src/added.rs"])],
1434 &agent,
1435 &cfg(&["true"]),
1436 &infra,
1437 )
1438 .await;
1439
1440 let cwd = seen.lock().unwrap().clone().expect("agent ran");
1441 let cwd = cwd.canonicalize().unwrap_or(cwd);
1442 assert!(
1443 !cwd.starts_with(&repo_root),
1444 "worktree {cwd:?} must be OUTSIDE repo {repo_root:?}"
1445 );
1446 }
1447
1448 #[tokio::test]
1449 async fn no_verify_waiver_accepts_when_no_build_command() {
1450 let repo = init_repo();
1451 let agent = WriteAgent {
1452 path: "src/added.rs".into(),
1453 content: "pub fn added() {}\n".into(),
1454 };
1455 let infra = SharedInfra::new();
1456
1457 let r1 = run_farm_out(
1460 repo.path(),
1461 &[st("edit", &["src/added.rs"])],
1462 &agent,
1463 &FarmOutConfig::default(),
1464 &infra,
1465 )
1466 .await;
1467 assert!(
1468 !r1.outcomes[0].is_accepted(),
1469 "no command + no waiver must not be accepted: {:?}",
1470 r1.outcomes[0]
1471 );
1472
1473 let waived = FarmOutConfig {
1476 no_verify_waiver: Some(NoVerifyWaiver {
1477 class: "no-build-gate".into(),
1478 reason: "no reliable build command for this project".into(),
1479 }),
1480 ..Default::default()
1481 };
1482 let r2 = run_farm_out(
1483 repo.path(),
1484 &[st("edit", &["src/added.rs"])],
1485 &agent,
1486 &waived,
1487 &infra,
1488 )
1489 .await;
1490 let o = &r2.outcomes[0];
1491 assert!(o.is_accepted(), "waiver must yield acceptance: {o:?}");
1492 assert!(
1493 !o.verdict.as_ref().unwrap().is_verified(),
1494 "waiver-based acceptance is not build-verified"
1495 );
1496 }
1497
1498 #[tokio::test]
1499 async fn progress_streams_started_then_gated_per_subtask() {
1500 let repo = init_repo();
1501 let agent = WriteAgent {
1504 path: "src/added.rs".into(),
1505 content: "pub fn a() {}\n".into(),
1506 };
1507
1508 let events: Arc<std::sync::Mutex<Vec<ForemanProgress>>> =
1509 Arc::new(std::sync::Mutex::new(Vec::new()));
1510 let sink: ForemanProgressSink = {
1511 let events = Arc::clone(&events);
1512 Arc::new(move |ev| events.lock().unwrap().push(ev))
1513 };
1514
1515 let infra = SharedInfra::new();
1516 let result = run_farm_out_with_progress(
1517 repo.path(),
1518 &[st("only", &["src/added.rs"])],
1519 &agent,
1520 &cfg(&["true"]),
1521 &infra,
1522 sink,
1523 )
1524 .await;
1525 assert!(result.outcomes[0].is_accepted());
1526
1527 let events = events.lock().unwrap();
1528 assert_eq!(events.len(), 3, "started + verifying + gated: {events:?}");
1529 assert!(
1530 matches!(
1531 &events[0],
1532 ForemanProgress::SubtaskStarted { subtask_id, index: 0, level: 0, total: 1 } if subtask_id == "only"
1533 ),
1534 "first event is started: {:?}",
1535 events[0]
1536 );
1537 assert!(
1538 matches!(
1539 &events[1],
1540 ForemanProgress::SubtaskVerifying { subtask_id } if subtask_id == "only"
1541 ),
1542 "second event is verifying: {:?}",
1543 events[1]
1544 );
1545 assert!(
1546 matches!(
1547 &events[2],
1548 ForemanProgress::SubtaskGated { subtask_id, accepted: true, status } if subtask_id == "only" && status == "accepted"
1549 ),
1550 "third event is an accepted gate: {:?}",
1551 events[2]
1552 );
1553 }
1554
1555 #[tokio::test]
1556 async fn progress_reports_error_status_when_agent_fails() {
1557 let repo = init_repo();
1558 struct FailAgent;
1560 #[async_trait]
1561 impl WorktreeAgent for FailAgent {
1562 async fn run_in(
1563 &self,
1564 _: &WorktreeAgentRequest<'_>,
1565 ) -> Result<AgentRunSummary, ForemanError> {
1566 Err(ForemanError::Agent("boom".into()))
1567 }
1568 }
1569 let events: Arc<std::sync::Mutex<Vec<ForemanProgress>>> =
1570 Arc::new(std::sync::Mutex::new(Vec::new()));
1571 let sink: ForemanProgressSink = {
1572 let events = Arc::clone(&events);
1573 Arc::new(move |ev| events.lock().unwrap().push(ev))
1574 };
1575 let infra = SharedInfra::new();
1576 let _ = run_farm_out_with_progress(
1577 repo.path(),
1578 &[st("boom", &["src/x.rs"])],
1579 &FailAgent,
1580 &cfg(&["true"]),
1581 &infra,
1582 sink,
1583 )
1584 .await;
1585 let events = events.lock().unwrap();
1586 assert_eq!(events.len(), 2, "started + gated(error): {events:?}");
1587 assert!(
1588 matches!(
1589 &events[1],
1590 ForemanProgress::SubtaskGated { accepted: false, status, .. } if status == "error"
1591 ),
1592 "agent failure surfaces as an error gate: {:?}",
1593 events[1]
1594 );
1595 }
1596
1597 #[tokio::test]
1598 async fn declared_footprint_containment_rejects_out_of_scope_edit() {
1599 let repo = init_repo(); std::fs::write(
1604 repo.path().join("src/lib.rs"),
1605 "pub fn foo() {}\npub fn other() {}\n",
1606 )
1607 .unwrap();
1608 git_ok(repo.path(), &["commit", "-qam", "two fns"]);
1609
1610 let mut subtask = Subtask::files_only("a", "edit foo", vec!["src/lib.rs".into()]);
1611 subtask.footprint = Some(car_ast::SymbolFootprint::writing([
1612 car_ast::SymbolRef::new("src/lib.rs", "foo"),
1613 ]));
1614
1615 let agent = WriteAgent {
1617 path: "src/lib.rs".into(),
1618 content: "pub fn foo() -> u8 { 1 }\npub fn other() -> u8 { 2 }\n".into(),
1619 };
1620 let infra = SharedInfra::new();
1621 let result = run_farm_out(repo.path(), &[subtask], &agent, &cfg(&["true"]), &infra).await;
1622 let verdict = result.outcomes[0].verdict.as_ref().unwrap();
1623 assert!(
1624 matches!(verdict, MergeVerdict::Rejected { .. }),
1625 "out-of-footprint edit must be rejected: {verdict:?}"
1626 );
1627 assert!(verdict
1628 .evidence()
1629 .containment_violations
1630 .iter()
1631 .any(|v| v.changed.symbol == "other"));
1632 }
1633
1634 #[tokio::test]
1635 async fn workspace_failure_is_captured_not_propagated() {
1636 let dir = tempfile::tempdir().unwrap(); let agent = WriteAgent {
1638 path: "x".into(),
1639 content: String::new(),
1640 };
1641 let infra = SharedInfra::new();
1642 let result = run_farm_out(
1643 dir.path(),
1644 &[st("x", &["a.rs"])],
1645 &agent,
1646 &cfg(&["true"]),
1647 &infra,
1648 )
1649 .await;
1650 assert!(result.outcomes[0].verdict.is_none());
1651 assert!(result.outcomes[0].error.is_some());
1652 }
1653
1654 #[tokio::test]
1655 async fn union_integration_catches_cross_subtask_duplicate() {
1656 let repo = init_repo(); let infra = SharedInfra::new();
1663
1664 let agent_a = WriteAgent {
1665 path: "src/lib.rs".into(),
1666 content: "pub fn foo() {}\npub fn original() {}\n".into(), };
1668 let agent_b = WriteAgent {
1669 path: "src/lib.rs".into(),
1670 content: "pub fn original() {}\npub fn foo() {}\n".into(), };
1672
1673 let a = run_farm_out(
1674 repo.path(),
1675 &[st("a", &["src/lib.rs"])],
1676 &agent_a,
1677 &cfg(&["true"]),
1678 &infra,
1679 )
1680 .await;
1681 let b = run_farm_out(
1682 repo.path(),
1683 &[st("b", &["src/lib.rs"])],
1684 &agent_b,
1685 &cfg(&["true"]),
1686 &infra,
1687 )
1688 .await;
1689 assert!(
1691 a.outcomes[0].is_accepted(),
1692 "A alone: {:?}",
1693 a.outcomes[0].verdict
1694 );
1695 assert!(
1696 b.outcomes[0].is_accepted(),
1697 "B alone: {:?}",
1698 b.outcomes[0].verdict
1699 );
1700
1701 let patches = vec![
1702 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
1703 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
1704 ];
1705 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
1706 .await
1707 .unwrap();
1708 assert!(
1711 !integ.integrated_cleanly(),
1712 "union of two subtasks both adding foo must be rejected: {integ:?}"
1713 );
1714 }
1715
1716 #[tokio::test]
1717 async fn union_surfaces_overlapping_edit_as_apply_conflict() {
1718 let repo = init_repo();
1723 let infra = SharedInfra::new();
1724 let agent_a = WriteAgent {
1725 path: "src/lib.rs".into(),
1726 content: "pub fn original() -> u8 { 1 }\n".into(),
1727 };
1728 let agent_b = WriteAgent {
1729 path: "src/lib.rs".into(),
1730 content: "pub fn original() -> u16 { 2 }\n".into(),
1731 };
1732 let a = run_farm_out(
1733 repo.path(),
1734 &[st("a", &["src/lib.rs"])],
1735 &agent_a,
1736 &cfg(&["true"]),
1737 &infra,
1738 )
1739 .await;
1740 let b = run_farm_out(
1741 repo.path(),
1742 &[st("b", &["src/lib.rs"])],
1743 &agent_b,
1744 &cfg(&["true"]),
1745 &infra,
1746 )
1747 .await;
1748 let patches = vec![
1749 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
1750 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
1751 ];
1752 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
1753 .await
1754 .unwrap();
1755 assert!(
1756 !integ.apply_conflicts.is_empty(),
1757 "overlapping edit must conflict loudly: {integ:?}"
1758 );
1759 assert!(!integ.integrated_cleanly());
1760 }
1761
1762 #[tokio::test]
1763 async fn union_of_disjoint_subtasks_integrates_cleanly() {
1764 let repo = init_repo();
1765 let infra = SharedInfra::new();
1766 let agent_a = WriteAgent {
1767 path: "a.rs".into(),
1768 content: "pub fn a() {}\n".into(),
1769 };
1770 let agent_b = WriteAgent {
1771 path: "b.rs".into(),
1772 content: "pub fn b() {}\n".into(),
1773 };
1774 let a = run_farm_out(
1775 repo.path(),
1776 &[st("a", &["a.rs"])],
1777 &agent_a,
1778 &cfg(&["true"]),
1779 &infra,
1780 )
1781 .await;
1782 let b = run_farm_out(
1783 repo.path(),
1784 &[st("b", &["b.rs"])],
1785 &agent_b,
1786 &cfg(&["true"]),
1787 &infra,
1788 )
1789 .await;
1790 let patches = vec![
1791 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
1792 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
1793 ];
1794 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
1795 .await
1796 .unwrap();
1797 assert!(
1798 integ.integrated_cleanly(),
1799 "disjoint union integrates: {integ:?}"
1800 );
1801 }
1802
1803 #[tokio::test]
1804 async fn union_uses_union_verify_command_not_worktree_command() {
1805 let repo = init_repo();
1809 let infra = SharedInfra::new();
1810 let agent = WriteAgent {
1811 path: "src/lib.rs".into(),
1812 content: "pub fn original() {}\npub fn added() {}\n".into(),
1813 };
1814 let config = FarmOutConfig {
1815 verify_command: Some(crate::patterns::foreman::test_verify::pass()), union_verify_command: Some(crate::patterns::foreman::test_verify::fail()), ..Default::default()
1818 };
1819 let r = run_farm_out(
1820 repo.path(),
1821 &[st("a", &["src/lib.rs"])],
1822 &agent,
1823 &config,
1824 &infra,
1825 )
1826 .await;
1827 assert!(r.outcomes[0].is_accepted(), "per-worktree (true) accepts");
1828
1829 let patches = vec![("a".to_string(), r.outcomes[0].patch.clone().unwrap())];
1830 let integ = integrate_and_verify(repo.path(), "u", &patches, &config, &infra)
1831 .await
1832 .unwrap();
1833 assert!(
1834 !integ.integrated_cleanly(),
1835 "union must run union_verify_command (false) and reject: {integ:?}"
1836 );
1837 }
1838
1839 #[tokio::test]
1840 async fn regional_replan_resumes_from_clean_and_delivers() {
1841 let repo = init_repo();
1842 let infra = SharedInfra::new();
1843 let keeper = WriteAgent {
1845 path: "keep.rs".into(),
1846 content: "pub fn keep() {}\n".into(),
1847 };
1848 let k = run_farm_out(
1849 repo.path(),
1850 &[st("keep", &["keep.rs"])],
1851 &keeper,
1852 &cfg(&["true"]),
1853 &infra,
1854 )
1855 .await;
1856 let clean = vec![("keep".to_string(), k.outcomes[0].patch.clone().unwrap())];
1857
1858 let agent = WriteAgent {
1861 path: "good.txt".into(),
1862 content: "done".into(),
1863 };
1864 let config = FarmOutConfig {
1865 union_verify_command: Some(crate::patterns::foreman::test_verify::files_exist(&[
1866 "good.txt", "keep.rs",
1867 ])),
1868 ..Default::default()
1869 };
1870 let outcome = regional_replan(repo.path(), "finish it", &clean, &agent, &config, &infra)
1871 .await
1872 .expect("regional ran");
1873 assert!(
1874 outcome.is_accepted(),
1875 "regional delivered clean+region: {outcome:?}"
1876 );
1877 let patch = outcome.patch.unwrap();
1878 assert!(
1879 patch.contains("keep.rs"),
1880 "clean work preserved in result: {patch}"
1881 );
1882 assert!(patch.contains("good.txt"), "region work present: {patch}");
1883 }
1884
1885 #[tokio::test]
1886 async fn regional_replan_bails_when_a_clean_patch_does_not_apply() {
1887 let repo = init_repo();
1888 let infra = SharedInfra::new();
1889 let agent = WriteAgent {
1890 path: "good.txt".into(),
1891 content: "done".into(),
1892 };
1893 let clean = vec![(
1896 "broken".to_string(),
1897 "this is not a valid patch\n".to_string(),
1898 )];
1899 let outcome = regional_replan(
1900 repo.path(),
1901 "finish it",
1902 &clean,
1903 &agent,
1904 &cfg(&["true"]),
1905 &infra,
1906 )
1907 .await;
1908 assert!(outcome.is_none(), "unappliable clean set bails to fallback");
1909 }
1910
1911 #[test]
1912 fn localize_build_failure_picks_subtasks_whose_files_are_named() {
1913 let mut map = std::collections::HashMap::new();
1914 map.insert("src/a.rs".to_string(), vec!["a".to_string()]);
1915 map.insert("src/b.rs".to_string(), vec!["b".to_string()]);
1916 let ids = localize_build_failure("error[E0277]: in src/a.rs:42:5\n", &map);
1918 assert_eq!(
1919 ids,
1920 vec!["a".to_string()],
1921 "localized to the named file's subtask"
1922 );
1923 assert!(localize_build_failure("linker error, no file named\n", &map).is_empty());
1925 }
1926
1927 #[test]
1928 fn files_in_patch_parses_target_paths() {
1929 let patch = "diff --git a/src/foo.rs b/src/foo.rs\n\
1930 index e69de29..abc1234 100644\n\
1931 --- a/src/foo.rs\n+++ b/src/foo.rs\n\
1932 @@ -0,0 +1 @@\n+pub fn foo() {}\n\
1933 diff --git a/bar.rs b/bar.rs\n--- a/bar.rs\n+++ b/bar.rs\n";
1934 assert_eq!(
1935 files_in_patch(patch),
1936 vec!["src/foo.rs".to_string(), "bar.rs".to_string()]
1937 );
1938 }
1939
1940 #[tokio::test]
1941 async fn blame_attributes_apply_conflict_to_subtask_and_files() {
1942 let repo = init_repo();
1943 let infra = SharedInfra::new();
1944 let agent_a = WriteAgent {
1945 path: "src/lib.rs".into(),
1946 content: "pub fn original() -> u8 { 1 }\n".into(),
1947 };
1948 let agent_b = WriteAgent {
1949 path: "src/lib.rs".into(),
1950 content: "pub fn original() -> u16 { 2 }\n".into(),
1951 };
1952 let a = run_farm_out(
1953 repo.path(),
1954 &[st("a", &["src/lib.rs"])],
1955 &agent_a,
1956 &cfg(&["true"]),
1957 &infra,
1958 )
1959 .await;
1960 let b = run_farm_out(
1961 repo.path(),
1962 &[st("b", &["src/lib.rs"])],
1963 &agent_b,
1964 &cfg(&["true"]),
1965 &infra,
1966 )
1967 .await;
1968 let patches = vec![
1969 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
1970 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
1971 ];
1972 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
1973 .await
1974 .unwrap();
1975 let blame = integ.blame.expect("apply conflict produces blame");
1976 assert_eq!(blame.apply_conflicts.len(), 1, "{blame:?}");
1977 let c = &blame.apply_conflicts[0];
1978 assert_eq!(
1979 c.subtask_id, "b",
1980 "the second patch is the one that conflicts"
1981 );
1982 assert!(
1983 c.files.contains(&"src/lib.rs".to_string()),
1984 "files attributed: {c:?}"
1985 );
1986 }
1987
1988 #[tokio::test]
1989 async fn blame_carries_union_build_test_failure() {
1990 let repo = init_repo();
1991 let infra = SharedInfra::new();
1992 let agent = WriteAgent {
1993 path: "src/lib.rs".into(),
1994 content: "pub fn original() {}\npub fn added() {}\n".into(),
1995 };
1996 let config = FarmOutConfig {
1997 verify_command: Some(crate::patterns::foreman::test_verify::pass()),
1998 union_verify_command: Some(crate::patterns::foreman::test_verify::fail()), ..Default::default()
2000 };
2001 let r = run_farm_out(
2002 repo.path(),
2003 &[st("a", &["src/lib.rs"])],
2004 &agent,
2005 &config,
2006 &infra,
2007 )
2008 .await;
2009 let patches = vec![("a".to_string(), r.outcomes[0].patch.clone().unwrap())];
2010 let integ = integrate_and_verify(repo.path(), "u", &patches, &config, &infra)
2011 .await
2012 .unwrap();
2013 let blame = integ.blame.expect("rejected union produces blame");
2014 let bt = blame.build_test.expect("union build/test failure recorded");
2015 assert_eq!(bt.code, Some(1), "`false` exits 1: {bt:?}");
2016 assert_eq!(
2017 bt.candidate_subtask_ids,
2018 vec!["a".to_string()],
2019 "region named: {bt:?}"
2020 );
2021 }
2022
2023 #[tokio::test]
2024 async fn blame_attributes_duplicate_declaration_to_both_subtasks() {
2025 let repo = init_repo();
2029 let pad = "pub fn original() {}\npub fn p1() {}\npub fn p2() {}\npub fn p3() {}\n";
2030 std::fs::write(repo.path().join("src/lib.rs"), pad).unwrap();
2031 git_ok(repo.path(), &["commit", "-qam", "pad"]);
2032 let infra = SharedInfra::new();
2033 let agent_a = WriteAgent {
2034 path: "src/lib.rs".into(),
2035 content: format!("pub fn dup() {{}}\n{pad}"),
2036 };
2037 let agent_b = WriteAgent {
2038 path: "src/lib.rs".into(),
2039 content: format!("{pad}pub fn dup() {{}}\n"),
2040 };
2041 let a = run_farm_out(
2042 repo.path(),
2043 &[st("a", &["src/lib.rs"])],
2044 &agent_a,
2045 &cfg(&["true"]),
2046 &infra,
2047 )
2048 .await;
2049 let b = run_farm_out(
2050 repo.path(),
2051 &[st("b", &["src/lib.rs"])],
2052 &agent_b,
2053 &cfg(&["true"]),
2054 &infra,
2055 )
2056 .await;
2057 let patches = vec![
2058 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
2059 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
2060 ];
2061 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
2062 .await
2063 .unwrap();
2064 assert!(
2065 integ.apply_conflicts.is_empty(),
2066 "disjoint hunks both apply: {integ:?}"
2067 );
2068 let blame = integ.blame.expect("duplicate union produces blame");
2069 let dup = blame
2070 .duplicate_conflicts
2071 .iter()
2072 .find(|d| d.symbol == "dup")
2073 .unwrap_or_else(|| panic!("duplicate `dup` attributed: {blame:?}"));
2074 assert_eq!(dup.file, "src/lib.rs");
2075 let mut ids = dup.candidate_subtask_ids.clone();
2076 ids.sort();
2077 assert_eq!(
2078 ids,
2079 vec!["a".to_string(), "b".to_string()],
2080 "both subtasks blamed"
2081 );
2082 }
2083}