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 pub mcp_config_dir: Option<PathBuf>,
183}
184
185#[async_trait]
190pub trait WorktreeAgent: Send + Sync {
191 async fn run_in(&self, req: &WorktreeAgentRequest<'_>)
192 -> Result<AgentRunSummary, ForemanError>;
193}
194
195#[derive(Debug, Clone, Default)]
197pub struct FarmOutConfig {
198 pub verify_command: Option<Vec<String>>,
203 pub union_verify_command: Option<Vec<String>>,
209 pub allowed_tools: Option<Vec<String>>,
211 pub mcp_endpoint: Option<String>,
213 pub mcp_config_dir: Option<PathBuf>,
220 pub recover_via_single_session: bool,
227 pub worktree_base: Option<PathBuf>,
235 pub no_verify_waiver: Option<NoVerifyWaiver>,
244}
245
246fn default_worktree_base(repo_root: &Path) -> PathBuf {
252 use std::collections::hash_map::DefaultHasher;
253 use std::hash::{Hash, Hasher};
254 let mut hasher = DefaultHasher::new();
255 repo_root.hash(&mut hasher);
256 std::env::temp_dir()
257 .join("car-foreman-worktrees")
258 .join(format!("{:016x}", hasher.finish()))
259}
260
261fn worktree_workspace_config(repo_root: &Path, config: &FarmOutConfig) -> WorkspaceConfig {
266 let base = config
267 .worktree_base
268 .clone()
269 .unwrap_or_else(|| default_worktree_base(repo_root));
270 WorkspaceConfig::git_worktree_at(repo_root, base)
271}
272
273pub fn partition_by_files(subtasks: &[Subtask]) -> Vec<Vec<usize>> {
282 struct Level {
283 ids: Vec<usize>,
284 claimed: HashSet<String>,
285 open: bool,
287 }
288 let mut levels: Vec<Level> = Vec::new();
289
290 for (i, st) in subtasks.iter().enumerate() {
291 let files: HashSet<String> = st.files.iter().cloned().collect();
292 if files.is_empty() {
293 levels.push(Level {
294 ids: vec![i],
295 claimed: HashSet::new(),
296 open: false,
297 });
298 continue;
299 }
300 match levels
301 .iter_mut()
302 .find(|l| l.open && l.claimed.is_disjoint(&files))
303 {
304 Some(level) => {
305 level.ids.push(i);
306 level.claimed.extend(files);
307 }
308 None => levels.push(Level {
309 ids: vec![i],
310 claimed: files,
311 open: true,
312 }),
313 }
314 }
315 levels.into_iter().map(|l| l.ids).collect()
316}
317
318pub(crate) const FOOTPRINT_BLAST_DEPTH: usize = 3;
321
322fn schedule(repo_root: &Path, subtasks: &[Subtask]) -> Vec<Vec<usize>> {
328 if subtasks.is_empty() || !subtasks.iter().all(|s| s.footprint.is_some()) {
329 return partition_by_files(subtasks);
330 }
331 let mut seen = HashSet::new();
334 if !subtasks.iter().all(|s| seen.insert(s.id.as_str())) {
335 return partition_by_files(subtasks);
336 }
337 let index = car_ast::ProjectIndex::build(repo_root);
338 let fsubs: Vec<car_ast::FootprintSubtask> = subtasks
339 .iter()
340 .map(|s| car_ast::FootprintSubtask {
341 id: s.id.clone(),
342 footprint: car_ast::expand_footprint(
345 &index,
346 s.footprint.as_ref().expect("all footprints present"),
347 FOOTPRINT_BLAST_DEPTH,
348 ),
349 })
350 .collect();
351 let plan = car_ast::analyze(&fsubs);
352 plan.levels
353 .iter()
354 .map(|level| {
355 level
356 .iter()
357 .map(|id| subtasks.iter().position(|s| &s.id == id).unwrap())
358 .collect()
359 })
360 .collect()
361}
362
363#[derive(Debug)]
369pub struct SubtaskOutcome {
370 pub subtask_id: String,
371 pub verdict: Option<MergeVerdict>,
372 pub changes: Vec<FileChange>,
373 pub patch: Option<String>,
374 pub error: Option<String>,
375}
376
377impl SubtaskOutcome {
378 pub fn is_accepted(&self) -> bool {
379 self.verdict.as_ref().is_some_and(|v| v.is_accepted())
380 }
381}
382
383#[derive(Debug)]
385pub struct FarmOutResult {
386 pub levels: Vec<Vec<usize>>,
388 pub outcomes: Vec<SubtaskOutcome>,
389}
390
391impl FarmOutResult {
392 pub fn accepted_count(&self) -> usize {
393 self.outcomes.iter().filter(|o| o.is_accepted()).count()
394 }
395}
396
397pub async fn run_farm_out(
403 repo_root: &Path,
404 subtasks: &[Subtask],
405 agent: &dyn WorktreeAgent,
406 config: &FarmOutConfig,
407 infra: &SharedInfra,
408) -> FarmOutResult {
409 run_farm_out_inner(repo_root, subtasks, agent, config, infra, None).await
410}
411
412pub async fn run_farm_out_with_progress(
417 repo_root: &Path,
418 subtasks: &[Subtask],
419 agent: &dyn WorktreeAgent,
420 config: &FarmOutConfig,
421 infra: &SharedInfra,
422 progress: ForemanProgressSink,
423) -> FarmOutResult {
424 run_farm_out_inner(repo_root, subtasks, agent, config, infra, Some(progress)).await
425}
426
427async fn run_farm_out_inner(
428 repo_root: &Path,
429 subtasks: &[Subtask],
430 agent: &dyn WorktreeAgent,
431 config: &FarmOutConfig,
432 infra: &SharedInfra,
433 progress: Option<ForemanProgressSink>,
434) -> FarmOutResult {
435 let levels = schedule(repo_root, subtasks);
436 let total = subtasks.len();
437 let mut outcomes = Vec::with_capacity(subtasks.len());
438
439 for (level_idx, level) in levels.iter().enumerate() {
440 let level_futs = level.iter().map(|&i| {
441 run_one_subtask(
442 repo_root,
443 i,
444 &subtasks[i],
445 agent,
446 config,
447 infra,
448 level_idx,
449 total,
450 progress.as_ref(),
451 )
452 });
453 outcomes.extend(futures::future::join_all(level_futs).await);
454 }
455
456 FarmOutResult { levels, outcomes }
457}
458
459#[allow(clippy::too_many_arguments)]
460async fn run_one_subtask(
461 repo_root: &Path,
462 index: usize,
463 subtask: &Subtask,
464 agent: &dyn WorktreeAgent,
465 config: &FarmOutConfig,
466 infra: &SharedInfra,
467 level: usize,
468 total: usize,
469 progress: Option<&ForemanProgressSink>,
470) -> SubtaskOutcome {
471 let progress = progress.cloned();
474 emit(
475 &progress,
476 ForemanProgress::SubtaskStarted {
477 subtask_id: subtask.id.clone(),
478 index,
479 level,
480 total,
481 },
482 );
483 let fail = |error: ForemanError| {
484 emit(
485 &progress,
486 ForemanProgress::SubtaskGated {
487 subtask_id: subtask.id.clone(),
488 accepted: false,
489 status: "error".into(),
490 },
491 );
492 SubtaskOutcome {
493 subtask_id: subtask.id.clone(),
494 verdict: None,
495 changes: Vec::new(),
496 patch: None,
497 error: Some(error.to_string()),
498 }
499 };
500
501 let ws_name = format!("{index:04}-{}", subtask.id);
504 let workspace =
505 match AgentWorkspace::provision(&worktree_workspace_config(repo_root, config), &ws_name) {
506 Ok(ws) => ws,
507 Err(e) => return fail(ForemanError::Workspace(e)),
508 };
509 let cwd = workspace.path().to_path_buf();
510
511 let req = WorktreeAgentRequest {
512 subtask,
513 cwd: &cwd,
514 allowed_tools: config.allowed_tools.clone(),
515 mcp_endpoint: config.mcp_endpoint.clone(),
516 mcp_config_dir: config.mcp_config_dir.clone(),
517 };
518 if let Err(e) = agent.run_in(&req).await {
519 return fail(e);
520 }
521
522 let cwd_for_blocking = cwd.clone();
523 let (changes, patch) = match tokio::task::spawn_blocking(move || {
524 let changes = collect_file_changes(&cwd_for_blocking)?;
525 let patch = capture_patch(&cwd_for_blocking)?;
526 Ok::<_, ForemanError>((changes, patch))
527 })
528 .await
529 {
530 Ok(Ok(v)) => v,
531 Ok(Err(e)) => return fail(e),
532 Err(e) => return fail(ForemanError::Git(format!("collect task panicked: {e}"))),
533 };
534
535 emit(
536 &progress,
537 ForemanProgress::SubtaskVerifying {
538 subtask_id: subtask.id.clone(),
539 },
540 );
541 let mut gate_config = GateConfig::new(subtask.id.clone(), &cwd);
542 gate_config.verify_command = config.verify_command.clone();
543 gate_config.no_verify_waiver = config.no_verify_waiver.clone();
544 let footprint = match &subtask.footprint {
547 Some(fp) => DeclaredFootprint::from_refs(fp.writes.iter().cloned()),
548 None => DeclaredFootprint::unconstrained(),
549 };
550 let verdict = verify_changes(&gate_config, &changes, &footprint, infra).await;
551 emit(
552 &progress,
553 ForemanProgress::SubtaskGated {
554 subtask_id: subtask.id.clone(),
555 accepted: verdict.is_accepted(),
556 status: verdict_status(&verdict).into(),
557 },
558 );
559
560 SubtaskOutcome {
561 subtask_id: subtask.id.clone(),
562 verdict: Some(verdict),
563 changes,
564 patch: Some(patch),
565 error: None,
566 }
567}
568
569#[derive(Debug)]
571pub struct IntegrationResult {
572 pub applied: usize,
573 pub apply_conflicts: Vec<String>,
579 pub verdict: Option<MergeVerdict>,
582 pub blame: Option<IntegrationBlame>,
585}
586
587impl IntegrationResult {
588 pub fn integrated_cleanly(&self) -> bool {
591 self.apply_conflicts.is_empty() && self.verdict.as_ref().is_some_and(|v| v.is_accepted())
592 }
593}
594
595#[derive(Debug, Clone, PartialEq, Eq)]
598pub struct ApplyConflict {
599 pub subtask_id: String,
600 pub files: Vec<String>,
601 pub detail: String,
602}
603
604#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct DuplicateBlame {
611 pub file: String,
612 pub symbol: String,
613 pub candidate_subtask_ids: Vec<String>,
614}
615
616#[derive(Debug, Clone, PartialEq, Eq)]
623pub struct BuildTestFailure {
624 pub code: Option<i32>,
625 pub output_tail: String,
626 pub candidate_subtask_ids: Vec<String>,
627}
628
629#[derive(Debug, Clone, Default, PartialEq, Eq)]
641pub struct IntegrationBlame {
642 pub apply_conflicts: Vec<ApplyConflict>,
644 pub duplicate_conflicts: Vec<DuplicateBlame>,
646 pub build_test: Option<BuildTestFailure>,
648}
649
650impl IntegrationBlame {
651 pub fn is_empty(&self) -> bool {
652 self.apply_conflicts.is_empty()
653 && self.duplicate_conflicts.is_empty()
654 && self.build_test.is_none()
655 }
656
657 pub fn implicated_subtasks(&self) -> BTreeSet<String> {
665 let mut ids = BTreeSet::new();
666 for c in &self.apply_conflicts {
667 ids.insert(c.subtask_id.clone());
668 }
669 for d in &self.duplicate_conflicts {
670 ids.extend(d.candidate_subtask_ids.iter().cloned());
671 }
672 if let Some(bt) = &self.build_test {
673 ids.extend(bt.candidate_subtask_ids.iter().cloned());
674 }
675 ids
676 }
677}
678
679fn localize_build_failure(
687 output: &str,
688 file_to_subtasks: &std::collections::HashMap<String, Vec<String>>,
689) -> Vec<String> {
690 let mut ids = BTreeSet::new();
691 for (file, subtasks) in file_to_subtasks {
692 if output.contains(file.as_str()) {
698 ids.extend(subtasks.iter().cloned());
699 }
700 }
701 ids.into_iter().collect()
702}
703
704pub fn files_in_patch(patch: &str) -> Vec<String> {
716 let mut files = Vec::new();
717 for line in patch.lines() {
718 if let Some(rest) = line.strip_prefix("diff --git ") {
719 if let Some(pos) = rest.rfind(" b/") {
720 let file = &rest[pos + 3..];
721 if !file.is_empty() && !files.iter().any(|f| f == file) {
722 files.push(file.to_string());
723 }
724 }
725 }
726 }
727 files
728}
729
730pub async fn integrate_and_verify(
738 repo_root: &Path,
739 subtask_label: &str,
740 accepted_patches: &[(String, String)], config: &FarmOutConfig,
742 infra: &SharedInfra,
743) -> Result<IntegrationResult, ForemanError> {
744 let staging = AgentWorkspace::provision(
745 &worktree_workspace_config(repo_root, config),
746 &format!("integrate-{subtask_label}"),
747 )
748 .map_err(ForemanError::Workspace)?;
749 let staging_path = staging.path().to_path_buf();
750 let patches: Vec<(String, String)> = accepted_patches.to_vec();
751 let patch_count = patches.len();
752 let verify_command = config
755 .union_verify_command
756 .clone()
757 .or_else(|| config.verify_command.clone());
758 let label = subtask_label.to_string();
759
760 let mut file_to_subtasks: std::collections::HashMap<String, Vec<String>> =
766 std::collections::HashMap::new();
767 let mut union_members: Vec<String> = Vec::new();
768 for (id, patch) in &patches {
769 if !union_members.contains(id) {
770 union_members.push(id.clone());
771 }
772 for file in files_in_patch(patch) {
773 file_to_subtasks.entry(file).or_default().push(id.clone());
774 }
775 }
776
777 let staging_for_blocking = staging_path.clone();
779 let (conflicts, changes) = tokio::task::spawn_blocking(move || {
780 let mut conflicts: Vec<ApplyConflict> = Vec::new();
781 for (id, patch) in &patches {
782 if patch.trim().is_empty() {
783 continue;
784 }
785 if let Err(e) = git_apply(&staging_for_blocking, patch) {
786 conflicts.push(ApplyConflict {
787 subtask_id: id.clone(),
788 files: files_in_patch(patch),
789 detail: e.to_string(),
790 });
791 }
792 }
793 let changes = collect_file_changes(&staging_for_blocking)?;
794 Ok::<_, ForemanError>((conflicts, changes))
795 })
796 .await
797 .map_err(|e| ForemanError::Git(format!("integrate task panicked: {e}")))??;
798
799 if !conflicts.is_empty() {
802 let apply_conflicts: Vec<String> = conflicts
803 .iter()
804 .map(|c| format!("{}: {}", c.subtask_id, c.detail))
805 .collect();
806 return Ok(IntegrationResult {
807 applied: patch_count - conflicts.len(),
808 apply_conflicts,
809 verdict: None,
810 blame: Some(IntegrationBlame {
811 apply_conflicts: conflicts,
812 ..Default::default()
813 }),
814 });
815 }
816
817 let mut gate_config = GateConfig::new(format!("union:{label}"), &staging_path);
818 gate_config.verify_command = verify_command;
819 gate_config.no_verify_waiver = config.no_verify_waiver.clone();
820 let verdict = verify_changes(
821 &gate_config,
822 &changes,
823 &DeclaredFootprint::unconstrained(),
824 infra,
825 )
826 .await;
827
828 let blame = if verdict.is_accepted() {
832 None
833 } else {
834 let ev = verdict.evidence();
835 let duplicate_conflicts = ev
836 .semantic_conflicts
837 .iter()
838 .map(|d| DuplicateBlame {
839 file: d.file.clone(),
840 symbol: d.symbol.clone(),
841 candidate_subtask_ids: file_to_subtasks.get(&d.file).cloned().unwrap_or_default(),
842 })
843 .collect();
844 let build_test = match &ev.build_test {
845 BuildTestStatus::Failed { code, output } => {
846 let localized = localize_build_failure(output, &file_to_subtasks);
851 let candidate_subtask_ids = if localized.is_empty() {
852 union_members.clone()
853 } else {
854 localized
855 };
856 Some(BuildTestFailure {
857 code: *code,
858 output_tail: output.clone(), candidate_subtask_ids,
860 })
861 }
862 _ => None,
863 };
864 Some(IntegrationBlame {
865 apply_conflicts: Vec::new(),
866 duplicate_conflicts,
867 build_test,
868 })
869 };
870
871 Ok(IntegrationResult {
872 applied: patch_count,
873 apply_conflicts: Vec::new(),
874 verdict: Some(verdict),
875 blame,
876 })
877}
878
879pub async fn regional_replan(
896 repo_root: &Path,
897 goal: &str,
898 clean_patches: &[(String, String)],
899 agent: &dyn WorktreeAgent,
900 config: &FarmOutConfig,
901 infra: &SharedInfra,
902) -> Option<SubtaskOutcome> {
903 let workspace = AgentWorkspace::provision(
904 &worktree_workspace_config(repo_root, config),
905 "regional-replan",
906 )
907 .ok()?;
908 let cwd = workspace.path().to_path_buf();
909
910 let cwd_for_apply = cwd.clone();
914 let clean = clean_patches.to_vec();
915 let staged = tokio::task::spawn_blocking(move || {
916 for (_id, patch) in &clean {
917 if patch.trim().is_empty() {
918 continue;
919 }
920 if git_apply(&cwd_for_apply, patch).is_err() {
921 return false;
922 }
923 }
924 true
925 })
926 .await
927 .ok()?;
928 if !staged {
929 return None;
930 }
931
932 let subtask = Subtask::files_only("__regional_replan__", goal.to_string(), Vec::new());
936 let req = WorktreeAgentRequest {
937 subtask: &subtask,
938 cwd: &cwd,
939 allowed_tools: config.allowed_tools.clone(),
940 mcp_endpoint: config.mcp_endpoint.clone(),
941 mcp_config_dir: config.mcp_config_dir.clone(),
942 };
943 if agent.run_in(&req).await.is_err() {
944 return None;
945 }
946
947 let cwd_for_blocking = cwd.clone();
948 let (changes, patch) = tokio::task::spawn_blocking(move || {
949 let changes = collect_file_changes(&cwd_for_blocking)?;
950 let patch = capture_patch(&cwd_for_blocking)?;
951 Ok::<_, ForemanError>((changes, patch))
952 })
953 .await
954 .ok()?
955 .ok()?;
956
957 let goal_check = config
960 .union_verify_command
961 .clone()
962 .or_else(|| config.verify_command.clone());
963 let mut gate_config = GateConfig::new("__regional_replan__", &cwd);
964 gate_config.verify_command = goal_check;
965 gate_config.no_verify_waiver = config.no_verify_waiver.clone();
966 let verdict = verify_changes(
967 &gate_config,
968 &changes,
969 &DeclaredFootprint::unconstrained(),
970 infra,
971 )
972 .await;
973
974 Some(SubtaskOutcome {
975 subtask_id: "__regional_replan__".into(),
976 verdict: Some(verdict),
977 changes,
978 patch: Some(patch),
979 error: None,
980 })
981}
982
983fn collect_file_changes(worktree: &Path) -> Result<Vec<FileChange>, ForemanError> {
988 let porcelain = git(
989 worktree,
990 &["status", "--porcelain", "-z", "--untracked-files=all"],
991 )?;
992
993 let mut changes = Vec::new();
994 let mut fields = porcelain.split('\0');
995 while let Some(entry) = fields.next() {
996 if entry.len() < 4 {
998 continue;
999 }
1000 let x = entry.as_bytes()[0];
1003 let path = entry[3..].to_string();
1004
1005 if x == b'R' || x == b'C' {
1013 if let Some(old) = fields.next() {
1014 if !old.is_empty() {
1015 changes.push(FileChange {
1016 path: old.to_string(),
1017 before: read_head(worktree, old),
1018 after: None,
1019 });
1020 }
1021 }
1022 changes.push(FileChange {
1023 path: path.clone(),
1024 before: None,
1025 after: read_worktree(worktree, &path),
1026 });
1027 continue;
1028 }
1029
1030 let before = read_head(worktree, &path);
1031 let after = read_worktree(worktree, &path);
1032 if before.is_none() && after.is_none() {
1033 continue;
1034 }
1035 changes.push(FileChange {
1036 path,
1037 before,
1038 after,
1039 });
1040 }
1041 Ok(changes)
1042}
1043
1044fn read_head(worktree: &Path, path: &str) -> Option<String> {
1047 git(worktree, &["show", &format!("HEAD:./{path}")]).ok()
1048}
1049
1050fn read_worktree(worktree: &Path, path: &str) -> Option<String> {
1053 std::fs::read(worktree.join(path))
1054 .ok()
1055 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1056}
1057
1058pub fn capture_patch(worktree: &Path) -> Result<String, ForemanError> {
1066 git(worktree, &["add", "-AN"])?;
1069 git(
1073 worktree,
1074 &[
1075 "-c",
1076 "core.autocrlf=false",
1077 "diff",
1078 "HEAD",
1079 "--binary",
1080 "--no-textconv",
1081 ],
1082 )
1083}
1084
1085fn git(cwd: &Path, args: &[&str]) -> Result<String, ForemanError> {
1086 let out = Command::new("git")
1087 .args(args)
1088 .current_dir(cwd)
1089 .output()
1090 .map_err(|e| ForemanError::Git(format!("spawn git: {e}")))?;
1091 if !out.status.success() {
1092 return Err(ForemanError::Git(
1093 String::from_utf8_lossy(&out.stderr).trim().to_string(),
1094 ));
1095 }
1096 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
1097}
1098
1099pub fn git_apply(cwd: &Path, patch: &str) -> Result<(), ForemanError> {
1108 let mut child = Command::new("git")
1109 .args(["apply", "--whitespace=nowarn"])
1110 .current_dir(cwd)
1111 .stdin(Stdio::piped())
1112 .stdout(Stdio::piped())
1113 .stderr(Stdio::piped())
1114 .spawn()
1115 .map_err(|e| ForemanError::Git(format!("spawn git apply: {e}")))?;
1116 child
1117 .stdin
1118 .take()
1119 .ok_or_else(|| ForemanError::Git("no stdin for git apply".into()))?
1120 .write_all(patch.as_bytes())
1121 .map_err(|e| ForemanError::Git(format!("write patch: {e}")))?;
1122 let out = child
1123 .wait_with_output()
1124 .map_err(|e| ForemanError::Git(format!("git apply: {e}")))?;
1125 if out.status.success() {
1126 Ok(())
1127 } else {
1128 Err(ForemanError::Git(
1129 String::from_utf8_lossy(&out.stderr).trim().to_string(),
1130 ))
1131 }
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136 use super::*;
1137
1138 fn st(id: &str, files: &[&str]) -> Subtask {
1139 Subtask::files_only(
1140 id,
1141 format!("do {id}"),
1142 files.iter().map(|s| s.to_string()).collect(),
1143 )
1144 }
1145
1146 #[test]
1149 fn disjoint_files_pack_into_one_level() {
1150 let levels = partition_by_files(&[st("a", &["src/a.rs"]), st("b", &["src/b.rs"])]);
1151 assert_eq!(levels.len(), 1);
1152 assert_eq!(levels[0].len(), 2);
1153 }
1154
1155 #[test]
1156 fn shared_file_forces_separate_levels() {
1157 let levels =
1158 partition_by_files(&[st("a", &["src/shared.rs"]), st("b", &["src/shared.rs"])]);
1159 assert_eq!(levels.len(), 2);
1160 }
1161
1162 #[test]
1163 fn no_files_subtask_gets_its_own_isolated_level() {
1164 let levels =
1166 partition_by_files(&[st("a", &["x.rs"]), st("nofiles", &[]), st("b", &["y.rs"])]);
1167 let nofiles_level = levels.iter().find(|l| l.contains(&1)).unwrap();
1168 assert_eq!(nofiles_level, &vec![1], "no-files subtask is isolated");
1169 }
1170
1171 fn git_ok(cwd: &Path, args: &[&str]) {
1174 let out = Command::new("git")
1175 .args(args)
1176 .current_dir(cwd)
1177 .output()
1178 .unwrap();
1179 assert!(
1180 out.status.success(),
1181 "git {args:?}: {}",
1182 String::from_utf8_lossy(&out.stderr)
1183 );
1184 }
1185
1186 fn init_repo() -> tempfile::TempDir {
1187 let dir = tempfile::tempdir().unwrap();
1188 let root = dir.path();
1189 git_ok(root, &["init", "-q", "-b", "main"]);
1190 git_ok(root, &["config", "user.email", "t@t.t"]);
1191 git_ok(root, &["config", "user.name", "t"]);
1192 std::fs::create_dir_all(root.join("src")).unwrap();
1193 std::fs::write(root.join("src/lib.rs"), "pub fn original() {}\n").unwrap();
1194 git_ok(root, &["add", "-A"]);
1195 git_ok(root, &["commit", "-q", "-m", "init"]);
1196 dir
1197 }
1198
1199 #[test]
1200 fn collect_changes_handles_rename_with_spaces() {
1201 let repo = init_repo();
1202 let root = repo.path();
1203 std::fs::write(root.join("old name.rs"), "pub fn moved() {}\n").unwrap();
1204 git_ok(root, &["add", "-A"]);
1205 git_ok(root, &["commit", "-q", "-m", "add"]);
1206 git_ok(root, &["mv", "old name.rs", "new name.rs"]);
1208
1209 let changes = collect_file_changes(root).unwrap();
1210 let paths: Vec<_> = changes.iter().map(|c| c.path.as_str()).collect();
1211 assert!(
1212 paths.contains(&"old name.rs"),
1213 "rename deletion side present: {paths:?}"
1214 );
1215 assert!(
1216 paths.contains(&"new name.rs"),
1217 "rename addition side present: {paths:?}"
1218 );
1219 let old = changes.iter().find(|c| c.path == "old name.rs").unwrap();
1220 assert!(
1221 old.before.is_some() && old.after.is_none(),
1222 "old path is a deletion"
1223 );
1224 }
1225
1226 struct WriteAgent {
1227 path: String,
1228 content: String,
1229 }
1230
1231 #[async_trait]
1232 impl WorktreeAgent for WriteAgent {
1233 async fn run_in(
1234 &self,
1235 req: &WorktreeAgentRequest<'_>,
1236 ) -> Result<AgentRunSummary, ForemanError> {
1237 let target = req.cwd.join(&self.path);
1238 if let Some(parent) = target.parent() {
1239 std::fs::create_dir_all(parent).ok();
1240 }
1241 std::fs::write(target, &self.content)
1242 .map_err(|e| ForemanError::Agent(e.to_string()))?;
1243 Ok(AgentRunSummary::default())
1244 }
1245 }
1246
1247 fn cfg(verify: &[&str]) -> FarmOutConfig {
1248 let cmd = match verify {
1252 ["true"] => crate::patterns::foreman::test_verify::pass(),
1253 ["false"] => crate::patterns::foreman::test_verify::fail(),
1254 other => other.iter().map(|s| s.to_string()).collect(),
1255 };
1256 FarmOutConfig {
1257 verify_command: Some(cmd),
1258 ..Default::default()
1259 }
1260 }
1261
1262 #[derive(Clone, Default)]
1265 struct SawUpstream(std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, String>>>);
1266
1267 #[async_trait]
1268 impl WorktreeAgent for SawUpstream {
1269 async fn run_in(
1270 &self,
1271 req: &WorktreeAgentRequest<'_>,
1272 ) -> Result<AgentRunSummary, ForemanError> {
1273 let seen = std::fs::read_to_string(req.cwd.join("src/upstream.rs")).unwrap_or_default();
1274 self.0.lock().unwrap().insert(req.subtask.id.clone(), seen);
1275 let own = if req.subtask.id == "upstream" {
1276 ("src/upstream.rs", "pub fn provided() -> u32 { 42 }\n")
1277 } else {
1278 ("src/downstream.rs", "pub fn consumes() -> u32 { 0 }\n")
1279 };
1280 std::fs::write(req.cwd.join(own.0), own.1)
1281 .map_err(|e| ForemanError::Agent(e.to_string()))?;
1282 Ok(AgentRunSummary::default())
1283 }
1284 }
1285
1286 #[tokio::test]
1320 async fn a_dependent_subtask_does_not_see_upstream_work_and_must_not() {
1321 let repo = init_repo();
1322 let root = repo.path();
1323 std::fs::write(
1324 root.join("src/upstream.rs"),
1325 "pub fn provided() -> u32 { 0 }\n",
1326 )
1327 .unwrap();
1328 std::fs::write(
1329 root.join("src/downstream.rs"),
1330 "pub fn consumes() -> u32 { 0 }\n",
1331 )
1332 .unwrap();
1333 git_ok(root, &["add", "-A"]);
1334 git_ok(root, &["commit", "-q", "-m", "seed"]);
1335
1336 let upstream = Subtask {
1337 id: "upstream".into(),
1338 prompt: "implement provided".into(),
1339 files: vec!["src/upstream.rs".into()],
1340 footprint: Some(car_ast::SymbolFootprint::writing([
1341 car_ast::SymbolRef::new("src/upstream.rs", "provided"),
1342 ])),
1343 };
1344 let downstream = Subtask {
1345 id: "downstream".into(),
1346 prompt: "implement consumes using provided".into(),
1347 files: vec!["src/downstream.rs".into()],
1348 footprint: Some(car_ast::SymbolFootprint {
1349 writes: [car_ast::SymbolRef::new("src/downstream.rs", "consumes")]
1350 .into_iter()
1351 .collect(),
1352 reads: [car_ast::SymbolRef::new("src/upstream.rs", "provided")]
1353 .into_iter()
1354 .collect(),
1355 uncertain: false,
1356 }),
1357 };
1358
1359 let agent = SawUpstream::default();
1360 let infra = SharedInfra::new();
1361 let result = run_farm_out(
1362 root,
1363 &[upstream, downstream],
1364 &agent,
1365 &cfg(&["true"]),
1366 &infra,
1367 )
1368 .await;
1369
1370 assert_eq!(
1373 result.levels.len(),
1374 2,
1375 "a declared read must place the dependent subtask in a later level"
1376 );
1377 let seen = agent.0.lock().unwrap().clone();
1378 assert!(
1379 seen["downstream"].contains("{ 0 }"),
1380 "downstream must see BASE content: patches have to stay independent \
1381 diffs from one base or the union gate cannot detect conflicts. Got {:?}",
1382 seen["downstream"]
1383 );
1384 }
1385
1386 #[tokio::test]
1387 async fn clean_edit_is_verified_through_harness() {
1388 let repo = init_repo();
1389 let agent = WriteAgent {
1390 path: "src/lib.rs".into(),
1391 content: "pub fn original() {}\npub fn added() {}\n".into(),
1392 };
1393 let infra = SharedInfra::new();
1394 let result = run_farm_out(
1395 repo.path(),
1396 &[st("edit", &["src/lib.rs"])],
1397 &agent,
1398 &cfg(&["true"]),
1399 &infra,
1400 )
1401 .await;
1402 let o = &result.outcomes[0];
1403 assert!(o.error.is_none(), "{o:?}");
1404 assert!(o.verdict.as_ref().unwrap().is_verified());
1405 assert!(
1406 o.patch.as_ref().unwrap().contains("added"),
1407 "patch retained"
1408 );
1409 }
1410
1411 #[test]
1412 fn default_worktree_base_is_outside_the_repo() {
1413 let repo = init_repo();
1414 let root = repo.path();
1415 let base = default_worktree_base(root);
1416 assert!(
1417 !base.starts_with(root),
1418 "worktree base {base:?} must not be inside repo {root:?}"
1419 );
1420 assert!(base.starts_with(std::env::temp_dir()));
1421 assert_eq!(base, default_worktree_base(root));
1423 let cfg = FarmOutConfig::default();
1425 let _ = worktree_workspace_config(root, &cfg); }
1427
1428 #[tokio::test]
1429 async fn worktrees_are_provisioned_outside_the_repo() {
1430 struct CwdProbe {
1433 seen: Arc<std::sync::Mutex<Option<PathBuf>>>,
1434 }
1435 #[async_trait]
1436 impl WorktreeAgent for CwdProbe {
1437 async fn run_in(
1438 &self,
1439 req: &WorktreeAgentRequest<'_>,
1440 ) -> Result<AgentRunSummary, ForemanError> {
1441 *self.seen.lock().unwrap() = Some(req.cwd.to_path_buf());
1442 std::fs::write(req.cwd.join("src/added.rs"), "pub fn a() {}\n")
1443 .map_err(|e| ForemanError::Agent(e.to_string()))?;
1444 Ok(AgentRunSummary::default())
1445 }
1446 }
1447
1448 let repo = init_repo();
1449 let repo_root = repo.path().canonicalize().unwrap();
1450 let seen = Arc::new(std::sync::Mutex::new(None));
1451 let agent = CwdProbe {
1452 seen: Arc::clone(&seen),
1453 };
1454 let infra = SharedInfra::new();
1455 let _ = run_farm_out(
1456 repo.path(),
1457 &[st("edit", &["src/added.rs"])],
1458 &agent,
1459 &cfg(&["true"]),
1460 &infra,
1461 )
1462 .await;
1463
1464 let cwd = seen.lock().unwrap().clone().expect("agent ran");
1465 let cwd = cwd.canonicalize().unwrap_or(cwd);
1466 assert!(
1467 !cwd.starts_with(&repo_root),
1468 "worktree {cwd:?} must be OUTSIDE repo {repo_root:?}"
1469 );
1470 }
1471
1472 #[tokio::test]
1478 async fn farm_out_config_mcp_config_dir_reaches_every_request() {
1479 #[derive(Clone)]
1480 struct DirProbe(Arc<std::sync::Mutex<Vec<Option<PathBuf>>>>);
1481
1482 #[async_trait]
1483 impl WorktreeAgent for DirProbe {
1484 async fn run_in(
1485 &self,
1486 req: &WorktreeAgentRequest<'_>,
1487 ) -> Result<AgentRunSummary, ForemanError> {
1488 self.0.lock().unwrap().push(req.mcp_config_dir.clone());
1489 std::fs::write(
1490 req.cwd.join(format!("src/{}.rs", req.subtask.id)),
1491 "pub fn a() {}\n",
1492 )
1493 .map_err(|e| ForemanError::Agent(e.to_string()))?;
1494 Ok(AgentRunSummary::default())
1495 }
1496 }
1497
1498 let repo = init_repo();
1499 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1500 let agent = DirProbe(Arc::clone(&seen));
1501 let infra = SharedInfra::new();
1502 let dir = PathBuf::from("/var/car/coder/state/mcp");
1503 let config = FarmOutConfig {
1504 mcp_config_dir: Some(dir.clone()),
1505 ..cfg(&["true"])
1506 };
1507 let _ = run_farm_out(
1508 repo.path(),
1509 &[st("one", &["src/one.rs"]), st("two", &["src/two.rs"])],
1510 &agent,
1511 &config,
1512 &infra,
1513 )
1514 .await;
1515
1516 let seen = seen.lock().unwrap().clone();
1517 assert_eq!(seen.len(), 2, "both subtasks ran: {seen:?}");
1518 assert!(
1519 seen.iter().all(|d| d.as_deref() == Some(dir.as_path())),
1520 "every request must carry the configured directory: {seen:?}"
1521 );
1522 }
1523
1524 #[tokio::test]
1529 async fn a_farm_out_without_an_mcp_config_dir_passes_none() {
1530 #[derive(Clone)]
1531 struct DirProbe(Arc<std::sync::Mutex<Vec<Option<PathBuf>>>>);
1532
1533 #[async_trait]
1534 impl WorktreeAgent for DirProbe {
1535 async fn run_in(
1536 &self,
1537 req: &WorktreeAgentRequest<'_>,
1538 ) -> Result<AgentRunSummary, ForemanError> {
1539 self.0.lock().unwrap().push(req.mcp_config_dir.clone());
1540 std::fs::write(req.cwd.join("src/added.rs"), "pub fn a() {}\n")
1541 .map_err(|e| ForemanError::Agent(e.to_string()))?;
1542 Ok(AgentRunSummary::default())
1543 }
1544 }
1545
1546 assert!(
1547 FarmOutConfig::default().mcp_config_dir.is_none(),
1548 "the default must not invent a directory"
1549 );
1550 let repo = init_repo();
1551 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1552 let agent = DirProbe(Arc::clone(&seen));
1553 let infra = SharedInfra::new();
1554 let _ = run_farm_out(
1555 repo.path(),
1556 &[st("edit", &["src/added.rs"])],
1557 &agent,
1558 &cfg(&["true"]),
1559 &infra,
1560 )
1561 .await;
1562
1563 assert_eq!(seen.lock().unwrap().as_slice(), &[None]);
1564 }
1565
1566 #[tokio::test]
1567 async fn no_verify_waiver_accepts_when_no_build_command() {
1568 let repo = init_repo();
1569 let agent = WriteAgent {
1570 path: "src/added.rs".into(),
1571 content: "pub fn added() {}\n".into(),
1572 };
1573 let infra = SharedInfra::new();
1574
1575 let r1 = run_farm_out(
1578 repo.path(),
1579 &[st("edit", &["src/added.rs"])],
1580 &agent,
1581 &FarmOutConfig::default(),
1582 &infra,
1583 )
1584 .await;
1585 assert!(
1586 !r1.outcomes[0].is_accepted(),
1587 "no command + no waiver must not be accepted: {:?}",
1588 r1.outcomes[0]
1589 );
1590
1591 let waived = FarmOutConfig {
1594 no_verify_waiver: Some(NoVerifyWaiver {
1595 class: "no-build-gate".into(),
1596 reason: "no reliable build command for this project".into(),
1597 }),
1598 ..Default::default()
1599 };
1600 let r2 = run_farm_out(
1601 repo.path(),
1602 &[st("edit", &["src/added.rs"])],
1603 &agent,
1604 &waived,
1605 &infra,
1606 )
1607 .await;
1608 let o = &r2.outcomes[0];
1609 assert!(o.is_accepted(), "waiver must yield acceptance: {o:?}");
1610 assert!(
1611 !o.verdict.as_ref().unwrap().is_verified(),
1612 "waiver-based acceptance is not build-verified"
1613 );
1614 }
1615
1616 #[tokio::test]
1617 async fn progress_streams_started_then_gated_per_subtask() {
1618 let repo = init_repo();
1619 let agent = WriteAgent {
1622 path: "src/added.rs".into(),
1623 content: "pub fn a() {}\n".into(),
1624 };
1625
1626 let events: Arc<std::sync::Mutex<Vec<ForemanProgress>>> =
1627 Arc::new(std::sync::Mutex::new(Vec::new()));
1628 let sink: ForemanProgressSink = {
1629 let events = Arc::clone(&events);
1630 Arc::new(move |ev| events.lock().unwrap().push(ev))
1631 };
1632
1633 let infra = SharedInfra::new();
1634 let result = run_farm_out_with_progress(
1635 repo.path(),
1636 &[st("only", &["src/added.rs"])],
1637 &agent,
1638 &cfg(&["true"]),
1639 &infra,
1640 sink,
1641 )
1642 .await;
1643 assert!(result.outcomes[0].is_accepted());
1644
1645 let events = events.lock().unwrap();
1646 assert_eq!(events.len(), 3, "started + verifying + gated: {events:?}");
1647 assert!(
1648 matches!(
1649 &events[0],
1650 ForemanProgress::SubtaskStarted { subtask_id, index: 0, level: 0, total: 1 } if subtask_id == "only"
1651 ),
1652 "first event is started: {:?}",
1653 events[0]
1654 );
1655 assert!(
1656 matches!(
1657 &events[1],
1658 ForemanProgress::SubtaskVerifying { subtask_id } if subtask_id == "only"
1659 ),
1660 "second event is verifying: {:?}",
1661 events[1]
1662 );
1663 assert!(
1664 matches!(
1665 &events[2],
1666 ForemanProgress::SubtaskGated { subtask_id, accepted: true, status } if subtask_id == "only" && status == "accepted"
1667 ),
1668 "third event is an accepted gate: {:?}",
1669 events[2]
1670 );
1671 }
1672
1673 #[tokio::test]
1674 async fn progress_reports_error_status_when_agent_fails() {
1675 let repo = init_repo();
1676 struct FailAgent;
1678 #[async_trait]
1679 impl WorktreeAgent for FailAgent {
1680 async fn run_in(
1681 &self,
1682 _: &WorktreeAgentRequest<'_>,
1683 ) -> Result<AgentRunSummary, ForemanError> {
1684 Err(ForemanError::Agent("boom".into()))
1685 }
1686 }
1687 let events: Arc<std::sync::Mutex<Vec<ForemanProgress>>> =
1688 Arc::new(std::sync::Mutex::new(Vec::new()));
1689 let sink: ForemanProgressSink = {
1690 let events = Arc::clone(&events);
1691 Arc::new(move |ev| events.lock().unwrap().push(ev))
1692 };
1693 let infra = SharedInfra::new();
1694 let _ = run_farm_out_with_progress(
1695 repo.path(),
1696 &[st("boom", &["src/x.rs"])],
1697 &FailAgent,
1698 &cfg(&["true"]),
1699 &infra,
1700 sink,
1701 )
1702 .await;
1703 let events = events.lock().unwrap();
1704 assert_eq!(events.len(), 2, "started + gated(error): {events:?}");
1705 assert!(
1706 matches!(
1707 &events[1],
1708 ForemanProgress::SubtaskGated { accepted: false, status, .. } if status == "error"
1709 ),
1710 "agent failure surfaces as an error gate: {:?}",
1711 events[1]
1712 );
1713 }
1714
1715 #[tokio::test]
1716 async fn declared_footprint_containment_rejects_out_of_scope_edit() {
1717 let repo = init_repo(); std::fs::write(
1722 repo.path().join("src/lib.rs"),
1723 "pub fn foo() {}\npub fn other() {}\n",
1724 )
1725 .unwrap();
1726 git_ok(repo.path(), &["commit", "-qam", "two fns"]);
1727
1728 let mut subtask = Subtask::files_only("a", "edit foo", vec!["src/lib.rs".into()]);
1729 subtask.footprint = Some(car_ast::SymbolFootprint::writing([
1730 car_ast::SymbolRef::new("src/lib.rs", "foo"),
1731 ]));
1732
1733 let agent = WriteAgent {
1735 path: "src/lib.rs".into(),
1736 content: "pub fn foo() -> u8 { 1 }\npub fn other() -> u8 { 2 }\n".into(),
1737 };
1738 let infra = SharedInfra::new();
1739 let result = run_farm_out(repo.path(), &[subtask], &agent, &cfg(&["true"]), &infra).await;
1740 let verdict = result.outcomes[0].verdict.as_ref().unwrap();
1741 assert!(
1742 matches!(verdict, MergeVerdict::Rejected { .. }),
1743 "out-of-footprint edit must be rejected: {verdict:?}"
1744 );
1745 assert!(verdict
1746 .evidence()
1747 .containment_violations
1748 .iter()
1749 .any(|v| v.changed.symbol == "other"));
1750 }
1751
1752 #[tokio::test]
1753 async fn workspace_failure_is_captured_not_propagated() {
1754 let dir = tempfile::tempdir().unwrap(); let agent = WriteAgent {
1756 path: "x".into(),
1757 content: String::new(),
1758 };
1759 let infra = SharedInfra::new();
1760 let result = run_farm_out(
1761 dir.path(),
1762 &[st("x", &["a.rs"])],
1763 &agent,
1764 &cfg(&["true"]),
1765 &infra,
1766 )
1767 .await;
1768 assert!(result.outcomes[0].verdict.is_none());
1769 assert!(result.outcomes[0].error.is_some());
1770 }
1771
1772 #[tokio::test]
1773 async fn union_integration_catches_cross_subtask_duplicate() {
1774 let repo = init_repo(); let infra = SharedInfra::new();
1781
1782 let agent_a = WriteAgent {
1783 path: "src/lib.rs".into(),
1784 content: "pub fn foo() {}\npub fn original() {}\n".into(), };
1786 let agent_b = WriteAgent {
1787 path: "src/lib.rs".into(),
1788 content: "pub fn original() {}\npub fn foo() {}\n".into(), };
1790
1791 let a = run_farm_out(
1792 repo.path(),
1793 &[st("a", &["src/lib.rs"])],
1794 &agent_a,
1795 &cfg(&["true"]),
1796 &infra,
1797 )
1798 .await;
1799 let b = run_farm_out(
1800 repo.path(),
1801 &[st("b", &["src/lib.rs"])],
1802 &agent_b,
1803 &cfg(&["true"]),
1804 &infra,
1805 )
1806 .await;
1807 assert!(
1809 a.outcomes[0].is_accepted(),
1810 "A alone: {:?}",
1811 a.outcomes[0].verdict
1812 );
1813 assert!(
1814 b.outcomes[0].is_accepted(),
1815 "B alone: {:?}",
1816 b.outcomes[0].verdict
1817 );
1818
1819 let patches = vec![
1820 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
1821 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
1822 ];
1823 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
1824 .await
1825 .unwrap();
1826 assert!(
1829 !integ.integrated_cleanly(),
1830 "union of two subtasks both adding foo must be rejected: {integ:?}"
1831 );
1832 }
1833
1834 #[tokio::test]
1835 async fn union_surfaces_overlapping_edit_as_apply_conflict() {
1836 let repo = init_repo();
1841 let infra = SharedInfra::new();
1842 let agent_a = WriteAgent {
1843 path: "src/lib.rs".into(),
1844 content: "pub fn original() -> u8 { 1 }\n".into(),
1845 };
1846 let agent_b = WriteAgent {
1847 path: "src/lib.rs".into(),
1848 content: "pub fn original() -> u16 { 2 }\n".into(),
1849 };
1850 let a = run_farm_out(
1851 repo.path(),
1852 &[st("a", &["src/lib.rs"])],
1853 &agent_a,
1854 &cfg(&["true"]),
1855 &infra,
1856 )
1857 .await;
1858 let b = run_farm_out(
1859 repo.path(),
1860 &[st("b", &["src/lib.rs"])],
1861 &agent_b,
1862 &cfg(&["true"]),
1863 &infra,
1864 )
1865 .await;
1866 let patches = vec![
1867 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
1868 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
1869 ];
1870 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
1871 .await
1872 .unwrap();
1873 assert!(
1874 !integ.apply_conflicts.is_empty(),
1875 "overlapping edit must conflict loudly: {integ:?}"
1876 );
1877 assert!(!integ.integrated_cleanly());
1878 }
1879
1880 #[tokio::test]
1881 async fn union_of_disjoint_subtasks_integrates_cleanly() {
1882 let repo = init_repo();
1883 let infra = SharedInfra::new();
1884 let agent_a = WriteAgent {
1885 path: "a.rs".into(),
1886 content: "pub fn a() {}\n".into(),
1887 };
1888 let agent_b = WriteAgent {
1889 path: "b.rs".into(),
1890 content: "pub fn b() {}\n".into(),
1891 };
1892 let a = run_farm_out(
1893 repo.path(),
1894 &[st("a", &["a.rs"])],
1895 &agent_a,
1896 &cfg(&["true"]),
1897 &infra,
1898 )
1899 .await;
1900 let b = run_farm_out(
1901 repo.path(),
1902 &[st("b", &["b.rs"])],
1903 &agent_b,
1904 &cfg(&["true"]),
1905 &infra,
1906 )
1907 .await;
1908 let patches = vec![
1909 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
1910 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
1911 ];
1912 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
1913 .await
1914 .unwrap();
1915 assert!(
1916 integ.integrated_cleanly(),
1917 "disjoint union integrates: {integ:?}"
1918 );
1919 }
1920
1921 #[tokio::test]
1922 async fn union_uses_union_verify_command_not_worktree_command() {
1923 let repo = init_repo();
1927 let infra = SharedInfra::new();
1928 let agent = WriteAgent {
1929 path: "src/lib.rs".into(),
1930 content: "pub fn original() {}\npub fn added() {}\n".into(),
1931 };
1932 let config = FarmOutConfig {
1933 verify_command: Some(crate::patterns::foreman::test_verify::pass()), union_verify_command: Some(crate::patterns::foreman::test_verify::fail()), ..Default::default()
1936 };
1937 let r = run_farm_out(
1938 repo.path(),
1939 &[st("a", &["src/lib.rs"])],
1940 &agent,
1941 &config,
1942 &infra,
1943 )
1944 .await;
1945 assert!(r.outcomes[0].is_accepted(), "per-worktree (true) accepts");
1946
1947 let patches = vec![("a".to_string(), r.outcomes[0].patch.clone().unwrap())];
1948 let integ = integrate_and_verify(repo.path(), "u", &patches, &config, &infra)
1949 .await
1950 .unwrap();
1951 assert!(
1952 !integ.integrated_cleanly(),
1953 "union must run union_verify_command (false) and reject: {integ:?}"
1954 );
1955 }
1956
1957 #[tokio::test]
1958 async fn regional_replan_resumes_from_clean_and_delivers() {
1959 let repo = init_repo();
1960 let infra = SharedInfra::new();
1961 let keeper = WriteAgent {
1963 path: "keep.rs".into(),
1964 content: "pub fn keep() {}\n".into(),
1965 };
1966 let k = run_farm_out(
1967 repo.path(),
1968 &[st("keep", &["keep.rs"])],
1969 &keeper,
1970 &cfg(&["true"]),
1971 &infra,
1972 )
1973 .await;
1974 let clean = vec![("keep".to_string(), k.outcomes[0].patch.clone().unwrap())];
1975
1976 let agent = WriteAgent {
1979 path: "good.txt".into(),
1980 content: "done".into(),
1981 };
1982 let config = FarmOutConfig {
1983 union_verify_command: Some(crate::patterns::foreman::test_verify::files_exist(&[
1984 "good.txt", "keep.rs",
1985 ])),
1986 ..Default::default()
1987 };
1988 let outcome = regional_replan(repo.path(), "finish it", &clean, &agent, &config, &infra)
1989 .await
1990 .expect("regional ran");
1991 assert!(
1992 outcome.is_accepted(),
1993 "regional delivered clean+region: {outcome:?}"
1994 );
1995 let patch = outcome.patch.unwrap();
1996 assert!(
1997 patch.contains("keep.rs"),
1998 "clean work preserved in result: {patch}"
1999 );
2000 assert!(patch.contains("good.txt"), "region work present: {patch}");
2001 }
2002
2003 #[tokio::test]
2004 async fn regional_replan_bails_when_a_clean_patch_does_not_apply() {
2005 let repo = init_repo();
2006 let infra = SharedInfra::new();
2007 let agent = WriteAgent {
2008 path: "good.txt".into(),
2009 content: "done".into(),
2010 };
2011 let clean = vec![(
2014 "broken".to_string(),
2015 "this is not a valid patch\n".to_string(),
2016 )];
2017 let outcome = regional_replan(
2018 repo.path(),
2019 "finish it",
2020 &clean,
2021 &agent,
2022 &cfg(&["true"]),
2023 &infra,
2024 )
2025 .await;
2026 assert!(outcome.is_none(), "unappliable clean set bails to fallback");
2027 }
2028
2029 #[test]
2030 fn localize_build_failure_picks_subtasks_whose_files_are_named() {
2031 let mut map = std::collections::HashMap::new();
2032 map.insert("src/a.rs".to_string(), vec!["a".to_string()]);
2033 map.insert("src/b.rs".to_string(), vec!["b".to_string()]);
2034 let ids = localize_build_failure("error[E0277]: in src/a.rs:42:5\n", &map);
2036 assert_eq!(
2037 ids,
2038 vec!["a".to_string()],
2039 "localized to the named file's subtask"
2040 );
2041 assert!(localize_build_failure("linker error, no file named\n", &map).is_empty());
2043 }
2044
2045 #[test]
2046 fn files_in_patch_parses_target_paths() {
2047 let patch = "diff --git a/src/foo.rs b/src/foo.rs\n\
2048 index e69de29..abc1234 100644\n\
2049 --- a/src/foo.rs\n+++ b/src/foo.rs\n\
2050 @@ -0,0 +1 @@\n+pub fn foo() {}\n\
2051 diff --git a/bar.rs b/bar.rs\n--- a/bar.rs\n+++ b/bar.rs\n";
2052 assert_eq!(
2053 files_in_patch(patch),
2054 vec!["src/foo.rs".to_string(), "bar.rs".to_string()]
2055 );
2056 }
2057
2058 #[tokio::test]
2059 async fn blame_attributes_apply_conflict_to_subtask_and_files() {
2060 let repo = init_repo();
2061 let infra = SharedInfra::new();
2062 let agent_a = WriteAgent {
2063 path: "src/lib.rs".into(),
2064 content: "pub fn original() -> u8 { 1 }\n".into(),
2065 };
2066 let agent_b = WriteAgent {
2067 path: "src/lib.rs".into(),
2068 content: "pub fn original() -> u16 { 2 }\n".into(),
2069 };
2070 let a = run_farm_out(
2071 repo.path(),
2072 &[st("a", &["src/lib.rs"])],
2073 &agent_a,
2074 &cfg(&["true"]),
2075 &infra,
2076 )
2077 .await;
2078 let b = run_farm_out(
2079 repo.path(),
2080 &[st("b", &["src/lib.rs"])],
2081 &agent_b,
2082 &cfg(&["true"]),
2083 &infra,
2084 )
2085 .await;
2086 let patches = vec![
2087 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
2088 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
2089 ];
2090 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
2091 .await
2092 .unwrap();
2093 let blame = integ.blame.expect("apply conflict produces blame");
2094 assert_eq!(blame.apply_conflicts.len(), 1, "{blame:?}");
2095 let c = &blame.apply_conflicts[0];
2096 assert_eq!(
2097 c.subtask_id, "b",
2098 "the second patch is the one that conflicts"
2099 );
2100 assert!(
2101 c.files.contains(&"src/lib.rs".to_string()),
2102 "files attributed: {c:?}"
2103 );
2104 }
2105
2106 #[tokio::test]
2107 async fn blame_carries_union_build_test_failure() {
2108 let repo = init_repo();
2109 let infra = SharedInfra::new();
2110 let agent = WriteAgent {
2111 path: "src/lib.rs".into(),
2112 content: "pub fn original() {}\npub fn added() {}\n".into(),
2113 };
2114 let config = FarmOutConfig {
2115 verify_command: Some(crate::patterns::foreman::test_verify::pass()),
2116 union_verify_command: Some(crate::patterns::foreman::test_verify::fail()), ..Default::default()
2118 };
2119 let r = run_farm_out(
2120 repo.path(),
2121 &[st("a", &["src/lib.rs"])],
2122 &agent,
2123 &config,
2124 &infra,
2125 )
2126 .await;
2127 let patches = vec![("a".to_string(), r.outcomes[0].patch.clone().unwrap())];
2128 let integ = integrate_and_verify(repo.path(), "u", &patches, &config, &infra)
2129 .await
2130 .unwrap();
2131 let blame = integ.blame.expect("rejected union produces blame");
2132 let bt = blame.build_test.expect("union build/test failure recorded");
2133 assert_eq!(bt.code, Some(1), "`false` exits 1: {bt:?}");
2134 assert_eq!(
2135 bt.candidate_subtask_ids,
2136 vec!["a".to_string()],
2137 "region named: {bt:?}"
2138 );
2139 }
2140
2141 #[tokio::test]
2142 async fn blame_attributes_duplicate_declaration_to_both_subtasks() {
2143 let repo = init_repo();
2147 let pad = "pub fn original() {}\npub fn p1() {}\npub fn p2() {}\npub fn p3() {}\n";
2148 std::fs::write(repo.path().join("src/lib.rs"), pad).unwrap();
2149 git_ok(repo.path(), &["commit", "-qam", "pad"]);
2150 let infra = SharedInfra::new();
2151 let agent_a = WriteAgent {
2152 path: "src/lib.rs".into(),
2153 content: format!("pub fn dup() {{}}\n{pad}"),
2154 };
2155 let agent_b = WriteAgent {
2156 path: "src/lib.rs".into(),
2157 content: format!("{pad}pub fn dup() {{}}\n"),
2158 };
2159 let a = run_farm_out(
2160 repo.path(),
2161 &[st("a", &["src/lib.rs"])],
2162 &agent_a,
2163 &cfg(&["true"]),
2164 &infra,
2165 )
2166 .await;
2167 let b = run_farm_out(
2168 repo.path(),
2169 &[st("b", &["src/lib.rs"])],
2170 &agent_b,
2171 &cfg(&["true"]),
2172 &infra,
2173 )
2174 .await;
2175 let patches = vec![
2176 ("a".to_string(), a.outcomes[0].patch.clone().unwrap()),
2177 ("b".to_string(), b.outcomes[0].patch.clone().unwrap()),
2178 ];
2179 let integ = integrate_and_verify(repo.path(), "ab", &patches, &cfg(&["true"]), &infra)
2180 .await
2181 .unwrap();
2182 assert!(
2183 integ.apply_conflicts.is_empty(),
2184 "disjoint hunks both apply: {integ:?}"
2185 );
2186 let blame = integ.blame.expect("duplicate union produces blame");
2187 let dup = blame
2188 .duplicate_conflicts
2189 .iter()
2190 .find(|d| d.symbol == "dup")
2191 .unwrap_or_else(|| panic!("duplicate `dup` attributed: {blame:?}"));
2192 assert_eq!(dup.file, "src/lib.rs");
2193 let mut ids = dup.candidate_subtask_ids.clone();
2194 ids.sort();
2195 assert_eq!(
2196 ids,
2197 vec!["a".to_string(), "b".to_string()],
2198 "both subtasks blamed"
2199 );
2200 }
2201}