Skip to main content

car_multi/patterns/foreman/
orchestrator.rs

1//! `run_foreman` — the full pipeline as one reusable call.
2//!
3//! Decompose a goal, then EITHER farm the subtasks out and verify the integrated
4//! union, OR — when the plan doesn't decompose (invalid, or no parallelism worth
5//! it) — **fall back to a single whole-goal session**.
6//!
7//! Optionally (off by default; `FarmOutConfig::recover_via_single_session`), when
8//! the plan DID decompose but the integrated union was rejected — an integration
9//! failure the symbol-footprint planner structurally can't foresee — it
10//! **recovers** by re-running the whole goal as one session. So a delivery-first
11//! caller never just gives up; a cost-sensitive caller leaves recovery off and
12//! inspects `delivered()` + the retained `integration` evidence. The recovery
13//! costs a full extra (serial) session on top of the failed parallel spend —
14//! hence opt-in. The recovery is graded by the SAME merge-verify gate, so it can
15//! only turn a non-delivery into a *gated* delivery, never weaken soundness.
16//!
17//! The daemon's `foreman.run` is a thin caller of this, and the B7 eval exercises
18//! the exact same path (including the fallback), so what the eval measures is
19//! what production does.
20
21use std::future::Future;
22use std::path::Path;
23
24use super::harness::{
25    integrate_and_verify, regional_replan, run_farm_out, FarmOutConfig, IntegrationResult, Subtask,
26    SubtaskOutcome, WorktreeAgent,
27};
28use super::planner::{decompose, DecomposeResult};
29use crate::shared::SharedInfra;
30
31/// How `run_foreman` executed the goal.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RunMode {
34    /// The goal decomposed and the integrated union was accepted.
35    Parallel,
36    /// The plan didn't decompose (invalid or no real parallelism), so the whole
37    /// goal ran as one session.
38    SingleSession,
39    /// The goal decomposed and was farmed out, but the integrated union was
40    /// rejected (an integration failure the planner didn't foresee) — so it
41    /// recovered by re-running the whole goal as one session.
42    ParallelThenSingleSession,
43    /// The integrated union was rejected, but blame localized the failure to a
44    /// proper subset of the accepted subtasks — so it recovered by RESUMING from
45    /// the clean (non-implicated) patches and completing the goal in one session,
46    /// redoing only the failing region while preserving the successful parallel
47    /// work. Cheaper than `ParallelThenSingleSession`; tried first.
48    RegionalReplan,
49}
50
51/// Outcome of a full `run_foreman` pass.
52#[derive(Debug)]
53pub struct ForemanRunOutcome {
54    pub plan: DecomposeResult,
55    pub mode: RunMode,
56    /// Per-subtask outcomes (one entry in `SingleSession` mode).
57    pub outcomes: Vec<SubtaskOutcome>,
58    /// The integrated-union verdict (only in `Parallel` mode).
59    pub integration: Option<IntegrationResult>,
60}
61
62impl ForemanRunOutcome {
63    /// The pipeline delivered a sound result: the integrated union was accepted
64    /// (parallel), or the (recovery/fallback) single session was accepted.
65    pub fn delivered(&self) -> bool {
66        match self.mode {
67            RunMode::Parallel => self
68                .integration
69                .as_ref()
70                .is_some_and(|i| i.integrated_cleanly()),
71            // `outcomes` holds the (single-session / regional) result in these
72            // modes — exactly one entry, the gated recovery attempt.
73            RunMode::SingleSession
74            | RunMode::ParallelThenSingleSession
75            | RunMode::RegionalReplan => self.outcomes.first().is_some_and(|o| o.is_accepted()),
76        }
77    }
78}
79
80/// Run the whole goal as one session and return its gate outcomes. The single
81/// worktree IS the whole solution → graded by the GOAL check (union, falling
82/// back to per-worktree).
83async fn single_session(
84    repo_root: &Path,
85    goal: &str,
86    agent: &dyn WorktreeAgent,
87    config: &FarmOutConfig,
88    infra: &SharedInfra,
89) -> Vec<SubtaskOutcome> {
90    let goal_check = config
91        .union_verify_command
92        .clone()
93        .or_else(|| config.verify_command.clone());
94    let single_cfg = FarmOutConfig {
95        verify_command: goal_check,
96        allowed_tools: config.allowed_tools.clone(),
97        mcp_endpoint: config.mcp_endpoint.clone(),
98        ..Default::default()
99    };
100    let whole = Subtask::files_only("__single_session__", goal.to_string(), Vec::new());
101    let outcomes = run_farm_out(repo_root, &[whole], agent, &single_cfg, infra)
102        .await
103        .outcomes;
104    // Exactly one subtask in → exactly one outcome out; `delivered()` reads
105    // `outcomes.first()` on the strength of this invariant.
106    debug_assert_eq!(outcomes.len(), 1);
107    outcomes
108}
109
110/// Run the full Foreman pipeline. `generate` drives the planner (decompose);
111/// `agent` executes each farmed-out subtask (or the single session). `config`'s
112/// `verify_command` is the per-worktree regression check and `union_verify_command`
113/// the integrated-union goal check; the single-session fallback is graded by the
114/// goal check (union, falling back to per-worktree).
115pub async fn run_foreman<F, Fut>(
116    repo_root: &Path,
117    goal: &str,
118    max_attempts: u32,
119    agent: &dyn WorktreeAgent,
120    config: &FarmOutConfig,
121    infra: &SharedInfra,
122    generate: F,
123) -> ForemanRunOutcome
124where
125    F: Fn(String) -> Fut,
126    Fut: Future<Output = Result<String, String>>,
127{
128    let plan = decompose(repo_root, goal, max_attempts, generate).await;
129
130    // Fall back to a single whole-goal session when the plan didn't decompose:
131    // either the planner couldn't produce a valid plan, or it found no
132    // parallelism worth the coordination (coupled work). Don't farm out pieces
133    // that can't be correct in isolation; don't just give up either.
134    if !plan.is_valid() || plan.prefer_single_session {
135        return ForemanRunOutcome {
136            plan,
137            mode: RunMode::SingleSession,
138            outcomes: single_session(repo_root, goal, agent, config, infra).await,
139            integration: None,
140        };
141    }
142
143    // Decomposable: farm out, then integrate the accepted patches and gate the
144    // union.
145    let farmed = run_farm_out(repo_root, &plan.subtasks, agent, config, infra).await;
146    let accepted: Vec<(String, String)> = farmed
147        .outcomes
148        .iter()
149        .filter(|o| o.is_accepted())
150        .filter_map(|o| o.patch.clone().map(|p| (o.subtask_id.clone(), p)))
151        .collect();
152    let integration = if accepted.is_empty() {
153        None
154    } else {
155        integrate_and_verify(repo_root, "foreman-run", &accepted, config, infra)
156            .await
157            .ok()
158    };
159
160    // The parallel path delivered a sound integrated result, OR the caller
161    // didn't opt into recovery — either way return the parallel outcome. When it
162    // didn't integrate cleanly, `delivered()` is false and `integration` carries
163    // the failure evidence, so the caller can decide whether to pay for a retry.
164    if integration.as_ref().is_some_and(|i| i.integrated_cleanly())
165        || !config.recover_via_single_session
166    {
167        return ForemanRunOutcome {
168            plan,
169            mode: RunMode::Parallel,
170            outcomes: farmed.outcomes,
171            integration,
172        };
173    }
174
175    // Opted-in recovery. The parallel union was rejected (an integration failure
176    // the planner didn't foresee). Prefer a REGIONAL replan: if blame localized
177    // the failure to a proper subset of the accepted subtasks, resume from the
178    // clean (non-implicated) patches and complete the goal in one session —
179    // redoing only the failing region while preserving the successful parallel
180    // work. Fall back to a whole-goal session when the failure can't be localized
181    // (build/test implicates everything, nothing accepted) or the regional
182    // attempt doesn't deliver. Either recovery is graded by the same gate.
183    let clean_patches: Vec<(String, String)> = match &integration {
184        Some(i) => {
185            let implicated = i
186                .blame
187                .as_ref()
188                .map(|b| b.implicated_subtasks())
189                .unwrap_or_default();
190            farmed
191                .outcomes
192                .iter()
193                .filter(|o| o.is_accepted() && !implicated.contains(&o.subtask_id))
194                .filter_map(|o| o.patch.clone().map(|p| (o.subtask_id.clone(), p)))
195                .collect()
196        }
197        None => Vec::new(),
198    };
199
200    // A regional replan only makes sense when there is clean work to preserve AND
201    // the implicated set is a proper subset (some accepted patch was dropped) —
202    // otherwise it degenerates to the whole-goal session below. (`accepted` was
203    // computed once above; reuse its length rather than re-filtering.)
204    if !clean_patches.is_empty() && clean_patches.len() < accepted.len() {
205        if let Some(outcome) =
206            regional_replan(repo_root, goal, &clean_patches, agent, config, infra).await
207        {
208            if outcome.is_accepted() {
209                return ForemanRunOutcome {
210                    plan,
211                    mode: RunMode::RegionalReplan,
212                    outcomes: vec![outcome],
213                    integration,
214                };
215            }
216        }
217    }
218
219    // Regional wasn't applicable or didn't deliver — re-run the whole goal as one
220    // session. NB: this spends a full extra session on top of the failed parallel
221    // (and any regional) spend. The failed parallel `integration` is retained.
222    ForemanRunOutcome {
223        plan,
224        mode: RunMode::ParallelThenSingleSession,
225        outcomes: single_session(repo_root, goal, agent, config, infra).await,
226        integration,
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::patterns::foreman::harness::{AgentRunSummary, ForemanError, WorktreeAgentRequest};
234    use async_trait::async_trait;
235    use std::path::Path as StdPath;
236    use std::process::Command;
237
238    struct StubAgent;
239
240    #[async_trait]
241    impl WorktreeAgent for StubAgent {
242        async fn run_in(
243            &self,
244            req: &WorktreeAgentRequest<'_>,
245        ) -> Result<AgentRunSummary, ForemanError> {
246            // Write exactly the declared symbols (stays inside the footprint so
247            // the gate's containment accepts); fall back to a free file when no
248            // footprint was declared (the single-session subtask).
249            match &req.subtask.footprint {
250                Some(fp) => {
251                    for w in &fp.writes {
252                        std::fs::write(
253                            req.cwd.join(&w.file),
254                            format!("pub fn {}() {{}}\n", w.symbol),
255                        )
256                        .map_err(|e| ForemanError::Agent(e.to_string()))?;
257                    }
258                }
259                None => {
260                    // The single-session subtask has no footprint. Write the
261                    // marker the goal check can look for.
262                    std::fs::write(req.cwd.join("good.txt"), &req.subtask.prompt)
263                        .map_err(|e| ForemanError::Agent(e.to_string()))?;
264                }
265            }
266            Ok(AgentRunSummary::default())
267        }
268    }
269
270    fn git(cwd: &StdPath, args: &[&str]) {
271        Command::new("git")
272            .args(args)
273            .current_dir(cwd)
274            .output()
275            .unwrap();
276    }
277
278    fn repo() -> tempfile::TempDir {
279        let dir = tempfile::tempdir().unwrap();
280        let root = dir.path();
281        git(root, &["init", "-q", "-b", "main"]);
282        git(root, &["config", "user.email", "t@t.t"]);
283        git(root, &["config", "user.name", "t"]);
284        // Keep the seeded LF files byte-faithful so patches/diffs don't pick up
285        // spurious CRLF changes on Windows (global core.autocrlf=true).
286        git(root, &["config", "core.autocrlf", "false"]);
287        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
288        // Pre-existing symbols so the footprint analyzer can resolve them (an
289        // unknown symbol is fail-closed to uncertain → serialized).
290        std::fs::write(root.join("x.rs"), "pub fn x() {}\n").unwrap();
291        std::fs::write(root.join("y.rs"), "pub fn y() {}\n").unwrap();
292        git(root, &["add", "-A"]);
293        git(root, &["commit", "-qm", "base"]);
294        dir
295    }
296
297    fn cfg() -> FarmOutConfig {
298        FarmOutConfig {
299            verify_command: Some(crate::patterns::foreman::test_verify::pass()),
300            ..Default::default()
301        }
302    }
303
304    #[tokio::test]
305    async fn invalid_plan_falls_back_to_single_session_and_delivers() {
306        let dir = repo();
307        let infra = SharedInfra::new();
308        // Generator always returns an unparseable plan → decompose gives up →
309        // run_foreman must NOT bail; it runs one session and delivers.
310        let outcome = run_foreman(
311            dir.path(),
312            "do the thing",
313            2,
314            &StubAgent,
315            &cfg(),
316            &infra,
317            |_p| async move { Ok("not json at all".to_string()) },
318        )
319        .await;
320        assert_eq!(
321            outcome.mode,
322            RunMode::SingleSession,
323            "{:?}",
324            outcome.plan.issues
325        );
326        assert!(
327            outcome.delivered(),
328            "single-session fallback delivered: {outcome:?}"
329        );
330        assert!(!outcome.plan.is_valid());
331    }
332
333    #[tokio::test]
334    async fn disjoint_plan_runs_parallel_and_delivers() {
335        let dir = repo();
336        let infra = SharedInfra::new();
337        let plan = r#"{"subtasks":[
338            {"id":"x","prompt":"x","writes":[{"file":"x.rs","symbol":"x"}]},
339            {"id":"y","prompt":"y","writes":[{"file":"y.rs","symbol":"y"}]}
340        ]}"#;
341        let outcome = run_foreman(
342            dir.path(),
343            "two things",
344            2,
345            &StubAgent,
346            &cfg(),
347            &infra,
348            |_p| {
349                let plan = plan.to_string();
350                async move { Ok(plan) }
351            },
352        )
353        .await;
354        assert_eq!(outcome.mode, RunMode::Parallel, "{:?}", outcome.plan.issues);
355        assert!(outcome.delivered(), "parallel union delivered: {outcome:?}");
356    }
357
358    #[tokio::test]
359    async fn parallel_integration_failure_recovers_via_single_session() {
360        let dir = repo();
361        let infra = SharedInfra::new();
362        // Disjoint plan → runs parallel. Per-worktree gate ("true") accepts each
363        // subtask, but the GOAL check requires a `good.txt` the parallel subtasks
364        // never produce (they write x.rs/y.rs) → the union fails → recover with a
365        // single session, whose stub DOES write good.txt → delivers.
366        let cfg = FarmOutConfig {
367            verify_command: Some(crate::patterns::foreman::test_verify::pass()),
368            union_verify_command: Some(crate::patterns::foreman::test_verify::files_exist(&[
369                "good.txt",
370            ])),
371            recover_via_single_session: true, // opt in to recovery
372            ..Default::default()
373        };
374        let plan = r#"{"subtasks":[
375            {"id":"x","prompt":"x","writes":[{"file":"x.rs","symbol":"x"}]},
376            {"id":"y","prompt":"y","writes":[{"file":"y.rs","symbol":"y"}]}
377        ]}"#;
378        let outcome = run_foreman(
379            dir.path(),
380            "make good.txt",
381            2,
382            &StubAgent,
383            &cfg,
384            &infra,
385            |_p| {
386                let plan = plan.to_string();
387                async move { Ok(plan) }
388            },
389        )
390        .await;
391        assert_eq!(
392            outcome.mode,
393            RunMode::ParallelThenSingleSession,
394            "parallel failed → recovered: {outcome:?}"
395        );
396        assert!(
397            outcome.delivered(),
398            "single-session recovery delivered: {outcome:?}"
399        );
400        // The failed parallel attempt is retained as evidence.
401        assert!(outcome.integration.is_some());
402    }
403
404    #[tokio::test]
405    async fn parallel_failure_recovers_via_regional_replan_when_blame_localizes() {
406        let dir = repo();
407        let infra = SharedInfra::new();
408        // x and y run parallel (disjoint NEW files, planner-accepted) and both
409        // pass the per-worktree gate. The union GOAL check fails AND its output
410        // names `xx.rs` (as a real compiler error would name the broken file) →
411        // blame localizes the failure to subtask x. So clean = {y} ⊊ accepted →
412        // REGIONAL replan: resume from y's preserved work; the stub completes the
413        // goal (writes good.txt) → the goal check now passes. No whole-goal rerun.
414        let cfg = FarmOutConfig {
415            verify_command: Some(crate::patterns::foreman::test_verify::pass()),
416            // Emits the blame-able "xx.rs" message (localize_build_failure reads
417            // it) AND exits on whether good.txt exists — cross-platform.
418            union_verify_command: Some(if cfg!(windows) {
419                vec![
420                    "cmd".into(),
421                    "/C".into(),
422                    "echo compile error in xx.rs & if exist good.txt (exit 0) else (exit 1)".into(),
423                ]
424            } else {
425                vec![
426                    "sh".into(),
427                    "-c".into(),
428                    "echo 'compile error in xx.rs'; test -f good.txt".into(),
429                ]
430            }),
431            recover_via_single_session: true,
432            ..Default::default()
433        };
434        // New files (not the repo's pre-existing x.rs/y.rs) so the stub's writes
435        // are real, contained patches the blame map can attribute.
436        let plan = r#"{"subtasks":[
437            {"id":"x","prompt":"x","writes":[{"file":"xx.rs","symbol":"fx"}]},
438            {"id":"y","prompt":"y","writes":[{"file":"yy.rs","symbol":"fy"}]}
439        ]}"#;
440        let outcome = run_foreman(
441            dir.path(),
442            "make good.txt",
443            2,
444            &StubAgent,
445            &cfg,
446            &infra,
447            |_p| {
448                let plan = plan.to_string();
449                async move { Ok(plan) }
450            },
451        )
452        .await;
453        assert_eq!(
454            outcome.mode,
455            RunMode::RegionalReplan,
456            "localized blame → regional, not whole-goal: {outcome:?}"
457        );
458        assert!(
459            outcome.delivered(),
460            "regional replan delivered: {outcome:?}"
461        );
462        // Clean work (y) was preserved into the regional result.
463        let patch = outcome.outcomes[0].patch.as_ref().unwrap();
464        assert!(patch.contains("yy.rs"), "y's clean work preserved: {patch}");
465        assert!(
466            outcome.integration.is_some(),
467            "failed parallel retained as evidence"
468        );
469    }
470}