Skip to main content

car_multi/patterns/foreman/
harness.rs

1//! B2 — the farm-out harness.
2//!
3//! Provisions a git worktree per subtask, runs an agent in it, captures the
4//! worktree's changes (both as [`FileChange`]s for the gate and as a git patch
5//! for later integration), and feeds the changes to the B1 [`gate`](super::gate).
6//!
7//! Two verification scopes:
8//! - **Per-worktree** ([`run_farm_out`]) — each subtask's worktree is gated
9//!   against its own base. This catches a subtask that is broken *on its own*.
10//! - **Union** ([`integrate_and_verify`]) — accepted subtasks' patches are
11//!   applied into one fresh staging worktree, in level order, and the gate runs
12//!   on the *integrated* tree. This is what catches the cross-subtask conflict
13//!   class (two subtasks each fine alone, broken when merged) that per-worktree
14//!   isolation is structurally blind to — the failure mode B3 must measure.
15//!
16//! This is still the *dumb* partitioner (file-disjoint leveling, no symbol-level
17//! footprints — that is B4) and there is no replan yet (B5).
18//!
19//! `car-multi` stays free of a `car-external-agents` dependency: the agent is a
20//! [`WorktreeAgent`] trait the caller implements. The daemon-side impl (B6)
21//! wraps `car_external_agents::invoke`; tests provide a stub.
22
23use 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/// A live per-subtask progress event from [`run_farm_out_with_progress`]. The
39/// run-level stages (planning / planned / union_verified) are emitted by the
40/// *caller* (e.g. the coder loop); this is the finer-grained per-subtask
41/// lifecycle the batch return value (`FarmOutResult`) otherwise only exposes
42/// after the whole run finishes. Used to drive a live UI (a foreman pane) that
43/// shows each subtask's worktree advancing and its gate verdict as it lands.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ForemanProgress {
46    /// A subtask's worktree was provisioned and its agent is starting. `index`
47    /// is its position in the original subtask list; `level` is its schedule
48    /// level (subtasks in the same level run concurrently); `total` is the batch
49    /// size.
50    SubtaskStarted {
51        subtask_id: String,
52        index: usize,
53        level: usize,
54        total: usize,
55    },
56    /// The agent finished editing; the gate's build/test is now running. This
57    /// phase can dominate wall-clock (a cold `cargo check` / `swift build`), so
58    /// it gets its own event — a live UI can show "verifying" distinctly from
59    /// "agent still editing" instead of one undifferentiated spinner.
60    SubtaskVerifying { subtask_id: String },
61    /// A subtask reached a terminal state. `status` is one of `accepted`,
62    /// `rejected`, `inconclusive`, or `error` (agent/workspace failed before the
63    /// gate could run). `accepted` mirrors `status == "accepted"` for callers
64    /// that only need the boolean.
65    SubtaskGated {
66        subtask_id: String,
67        accepted: bool,
68        status: String,
69    },
70}
71
72/// Sink for [`ForemanProgress`] events. Shared (`Arc`) and `Send + Sync` because
73/// subtasks within a schedule level fire concurrently from `join_all` futures.
74pub type ForemanProgressSink = Arc<dyn Fn(ForemanProgress) + Send + Sync>;
75
76/// Short terminal-status label for a gate verdict — the `status` field of
77/// [`ForemanProgress::SubtaskGated`].
78fn 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/// Errors from running the farm-out harness.
94#[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    /// The failure is a fact about the WORKER, not about the subtask: the
101    /// subtask never got a fair attempt, and this worker will fail the same way
102    /// on every remaining subtask of the run.
103    ///
104    /// Separate from [`ForemanError::Agent`] because a caller cannot tell the
105    /// two apart from a string, and the difference decides whether the worker is
106    /// still worth offering work. `FleetPool` quarantines a remote worker for
107    /// the rest of the run on this and only this (car#1323); a subtask that
108    /// merely failed must stay `Agent`, or a healthy machine gets removed for
109    /// having had one bad task.
110    #[error("worker cannot serve this run: {0}")]
111    Worker(String),
112    #[error("git error: {0}")]
113    Git(String),
114}
115
116/// One unit of farmed-out work. Under the B2 dumb partitioner, `files` is the
117/// set of repo-relative paths the subtask is expected to touch — the partition
118/// key. (B4 replaces this with symbol-level footprints.)
119#[derive(Debug, Clone)]
120pub struct Subtask {
121    pub id: String,
122    pub prompt: String,
123    /// Repo-relative paths this subtask is expected to touch — the dumb
124    /// partitioner's key, and a fallback when no footprint is declared.
125    pub files: Vec<String>,
126    /// The subtask's DECLARED symbol footprint (what it promised to write/read).
127    /// When present on every subtask, scheduling uses the B4 footprint analyzer
128    /// instead of the file partitioner, and the gate's containment is checked
129    /// against the declared *writes*. Kept distinct from the scheduler's
130    /// *expanded* (blast-radius) footprint — the gate must never see the
131    /// expanded set, or containment would silently widen.
132    pub footprint: Option<car_ast::SymbolFootprint>,
133}
134
135impl Subtask {
136    /// A file-only subtask (no symbol footprint — schedules via the dumb
137    /// partitioner, gate containment disabled).
138    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/// What an agent reported after running in a worktree.
153#[derive(Debug, Clone, Default)]
154pub struct AgentRunSummary {
155    pub answer: String,
156}
157
158/// Everything an agent needs to run one subtask in a worktree. Carrying the
159/// governance surface (`allowed_tools`, `mcp_endpoint`) now means the
160/// daemon-side impl that wraps `car_external_agents::invoke` does not force a
161/// breaking trait change at B6.
162#[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/// Runs an agent's prompt against a working tree, editing files in place. The
171/// real impl (daemon-side, B6) wraps `car_external_agents::invoke`; tests stub
172/// it. Keeping this a trait is what keeps `car-multi` independent of
173/// `car-external-agents`.
174#[async_trait]
175pub trait WorktreeAgent: Send + Sync {
176    async fn run_in(&self, req: &WorktreeAgentRequest<'_>)
177        -> Result<AgentRunSummary, ForemanError>;
178}
179
180/// Tuning for a farm-out run.
181#[derive(Debug, Clone, Default)]
182pub struct FarmOutConfig {
183    /// Per-worktree gate command — a **regression** check ("does this one
184    /// subtask's change compile / not break existing tests?"). A subtask
185    /// legitimately implements only PART of the goal, so a goal-level test that
186    /// needs every subtask must NOT run here (it would reject each subtask).
187    pub verify_command: Option<Vec<String>>,
188    /// Integrated-**union** gate command — the **goal** check ("does the merged
189    /// result actually achieve the goal?"). Falls back to [`verify_command`]
190    /// when `None`, so callers that want one command for both can leave it unset.
191    ///
192    /// [`verify_command`]: FarmOutConfig::verify_command
193    pub union_verify_command: Option<Vec<String>>,
194    /// Tool allowlist passed to each agent invocation.
195    pub allowed_tools: Option<Vec<String>>,
196    /// MCP governance endpoint passed to each agent invocation.
197    pub mcp_endpoint: Option<String>,
198    /// When the decomposed parallel path fails to integrate, recover by
199    /// re-running the whole goal as one session (see `run_foreman`). **Off by
200    /// default** because it spends a full extra (serial) session on top of the
201    /// failed parallel spend — delivery-first callers (e.g. the daemon's
202    /// `foreman.run`) opt in; cost-sensitive callers leave it off and inspect the
203    /// `delivered()` flag / retained integration evidence instead.
204    pub recover_via_single_session: bool,
205    /// Base directory under which per-subtask git worktrees are created. When
206    /// `None`, defaults to an **out-of-repo** location derived from `repo_root`
207    /// (see [`default_worktree_base`]) so a worktree — and especially one
208    /// *leaked* by a crash mid-run — never appears as an untracked entry inside
209    /// the caller's checkout (which would make every later `git status` read
210    /// dirty). Set this to pin worktrees to a specific scratch area, e.g. a
211    /// daemon state dir.
212    pub worktree_base: Option<PathBuf>,
213    /// Explicit waiver letting the gate accept a change with **no** configured
214    /// build/test command. Without it, a missing verify command yields
215    /// [`MergeVerdict::Inconclusive`] (fail-closed), never `Accepted` — so a
216    /// decomposed run over a project with no reliably-detectable build command
217    /// can never deliver. Supply this when containment + apply-conflict +
218    /// duplicate-declaration checks are the intended soundness boundary and a
219    /// build leg genuinely can't be determined. Threaded into every gate
220    /// (per-subtask, union, regional).
221    pub no_verify_waiver: Option<NoVerifyWaiver>,
222}
223
224/// Default **out-of-repo** base for Foreman worktrees, keyed by the repo path so
225/// distinct repos don't collide and a crashed run can't leave worktree
226/// directories inside the caller's checkout (where `git status` would report
227/// them as untracked and any consumer guarding on a clean tree would wedge).
228/// Deterministic for a given `repo_root`.
229fn 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
239/// Build the worktree [`WorkspaceConfig`] for a farm-out run: a git worktree of
240/// `repo_root` created under an out-of-repo base (overridable via
241/// [`FarmOutConfig::worktree_base`]). Uses [`WorkspaceConfig::git_worktree_at`]
242/// so the worktree's working directory lives outside the repo.
243fn 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
251/// Group subtasks into levels of file-disjoint work (dumb partitioner): within a
252/// level no two subtasks declare the same file, so their worktrees cannot merge
253/// conflict; subtasks that share a file land in different levels (serialized).
254/// Greedy first-fit; B4 replaces it with footprint-aware scheduling.
255///
256/// A subtask that declares *no* files has unknown blast radius, so it gets its
257/// own isolated level rather than packing in parallel with everything (which it
258/// might actually collide with at runtime).
259pub fn partition_by_files(subtasks: &[Subtask]) -> Vec<Vec<usize>> {
260    struct Level {
261        ids: Vec<usize>,
262        claimed: HashSet<String>,
263        /// Closed levels (the isolated no-files ones) never accept more members.
264        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
296/// Blast-radius expansion depth for footprint scheduling. Shared with the
297/// planner's pre-check so the two cannot drift.
298pub(crate) const FOOTPRINT_BLAST_DEPTH: usize = 3;
299
300/// Choose the parallel schedule. When EVERY subtask declares a symbol footprint,
301/// use the B4 footprint analyzer (build a `ProjectIndex`, expand each declared
302/// footprint to its blast radius, and level by symbol conflicts — fail-closed on
303/// uncertainty). Otherwise fall back to the dumb file partitioner. Returns levels
304/// of indices into `subtasks`.
305fn 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    // Duplicate ids would make the id→index map below ambiguous and silently drop
310    // a subtask; the index-based file partitioner is safe in that case.
311    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            // Scheduler consumes the EXPANDED footprint (blast radius). The gate
321            // separately consumes the DECLARED footprint — never this one.
322            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/// Outcome of one farmed-out subtask. `verdict` is `None` only if the agent or
342/// workspace failed before the gate could run (captured in `error`); one
343/// subtask's failure never aborts the batch. `patch` (when present) is the git
344/// patch of this subtask's changes, retained so [`integrate_and_verify`] can
345/// replay it into a staging tree after the worktree is gone.
346#[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/// Result of farming out a batch of subtasks.
362#[derive(Debug)]
363pub struct FarmOutResult {
364    /// The dependency levels the partitioner produced (indices into `subtasks`).
365    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
375/// Provision a worktree per subtask, run the agent in it, capture its changes
376/// (as `FileChange`s and a retained patch), and run the B1 gate per worktree.
377/// Levels run sequentially; subtasks within a level run concurrently. Each
378/// worktree is cleaned up when its `AgentWorkspace` drops, after the gate has run
379/// its build/test inside that tree — which is why the patch is captured first.
380pub 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
390/// Like [`run_farm_out`], but streams [`ForemanProgress`] events to `progress`
391/// as each subtask's worktree starts and gates. The return value is identical;
392/// the sink is purely for live observation (a UI pane). Events from subtasks in
393/// the same schedule level interleave (they run concurrently).
394pub 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    // Clone so the helper closures hold an owned `Option<ForemanProgressSink>`
450    // (the `&ForemanProgressSink` borrow can't outlive the early-return paths).
451    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    // Index-prefix the workspace name so two subtask ids that sanitize to the
480    // same directory cannot collide and self-heal-clobber each other's worktree.
481    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    // The gate's containment checks the DECLARED writes only (never the
522    // scheduler's expanded blast radius). No declaration ⇒ containment disabled.
523    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/// Result of integrating accepted subtasks and gating the union.
547#[derive(Debug)]
548pub struct IntegrationResult {
549    pub applied: usize,
550    /// Patches that failed to apply cleanly (textual integration conflicts).
551    /// Human-readable `"{subtask_id}: {error}"` strings; [`blame`] carries the
552    /// structured form.
553    ///
554    /// [`blame`]: IntegrationResult::blame
555    pub apply_conflicts: Vec<String>,
556    /// Verdict on the integrated union — `None` if a patch failed to apply
557    /// (which is itself an integration failure the caller must treat as reject).
558    pub verdict: Option<MergeVerdict>,
559    /// Structured attribution of *why* the union failed — which subtask / file /
560    /// symbol / check is implicated. `None` when the union integrated cleanly.
561    pub blame: Option<IntegrationBlame>,
562}
563
564impl IntegrationResult {
565    /// The integration is sound only if every patch applied AND the union gate
566    /// accepted. A textual apply conflict is never a success.
567    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/// A patch that failed to apply during union integration, attributed to its
573/// subtask and the files its patch targets (parsed from the patch headers).
574#[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/// A duplicate declaration the union gate found. `candidate_subtask_ids` are the
582/// subtasks whose patches touched the offending *file* — candidates, not proven
583/// culprits: file-granularity can implicate a subtask that edited the file but
584/// not the duplicated symbol. Authoritative symbol-level culpability would need
585/// the declared footprints; advisory blame deliberately stays at file level.
586#[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/// The union build/test leg's failure (the goal check that rejected the merge).
594/// `candidate_subtask_ids` is the localized region: the subtasks whose touched
595/// files appear in the failure output (compilers print `file:line`), biased to
596/// inclusion. It falls back to the WHOLE integrated set when the output names no
597/// known file — we can't localize, so the caller redoes everything (whole-goal)
598/// rather than risk dropping the real culprit. See [`localize_build_failure`].
599#[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/// Structured attribution of *why* a union integration failed — which subtask,
607/// file, symbol, or check is implicated. Populated only on failure. This is the
608/// data a regional replan needs to retry just the failing region (instead of
609/// re-running the whole goal), and what a UI reads to show "why did this run
610/// fail", not merely "it failed".
611///
612/// Deliberately out of scope: parsing the build/test *output* to map a failing
613/// test back to a symbol — that needs a language-specific parser. The raw
614/// (bounded) output tail is retained in [`build_test`] instead.
615///
616/// [`build_test`]: IntegrationBlame::build_test
617#[derive(Debug, Clone, Default, PartialEq, Eq)]
618pub struct IntegrationBlame {
619    /// Patches that failed to apply (textual conflicts), attributed to subtask + files.
620    pub apply_conflicts: Vec<ApplyConflict>,
621    /// Duplicate declarations the union gate found, attributed to subtasks.
622    pub duplicate_conflicts: Vec<DuplicateBlame>,
623    /// The union build/test failure, when that is what rejected the merge.
624    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    /// The subtasks a regional replan must DROP and redo — every subtask any
635    /// failure cause implicates. A `build_test` failure carries its *localized*
636    /// region (the subtasks whose files the failure output named); when that
637    /// couldn't be localized it carries the whole integrated set instead, which
638    /// forces the implicated set to everything ⇒ the caller computes an empty
639    /// "clean" set ⇒ it falls back to a whole-goal session rather than a regional
640    /// one.
641    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
656/// Subtasks a build/test failure implicates, by matching the files they touched
657/// against the paths named in the failure output (compilers and panics print
658/// `file:line`). Biased toward INCLUSION: an over-broad region just makes the
659/// regional replan redo more (still gated), whereas under-inclusion would
660/// reproduce the failure (caught by the gate → whole-goal fallback). An empty
661/// result means the output named no known file — the caller can't localize and
662/// keeps the whole integrated set.
663fn 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        // Plain (non-boundary-aware) substring: a short name like `x.rs` can also
670        // match `xxx.rs` in the output, over-implicating. That's deliberate — the
671        // inclusion bias is the safe direction (over-broad region just redoes
672        // more, still gated; under-inclusion would reproduce the failure). Do NOT
673        // "fix" this into a boundary check without preserving that bias.
674        if output.contains(file.as_str()) {
675            ids.extend(subtasks.iter().cloned());
676        }
677    }
678    ids.into_iter().collect()
679}
680
681/// Repo-relative file paths a unified-diff patch targets, parsed from its
682/// `diff --git a/<x> b/<y>` headers (the `b/` post-image side). Used to attribute
683/// an apply conflict / duplicate to the files — and thence the subtasks —
684/// involved, without re-running git. Best-effort: paths with embedded `" b/"`
685/// are rare and only degrade blame precision, never correctness elsewhere.
686/// Repo-relative paths a unified diff touches, read off its `diff --git` headers.
687///
688/// Public because a caller that INTEGRATES a patch needs the same answer the
689/// gate computes: a subtask id is opaque model output, so "s2 ran on studio"
690/// is only reviewable next to the files s2 actually contributed (car#1322).
691/// One parser, so the gate's view and the delivered provenance cannot disagree.
692pub 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
707/// Apply accepted subtasks' patches into one fresh staging worktree from the
708/// common base, in the given order, and run the gate on the **integrated union**.
709/// This is the integration-failure check the per-worktree gate cannot perform:
710/// two subtasks each accepted in isolation can still produce duplicate
711/// declarations, broken references, or a failing build once merged.
712///
713/// `subtask_label` labels the union for the gate's audit/subtask field.
714pub async fn integrate_and_verify(
715    repo_root: &Path,
716    subtask_label: &str,
717    accepted_patches: &[(String, String)], // (subtask_id, patch)
718    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    // The union is graded by the GOAL check (union_verify_command), falling back
730    // to the per-worktree command when the caller supplied only one.
731    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    // Map file → subtasks whose patch touched it, for blame attribution. Built
738    // from the patch headers, so a duplicate the union gate finds in a file can
739    // name the subtasks that contributed it. `union_members` is the whole
740    // integrated set (deterministic order) — the region for a build/test failure
741    // that can't be localized further.
742    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    // Apply + collect is pure git I/O — keep it off the async executor.
755    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    // A patch that didn't apply is a textual integration conflict — do not run
777    // the gate on a partial tree; report it as the integration failure it is.
778    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    // On a rejected/inconclusive union, attribute the gate's findings: each
806    // duplicate declaration to the subtasks that touched its file, plus the
807    // build/test failure (raw output tail; symbol-level test blame is deferred).
808    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                // Localize by matching the failure output against the files each
824                // subtask touched. When the output names no known file we can't
825                // localize → keep the whole integrated set (the conservative
826                // region, which routes the caller to a whole-goal recovery).
827                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(), // already bounded by the gate's max_output_bytes
836                    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
856/// Recover a failed parallel union by RESUMING from the clean (non-implicated)
857/// accepted patches and completing the goal in one session — redoing only the
858/// failing region while preserving the successful parallel work.
859///
860/// `clean_patches` are the accepted patches blame did NOT implicate. They are
861/// applied (uncommitted) into a fresh worktree; the agent then runs over the
862/// ORIGINAL `goal`, sees the clean work already on disk, and completes only what
863/// is missing. The full clean+region tree is gated with the GOAL check (the same
864/// merge-verify gate) and returned as one [`SubtaskOutcome`].
865///
866/// Returns `None` when the regional attempt could not even be staged — a clean
867/// patch failed to apply (the clean set is NOT guaranteed internally consistent;
868/// this is a *checked* precondition, not an assumption), the workspace couldn't
869/// be provisioned, or the agent/capture errored — so the caller falls back to a
870/// whole-goal session. A `Some` outcome that isn't accepted likewise signals the
871/// caller to fall back; the gate stays the soundness boundary either way.
872pub 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    // Checked precondition (per design review): the clean set is NOT guaranteed
888    // to apply cleanly among itself — verify it actually does, and bail to the
889    // whole-goal fallback if not. We're provisioning the worktree anyway.
890    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    // Resume the ORIGINAL goal — the agent sees the clean work on disk and only
910    // does the remainder. No footprint ⇒ containment off (this is whole-of-region
911    // work, not a declared single symbol).
912    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    // Gate the full clean+region tree with the GOAL check (union command, falling
934    // back to the per-worktree one) — it must achieve the whole goal, not a piece.
935    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
959/// Derive the uncommitted changes in a worktree (relative to its HEAD) as
960/// [`FileChange`]s. Handles `-z` porcelain correctly, including renames (emitted
961/// as two NUL fields) and non-UTF8 files (kept as lossy text rather than
962/// silently dropped, so policy and the gate's `unparsed` path still see them).
963fn 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        // A primary entry is "XY <path>" — 2 status columns, a space, the path.
973        if entry.len() < 4 {
974            continue;
975        }
976        // Index (staged) column. Only X carries the rename/copy source field in
977        // porcelain v1; the worktree column Y never does.
978        let x = entry.as_bytes()[0];
979        let path = entry[3..].to_string();
980
981        // Renames/copies put the SOURCE path in the *next* NUL field with no
982        // status prefix. Only the INDEX column (X) carries this in porcelain v1;
983        // the worktree column (Y) never does. Consume the source field and emit
984        // its deletion side so a rename's removed symbols still reach the gate.
985        // (A copy's source isn't really deleted; modeling it as a deletion is
986        // conservative — it can only make the gate stricter, never miss a
987        // conflict.)
988        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
1020/// Content of `path` at HEAD, or `None` if it didn't exist there. The `./`
1021/// prefix stops git from interpreting a leading `:` or other revision syntax.
1022fn read_head(worktree: &Path, path: &str) -> Option<String> {
1023    git(worktree, &["show", &format!("HEAD:./{path}")]).ok()
1024}
1025
1026/// Worktree content of `path`, lossily decoded so binary files are surfaced
1027/// (and recorded by the gate as unparsed) rather than silently dropped.
1028fn 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
1034/// Capture the full patch of a worktree's changes (including untracked and
1035/// binary files) so it can be replayed into a staging tree later.
1036///
1037/// Public because a **fleet worker** produces exactly this: a peer runs the
1038/// subtask in its own worktree and returns the patch, which the orchestrator
1039/// replays into the worktree it is about to gate. Same bytes, same guarantees —
1040/// one function rather than a second, subtly-different capture on the far side.
1041pub fn capture_patch(worktree: &Path) -> Result<String, ForemanError> {
1042    // `add -N` registers untracked files so `diff` includes them, without
1043    // staging content (keeps the worktree state otherwise untouched).
1044    git(worktree, &["add", "-AN"])?;
1045    // `--no-textconv` + autocrlf off keep the patch a faithful byte image even
1046    // when the target repo has textconv/clean filters or CRLF normalization, so
1047    // it re-applies identically in the staging tree.
1048    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
1075/// Apply a patch into a worktree. Plain `git apply` (NOT `--3way`) on purpose:
1076/// a false-accept benchmark must surface overlapping/adjacent edits as loud
1077/// integration conflicts, not silently auto-merge them. A failure here is a
1078/// textual integration conflict the caller treats as an integration failure.
1079///
1080/// Public for the same reason as [`capture_patch`]: a fleet worker's patch is
1081/// replayed into the local worktree through this exact call, so a remote
1082/// subtask and a local one leave the tree in the same state.
1083pub 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    // ---- partitioner ----
1123
1124    #[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        // The no-files subtask must NOT pack with the file-declaring ones.
1141        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    // ---- git plumbing through a real repo ----
1148
1149    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        // Rename it — this is the case the blind parser corrupted.
1183        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        // Map the POSIX pass/fail sentinels these fixtures use to the
1225        // shell-neutral equivalent — the gate runs the verify command directly,
1226        // and `true`/`false` are POSIX binaries that don't exist on Windows.
1227        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    /// Records what `src/upstream.rs` looked like from inside each subtask's
1239    /// worktree.
1240    #[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    /// A dependent subtask does NOT see the work it declared a read on, and that
1263    /// is load-bearing rather than an oversight. Do not "fix" it by staging
1264    /// upstream patches into downstream worktrees.
1265    ///
1266    /// The design (`docs/proposals/verified-parallel-coding-orchestrator.md`)
1267    /// says "write-set(A) ∩ read-set(B) ≠ ∅ → dependency edge A→B: B reads what A
1268    /// writes, so B runs after A." The scheduler honors the ordering, but every
1269    /// worktree is provisioned from `repo_root` HEAD, so B opens the file it
1270    /// declared a read on and finds base content — it pays the serialization and
1271    /// codes against a contract it cannot execute.
1272    ///
1273    /// The obvious repair — apply accepted upstream patches into the worktree and
1274    /// commit them so `git diff HEAD` still yields only B's own work — was
1275    /// implemented and **it makes the merge-verify gate unsound**. Two subtasks
1276    /// writing the SAME file are put in separate levels by the partitioner, so B
1277    /// would receive A's edit, overwrite the file, and produce a patch that reads
1278    /// as a clean successive edit. The union then applies A then B without
1279    /// complaint and silently reverts A, where today it raises the conflict. The
1280    /// `foreman_false_accept_gauntlet` scenarios `broken_union_overlapping_edit`
1281    /// and `broken_union_duplicate_definition` both flip to false accepts —
1282    /// "broken merge marked safe", the exact class that gauntlet exists to
1283    /// prevent.
1284    ///
1285    /// The reason is structural: the gate detects conflicts because every patch
1286    /// is an INDEPENDENT diff from the same base. Staging makes patches
1287    /// sequentially dependent, which converts conflict detection into
1288    /// last-writer-wins. Delivering on the dependency edge therefore needs a
1289    /// mechanism that does not disturb that invariant — the design's own step 4,
1290    /// hoisting a *contract* the dependent reads (the interface, not the
1291    /// implementation) — not patch staging.
1292    ///
1293    /// Full write-up, including the per-scenario walk-through:
1294    /// `docs/solutions/foreman-patches-must-stay-independent-diffs.md`.
1295    #[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        // The edge really does serialize them — otherwise this test would be
1347        // asserting something the scheduler never promised.
1348        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        // Deterministic per repo path.
1398        assert_eq!(base, default_worktree_base(root));
1399        // git_worktree_at carries the repo so worktrees are of `root`, under `base`.
1400        let cfg = FarmOutConfig::default();
1401        let _ = worktree_workspace_config(root, &cfg); // smoke: builds without panic
1402    }
1403
1404    #[tokio::test]
1405    async fn worktrees_are_provisioned_outside_the_repo() {
1406        // The agent records the worktree cwd it was handed; assert it lives
1407        // outside the repo so a crash can't leak dirs into the user's checkout.
1408        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        // No verify command and no waiver → the gate is fail-closed (Inconclusive),
1458        // so the subtask is NOT accepted.
1459        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        // Same change, with an explicit waiver → accepted on containment alone,
1474        // but NOT build-verified.
1475        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        // Two file-disjoint subtasks: each writes its own new file (so the gate
1502        // accepts — `true` always passes — and footprint containment is off).
1503        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        // Agent that always errors before the gate — terminal status is `error`.
1559        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        // Subtask declares it will only write `foo`, but the agent also edits
1600        // `other` — the gate's containment (fed the DECLARED footprint) rejects.
1601        let repo = init_repo(); // base src/lib.rs = "pub fn original() {}\n"
1602                                // Seed a second symbol to escape to.
1603        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        // Agent edits BOTH foo (allowed) and other (NOT allowed).
1616        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(); // not a git repo
1637        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        // The integration-failure class per-worktree isolation is blind to:
1657        // both subtasks add `fn foo` to src/lib.rs at DIFFERENT positions. Each
1658        // worktree builds and is accepted alone. The two diffs are disjoint
1659        // hunks, so 3-way merge applies BOTH — yielding two `foo` in the union,
1660        // a duplicate declaration only the union gate can see.
1661        let repo = init_repo(); // base src/lib.rs = "pub fn original() {}\n"
1662        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(), // foo before
1667        };
1668        let agent_b = WriteAgent {
1669            path: "src/lib.rs".into(),
1670            content: "pub fn original() {}\npub fn foo() {}\n".into(), // foo after
1671        };
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        // Each is fine in isolation — neither worktree has a duplicate.
1690        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        // The union has two `foo` — must NOT integrate cleanly. (Caught either as
1709        // a 3-way apply conflict or as a duplicate declaration by the gate.)
1710        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        // Both subtasks rewrite the SAME line of src/lib.rs differently. Each is
1719        // accepted alone. Plain `git apply` (not --3way) must surface the second
1720        // as a loud apply conflict rather than silently auto-merging — exactly
1721        // the under-reporting a false-accept benchmark must avoid.
1722        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        // Per-worktree gate (verify_command) passes, but the union goal check
1806        // (union_verify_command) fails → the subtask is accepted in isolation yet
1807        // the union is rejected. Proves the two gates use different commands.
1808        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()), // per-worktree regression: pass
1816            union_verify_command: Some(crate::patterns::foreman::test_verify::fail()), // union goal: fail
1817            ..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        // A "clean" patch from a prior accepted subtask: it created keep.rs.
1844        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        // Regional resumes: applies keep.rs, the agent completes the goal (writes
1859        // good.txt), gated by a goal check requiring BOTH (clean preserved + done).
1860        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        // A clean patch that can't apply → the clean set isn't internally
1894        // consistent → bail (None) so the caller falls back to a whole-goal session.
1895        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        // Compiler output naming a.rs → only subtask a is implicated.
1917        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        // Output naming no known file → empty (caller keeps the whole set).
1924        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()), // union goal fails
1999            ..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        // Two subtasks each add `fn dup` to src/lib.rs in disjoint hunks: both
2026        // patches apply, but the union has a duplicate the gate flags — and blame
2027        // attributes it to BOTH a and b (the subtasks whose patches touched the file).
2028        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}