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    /// Directory the daemon-side agent writes its short-lived MCP config file
169    /// into (`car_external_agents::InvokeOptions::mcp_config_dir`). `None` keeps
170    /// the adapter's original behaviour, a bare `tempfile()` under `$TMPDIR`.
171    ///
172    /// `$TMPDIR` is inherited by the daemon and never checked: a daemon launched
173    /// with a stale installer-sandbox value cannot create that file, every
174    /// `--mcp-config` invocation fails setup, and the session silently runs a
175    /// different engine (car#1494 / car#1534). car#1534 hardened the coder's own
176    /// external loop, which owns a state directory; a farm-out reaches the same
177    /// adapter through this request, so the session's already-checked directory
178    /// has to travel with it.
179    ///
180    /// Only meaningful alongside `mcp_endpoint` — with no endpoint the adapter
181    /// writes no config at all, so the directory is never consulted.
182    pub mcp_config_dir: Option<PathBuf>,
183}
184
185/// Runs an agent's prompt against a working tree, editing files in place. The
186/// real impl (daemon-side, B6) wraps `car_external_agents::invoke`; tests stub
187/// it. Keeping this a trait is what keeps `car-multi` independent of
188/// `car-external-agents`.
189#[async_trait]
190pub trait WorktreeAgent: Send + Sync {
191    async fn run_in(&self, req: &WorktreeAgentRequest<'_>)
192        -> Result<AgentRunSummary, ForemanError>;
193}
194
195/// Tuning for a farm-out run.
196#[derive(Debug, Clone, Default)]
197pub struct FarmOutConfig {
198    /// Per-worktree gate command — a **regression** check ("does this one
199    /// subtask's change compile / not break existing tests?"). A subtask
200    /// legitimately implements only PART of the goal, so a goal-level test that
201    /// needs every subtask must NOT run here (it would reject each subtask).
202    pub verify_command: Option<Vec<String>>,
203    /// Integrated-**union** gate command — the **goal** check ("does the merged
204    /// result actually achieve the goal?"). Falls back to [`verify_command`]
205    /// when `None`, so callers that want one command for both can leave it unset.
206    ///
207    /// [`verify_command`]: FarmOutConfig::verify_command
208    pub union_verify_command: Option<Vec<String>>,
209    /// Tool allowlist passed to each agent invocation.
210    pub allowed_tools: Option<Vec<String>>,
211    /// MCP governance endpoint passed to each agent invocation.
212    pub mcp_endpoint: Option<String>,
213    /// Directory each agent invocation writes its MCP config file into, copied
214    /// onto every [`WorktreeAgentRequest`] this run builds. `None` — the default
215    /// — leaves the agent on `$TMPDIR`, which is what every caller that owns no
216    /// checked directory keeps doing. A caller that has one (the coder session's
217    /// state directory) passes it and takes the adapter off an environment
218    /// variable nobody validated (car#1494 / car#1534).
219    pub mcp_config_dir: Option<PathBuf>,
220    /// When the decomposed parallel path fails to integrate, recover by
221    /// re-running the whole goal as one session (see `run_foreman`). **Off by
222    /// default** because it spends a full extra (serial) session on top of the
223    /// failed parallel spend — delivery-first callers (e.g. the daemon's
224    /// `foreman.run`) opt in; cost-sensitive callers leave it off and inspect the
225    /// `delivered()` flag / retained integration evidence instead.
226    pub recover_via_single_session: bool,
227    /// Base directory under which per-subtask git worktrees are created. When
228    /// `None`, defaults to an **out-of-repo** location derived from `repo_root`
229    /// (see [`default_worktree_base`]) so a worktree — and especially one
230    /// *leaked* by a crash mid-run — never appears as an untracked entry inside
231    /// the caller's checkout (which would make every later `git status` read
232    /// dirty). Set this to pin worktrees to a specific scratch area, e.g. a
233    /// daemon state dir.
234    pub worktree_base: Option<PathBuf>,
235    /// Explicit waiver letting the gate accept a change with **no** configured
236    /// build/test command. Without it, a missing verify command yields
237    /// [`MergeVerdict::Inconclusive`] (fail-closed), never `Accepted` — so a
238    /// decomposed run over a project with no reliably-detectable build command
239    /// can never deliver. Supply this when containment + apply-conflict +
240    /// duplicate-declaration checks are the intended soundness boundary and a
241    /// build leg genuinely can't be determined. Threaded into every gate
242    /// (per-subtask, union, regional).
243    pub no_verify_waiver: Option<NoVerifyWaiver>,
244}
245
246/// Default **out-of-repo** base for Foreman worktrees, keyed by the repo path so
247/// distinct repos don't collide and a crashed run can't leave worktree
248/// directories inside the caller's checkout (where `git status` would report
249/// them as untracked and any consumer guarding on a clean tree would wedge).
250/// Deterministic for a given `repo_root`.
251fn 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
261/// Build the worktree [`WorkspaceConfig`] for a farm-out run: a git worktree of
262/// `repo_root` created under an out-of-repo base (overridable via
263/// [`FarmOutConfig::worktree_base`]). Uses [`WorkspaceConfig::git_worktree_at`]
264/// so the worktree's working directory lives outside the repo.
265fn 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
273/// Group subtasks into levels of file-disjoint work (dumb partitioner): within a
274/// level no two subtasks declare the same file, so their worktrees cannot merge
275/// conflict; subtasks that share a file land in different levels (serialized).
276/// Greedy first-fit; B4 replaces it with footprint-aware scheduling.
277///
278/// A subtask that declares *no* files has unknown blast radius, so it gets its
279/// own isolated level rather than packing in parallel with everything (which it
280/// might actually collide with at runtime).
281pub fn partition_by_files(subtasks: &[Subtask]) -> Vec<Vec<usize>> {
282    struct Level {
283        ids: Vec<usize>,
284        claimed: HashSet<String>,
285        /// Closed levels (the isolated no-files ones) never accept more members.
286        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
318/// Blast-radius expansion depth for footprint scheduling. Shared with the
319/// planner's pre-check so the two cannot drift.
320pub(crate) const FOOTPRINT_BLAST_DEPTH: usize = 3;
321
322/// Choose the parallel schedule. When EVERY subtask declares a symbol footprint,
323/// use the B4 footprint analyzer (build a `ProjectIndex`, expand each declared
324/// footprint to its blast radius, and level by symbol conflicts — fail-closed on
325/// uncertainty). Otherwise fall back to the dumb file partitioner. Returns levels
326/// of indices into `subtasks`.
327fn 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    // Duplicate ids would make the id→index map below ambiguous and silently drop
332    // a subtask; the index-based file partitioner is safe in that case.
333    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            // Scheduler consumes the EXPANDED footprint (blast radius). The gate
343            // separately consumes the DECLARED footprint — never this one.
344            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/// Outcome of one farmed-out subtask. `verdict` is `None` only if the agent or
364/// workspace failed before the gate could run (captured in `error`); one
365/// subtask's failure never aborts the batch. `patch` (when present) is the git
366/// patch of this subtask's changes, retained so [`integrate_and_verify`] can
367/// replay it into a staging tree after the worktree is gone.
368#[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/// Result of farming out a batch of subtasks.
384#[derive(Debug)]
385pub struct FarmOutResult {
386    /// The dependency levels the partitioner produced (indices into `subtasks`).
387    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
397/// Provision a worktree per subtask, run the agent in it, capture its changes
398/// (as `FileChange`s and a retained patch), and run the B1 gate per worktree.
399/// Levels run sequentially; subtasks within a level run concurrently. Each
400/// worktree is cleaned up when its `AgentWorkspace` drops, after the gate has run
401/// its build/test inside that tree — which is why the patch is captured first.
402pub 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
412/// Like [`run_farm_out`], but streams [`ForemanProgress`] events to `progress`
413/// as each subtask's worktree starts and gates. The return value is identical;
414/// the sink is purely for live observation (a UI pane). Events from subtasks in
415/// the same schedule level interleave (they run concurrently).
416pub 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    // Clone so the helper closures hold an owned `Option<ForemanProgressSink>`
472    // (the `&ForemanProgressSink` borrow can't outlive the early-return paths).
473    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    // Index-prefix the workspace name so two subtask ids that sanitize to the
502    // same directory cannot collide and self-heal-clobber each other's worktree.
503    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    // The gate's containment checks the DECLARED writes only (never the
545    // scheduler's expanded blast radius). No declaration ⇒ containment disabled.
546    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/// Result of integrating accepted subtasks and gating the union.
570#[derive(Debug)]
571pub struct IntegrationResult {
572    pub applied: usize,
573    /// Patches that failed to apply cleanly (textual integration conflicts).
574    /// Human-readable `"{subtask_id}: {error}"` strings; [`blame`] carries the
575    /// structured form.
576    ///
577    /// [`blame`]: IntegrationResult::blame
578    pub apply_conflicts: Vec<String>,
579    /// Verdict on the integrated union — `None` if a patch failed to apply
580    /// (which is itself an integration failure the caller must treat as reject).
581    pub verdict: Option<MergeVerdict>,
582    /// Structured attribution of *why* the union failed — which subtask / file /
583    /// symbol / check is implicated. `None` when the union integrated cleanly.
584    pub blame: Option<IntegrationBlame>,
585}
586
587impl IntegrationResult {
588    /// The integration is sound only if every patch applied AND the union gate
589    /// accepted. A textual apply conflict is never a success.
590    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/// A patch that failed to apply during union integration, attributed to its
596/// subtask and the files its patch targets (parsed from the patch headers).
597#[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/// A duplicate declaration the union gate found. `candidate_subtask_ids` are the
605/// subtasks whose patches touched the offending *file* — candidates, not proven
606/// culprits: file-granularity can implicate a subtask that edited the file but
607/// not the duplicated symbol. Authoritative symbol-level culpability would need
608/// the declared footprints; advisory blame deliberately stays at file level.
609#[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/// The union build/test leg's failure (the goal check that rejected the merge).
617/// `candidate_subtask_ids` is the localized region: the subtasks whose touched
618/// files appear in the failure output (compilers print `file:line`), biased to
619/// inclusion. It falls back to the WHOLE integrated set when the output names no
620/// known file — we can't localize, so the caller redoes everything (whole-goal)
621/// rather than risk dropping the real culprit. See [`localize_build_failure`].
622#[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/// Structured attribution of *why* a union integration failed — which subtask,
630/// file, symbol, or check is implicated. Populated only on failure. This is the
631/// data a regional replan needs to retry just the failing region (instead of
632/// re-running the whole goal), and what a UI reads to show "why did this run
633/// fail", not merely "it failed".
634///
635/// Deliberately out of scope: parsing the build/test *output* to map a failing
636/// test back to a symbol — that needs a language-specific parser. The raw
637/// (bounded) output tail is retained in [`build_test`] instead.
638///
639/// [`build_test`]: IntegrationBlame::build_test
640#[derive(Debug, Clone, Default, PartialEq, Eq)]
641pub struct IntegrationBlame {
642    /// Patches that failed to apply (textual conflicts), attributed to subtask + files.
643    pub apply_conflicts: Vec<ApplyConflict>,
644    /// Duplicate declarations the union gate found, attributed to subtasks.
645    pub duplicate_conflicts: Vec<DuplicateBlame>,
646    /// The union build/test failure, when that is what rejected the merge.
647    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    /// The subtasks a regional replan must DROP and redo — every subtask any
658    /// failure cause implicates. A `build_test` failure carries its *localized*
659    /// region (the subtasks whose files the failure output named); when that
660    /// couldn't be localized it carries the whole integrated set instead, which
661    /// forces the implicated set to everything ⇒ the caller computes an empty
662    /// "clean" set ⇒ it falls back to a whole-goal session rather than a regional
663    /// one.
664    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
679/// Subtasks a build/test failure implicates, by matching the files they touched
680/// against the paths named in the failure output (compilers and panics print
681/// `file:line`). Biased toward INCLUSION: an over-broad region just makes the
682/// regional replan redo more (still gated), whereas under-inclusion would
683/// reproduce the failure (caught by the gate → whole-goal fallback). An empty
684/// result means the output named no known file — the caller can't localize and
685/// keeps the whole integrated set.
686fn 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        // Plain (non-boundary-aware) substring: a short name like `x.rs` can also
693        // match `xxx.rs` in the output, over-implicating. That's deliberate — the
694        // inclusion bias is the safe direction (over-broad region just redoes
695        // more, still gated; under-inclusion would reproduce the failure). Do NOT
696        // "fix" this into a boundary check without preserving that bias.
697        if output.contains(file.as_str()) {
698            ids.extend(subtasks.iter().cloned());
699        }
700    }
701    ids.into_iter().collect()
702}
703
704/// Repo-relative file paths a unified-diff patch targets, parsed from its
705/// `diff --git a/<x> b/<y>` headers (the `b/` post-image side). Used to attribute
706/// an apply conflict / duplicate to the files — and thence the subtasks —
707/// involved, without re-running git. Best-effort: paths with embedded `" b/"`
708/// are rare and only degrade blame precision, never correctness elsewhere.
709/// Repo-relative paths a unified diff touches, read off its `diff --git` headers.
710///
711/// Public because a caller that INTEGRATES a patch needs the same answer the
712/// gate computes: a subtask id is opaque model output, so "s2 ran on studio"
713/// is only reviewable next to the files s2 actually contributed (car#1322).
714/// One parser, so the gate's view and the delivered provenance cannot disagree.
715pub 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
730/// Apply accepted subtasks' patches into one fresh staging worktree from the
731/// common base, in the given order, and run the gate on the **integrated union**.
732/// This is the integration-failure check the per-worktree gate cannot perform:
733/// two subtasks each accepted in isolation can still produce duplicate
734/// declarations, broken references, or a failing build once merged.
735///
736/// `subtask_label` labels the union for the gate's audit/subtask field.
737pub async fn integrate_and_verify(
738    repo_root: &Path,
739    subtask_label: &str,
740    accepted_patches: &[(String, String)], // (subtask_id, patch)
741    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    // The union is graded by the GOAL check (union_verify_command), falling back
753    // to the per-worktree command when the caller supplied only one.
754    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    // Map file → subtasks whose patch touched it, for blame attribution. Built
761    // from the patch headers, so a duplicate the union gate finds in a file can
762    // name the subtasks that contributed it. `union_members` is the whole
763    // integrated set (deterministic order) — the region for a build/test failure
764    // that can't be localized further.
765    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    // Apply + collect is pure git I/O — keep it off the async executor.
778    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    // A patch that didn't apply is a textual integration conflict — do not run
800    // the gate on a partial tree; report it as the integration failure it is.
801    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    // On a rejected/inconclusive union, attribute the gate's findings: each
829    // duplicate declaration to the subtasks that touched its file, plus the
830    // build/test failure (raw output tail; symbol-level test blame is deferred).
831    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                // Localize by matching the failure output against the files each
847                // subtask touched. When the output names no known file we can't
848                // localize → keep the whole integrated set (the conservative
849                // region, which routes the caller to a whole-goal recovery).
850                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(), // already bounded by the gate's max_output_bytes
859                    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
879/// Recover a failed parallel union by RESUMING from the clean (non-implicated)
880/// accepted patches and completing the goal in one session — redoing only the
881/// failing region while preserving the successful parallel work.
882///
883/// `clean_patches` are the accepted patches blame did NOT implicate. They are
884/// applied (uncommitted) into a fresh worktree; the agent then runs over the
885/// ORIGINAL `goal`, sees the clean work already on disk, and completes only what
886/// is missing. The full clean+region tree is gated with the GOAL check (the same
887/// merge-verify gate) and returned as one [`SubtaskOutcome`].
888///
889/// Returns `None` when the regional attempt could not even be staged — a clean
890/// patch failed to apply (the clean set is NOT guaranteed internally consistent;
891/// this is a *checked* precondition, not an assumption), the workspace couldn't
892/// be provisioned, or the agent/capture errored — so the caller falls back to a
893/// whole-goal session. A `Some` outcome that isn't accepted likewise signals the
894/// caller to fall back; the gate stays the soundness boundary either way.
895pub 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    // Checked precondition (per design review): the clean set is NOT guaranteed
911    // to apply cleanly among itself — verify it actually does, and bail to the
912    // whole-goal fallback if not. We're provisioning the worktree anyway.
913    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    // Resume the ORIGINAL goal — the agent sees the clean work on disk and only
933    // does the remainder. No footprint ⇒ containment off (this is whole-of-region
934    // work, not a declared single symbol).
935    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    // Gate the full clean+region tree with the GOAL check (union command, falling
958    // back to the per-worktree one) — it must achieve the whole goal, not a piece.
959    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
983/// Derive the uncommitted changes in a worktree (relative to its HEAD) as
984/// [`FileChange`]s. Handles `-z` porcelain correctly, including renames (emitted
985/// as two NUL fields) and non-UTF8 files (kept as lossy text rather than
986/// silently dropped, so policy and the gate's `unparsed` path still see them).
987fn 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        // A primary entry is "XY <path>" — 2 status columns, a space, the path.
997        if entry.len() < 4 {
998            continue;
999        }
1000        // Index (staged) column. Only X carries the rename/copy source field in
1001        // porcelain v1; the worktree column Y never does.
1002        let x = entry.as_bytes()[0];
1003        let path = entry[3..].to_string();
1004
1005        // Renames/copies put the SOURCE path in the *next* NUL field with no
1006        // status prefix. Only the INDEX column (X) carries this in porcelain v1;
1007        // the worktree column (Y) never does. Consume the source field and emit
1008        // its deletion side so a rename's removed symbols still reach the gate.
1009        // (A copy's source isn't really deleted; modeling it as a deletion is
1010        // conservative — it can only make the gate stricter, never miss a
1011        // conflict.)
1012        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
1044/// Content of `path` at HEAD, or `None` if it didn't exist there. The `./`
1045/// prefix stops git from interpreting a leading `:` or other revision syntax.
1046fn read_head(worktree: &Path, path: &str) -> Option<String> {
1047    git(worktree, &["show", &format!("HEAD:./{path}")]).ok()
1048}
1049
1050/// Worktree content of `path`, lossily decoded so binary files are surfaced
1051/// (and recorded by the gate as unparsed) rather than silently dropped.
1052fn 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
1058/// Capture the full patch of a worktree's changes (including untracked and
1059/// binary files) so it can be replayed into a staging tree later.
1060///
1061/// Public because a **fleet worker** produces exactly this: a peer runs the
1062/// subtask in its own worktree and returns the patch, which the orchestrator
1063/// replays into the worktree it is about to gate. Same bytes, same guarantees —
1064/// one function rather than a second, subtly-different capture on the far side.
1065pub fn capture_patch(worktree: &Path) -> Result<String, ForemanError> {
1066    // `add -N` registers untracked files so `diff` includes them, without
1067    // staging content (keeps the worktree state otherwise untouched).
1068    git(worktree, &["add", "-AN"])?;
1069    // `--no-textconv` + autocrlf off keep the patch a faithful byte image even
1070    // when the target repo has textconv/clean filters or CRLF normalization, so
1071    // it re-applies identically in the staging tree.
1072    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
1099/// Apply a patch into a worktree. Plain `git apply` (NOT `--3way`) on purpose:
1100/// a false-accept benchmark must surface overlapping/adjacent edits as loud
1101/// integration conflicts, not silently auto-merge them. A failure here is a
1102/// textual integration conflict the caller treats as an integration failure.
1103///
1104/// Public for the same reason as [`capture_patch`]: a fleet worker's patch is
1105/// replayed into the local worktree through this exact call, so a remote
1106/// subtask and a local one leave the tree in the same state.
1107pub 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    // ---- partitioner ----
1147
1148    #[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        // The no-files subtask must NOT pack with the file-declaring ones.
1165        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    // ---- git plumbing through a real repo ----
1172
1173    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        // Rename it — this is the case the blind parser corrupted.
1207        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        // Map the POSIX pass/fail sentinels these fixtures use to the
1249        // shell-neutral equivalent — the gate runs the verify command directly,
1250        // and `true`/`false` are POSIX binaries that don't exist on Windows.
1251        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    /// Records what `src/upstream.rs` looked like from inside each subtask's
1263    /// worktree.
1264    #[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    /// A dependent subtask does NOT see the work it declared a read on, and that
1287    /// is load-bearing rather than an oversight. Do not "fix" it by staging
1288    /// upstream patches into downstream worktrees.
1289    ///
1290    /// The design (`docs/proposals/verified-parallel-coding-orchestrator.md`)
1291    /// says "write-set(A) ∩ read-set(B) ≠ ∅ → dependency edge A→B: B reads what A
1292    /// writes, so B runs after A." The scheduler honors the ordering, but every
1293    /// worktree is provisioned from `repo_root` HEAD, so B opens the file it
1294    /// declared a read on and finds base content — it pays the serialization and
1295    /// codes against a contract it cannot execute.
1296    ///
1297    /// The obvious repair — apply accepted upstream patches into the worktree and
1298    /// commit them so `git diff HEAD` still yields only B's own work — was
1299    /// implemented and **it makes the merge-verify gate unsound**. Two subtasks
1300    /// writing the SAME file are put in separate levels by the partitioner, so B
1301    /// would receive A's edit, overwrite the file, and produce a patch that reads
1302    /// as a clean successive edit. The union then applies A then B without
1303    /// complaint and silently reverts A, where today it raises the conflict. The
1304    /// `foreman_false_accept_gauntlet` scenarios `broken_union_overlapping_edit`
1305    /// and `broken_union_duplicate_definition` both flip to false accepts —
1306    /// "broken merge marked safe", the exact class that gauntlet exists to
1307    /// prevent.
1308    ///
1309    /// The reason is structural: the gate detects conflicts because every patch
1310    /// is an INDEPENDENT diff from the same base. Staging makes patches
1311    /// sequentially dependent, which converts conflict detection into
1312    /// last-writer-wins. Delivering on the dependency edge therefore needs a
1313    /// mechanism that does not disturb that invariant — the design's own step 4,
1314    /// hoisting a *contract* the dependent reads (the interface, not the
1315    /// implementation) — not patch staging.
1316    ///
1317    /// Full write-up, including the per-scenario walk-through:
1318    /// `docs/solutions/foreman-patches-must-stay-independent-diffs.md`.
1319    #[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        // The edge really does serialize them — otherwise this test would be
1371        // asserting something the scheduler never promised.
1372        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        // Deterministic per repo path.
1422        assert_eq!(base, default_worktree_base(root));
1423        // git_worktree_at carries the repo so worktrees are of `root`, under `base`.
1424        let cfg = FarmOutConfig::default();
1425        let _ = worktree_workspace_config(root, &cfg); // smoke: builds without panic
1426    }
1427
1428    #[tokio::test]
1429    async fn worktrees_are_provisioned_outside_the_repo() {
1430        // The agent records the worktree cwd it was handed; assert it lives
1431        // outside the repo so a crash can't leak dirs into the user's checkout.
1432        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    /// **The thread, stated.** A caller that owns a checked directory for the
1473    /// agent's MCP config puts it on [`FarmOutConfig`], and every request the
1474    /// farm-out builds carries it. Without this the daemon-side agent has no
1475    /// directory to pass and writes under `$TMPDIR` — the inherited,
1476    /// never-checked value behind car#1494 / car#1534.
1477    #[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    /// **GUARD.** The field is additive: a caller that does not set it — every
1525    /// existing one, since [`FarmOutConfig::default`] leaves it unset — hands
1526    /// the agent `None` and keeps the pre-fix `$TMPDIR` behaviour rather than
1527    /// failing.
1528    #[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        // No verify command and no waiver → the gate is fail-closed (Inconclusive),
1576        // so the subtask is NOT accepted.
1577        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        // Same change, with an explicit waiver → accepted on containment alone,
1592        // but NOT build-verified.
1593        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        // Two file-disjoint subtasks: each writes its own new file (so the gate
1620        // accepts — `true` always passes — and footprint containment is off).
1621        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        // Agent that always errors before the gate — terminal status is `error`.
1677        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        // Subtask declares it will only write `foo`, but the agent also edits
1718        // `other` — the gate's containment (fed the DECLARED footprint) rejects.
1719        let repo = init_repo(); // base src/lib.rs = "pub fn original() {}\n"
1720                                // Seed a second symbol to escape to.
1721        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        // Agent edits BOTH foo (allowed) and other (NOT allowed).
1734        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(); // not a git repo
1755        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        // The integration-failure class per-worktree isolation is blind to:
1775        // both subtasks add `fn foo` to src/lib.rs at DIFFERENT positions. Each
1776        // worktree builds and is accepted alone. The two diffs are disjoint
1777        // hunks, so 3-way merge applies BOTH — yielding two `foo` in the union,
1778        // a duplicate declaration only the union gate can see.
1779        let repo = init_repo(); // base src/lib.rs = "pub fn original() {}\n"
1780        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(), // foo before
1785        };
1786        let agent_b = WriteAgent {
1787            path: "src/lib.rs".into(),
1788            content: "pub fn original() {}\npub fn foo() {}\n".into(), // foo after
1789        };
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        // Each is fine in isolation — neither worktree has a duplicate.
1808        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        // The union has two `foo` — must NOT integrate cleanly. (Caught either as
1827        // a 3-way apply conflict or as a duplicate declaration by the gate.)
1828        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        // Both subtasks rewrite the SAME line of src/lib.rs differently. Each is
1837        // accepted alone. Plain `git apply` (not --3way) must surface the second
1838        // as a loud apply conflict rather than silently auto-merging — exactly
1839        // the under-reporting a false-accept benchmark must avoid.
1840        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        // Per-worktree gate (verify_command) passes, but the union goal check
1924        // (union_verify_command) fails → the subtask is accepted in isolation yet
1925        // the union is rejected. Proves the two gates use different commands.
1926        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()), // per-worktree regression: pass
1934            union_verify_command: Some(crate::patterns::foreman::test_verify::fail()), // union goal: fail
1935            ..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        // A "clean" patch from a prior accepted subtask: it created keep.rs.
1962        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        // Regional resumes: applies keep.rs, the agent completes the goal (writes
1977        // good.txt), gated by a goal check requiring BOTH (clean preserved + done).
1978        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        // A clean patch that can't apply → the clean set isn't internally
2012        // consistent → bail (None) so the caller falls back to a whole-goal session.
2013        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        // Compiler output naming a.rs → only subtask a is implicated.
2035        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        // Output naming no known file → empty (caller keeps the whole set).
2042        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()), // union goal fails
2117            ..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        // Two subtasks each add `fn dup` to src/lib.rs in disjoint hunks: both
2144        // patches apply, but the union has a duplicate the gate flags — and blame
2145        // attributes it to BOTH a and b (the subtasks whose patches touched the file).
2146        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}