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        // The governance surface travels as a PAIR: the endpoint the agent's
99        // CAR-namespace tool calls route through, and the directory the config
100        // naming that endpoint is written in. Carrying only the endpoint put the
101        // fallback session's config back under the daemon's inherited `$TMPDIR`
102        // — unwritable under a stale installer sandbox, so `--mcp-config` failed
103        // setup and the session silently ran a different engine (car#1494 /
104        // car#1534). Both fallback modes reach this: planning fallback and
105        // post-integration recovery.
106        mcp_config_dir: config.mcp_config_dir.clone(),
107        ..Default::default()
108    };
109    let whole = Subtask::files_only("__single_session__", goal.to_string(), Vec::new());
110    let outcomes = run_farm_out(repo_root, &[whole], agent, &single_cfg, infra)
111        .await
112        .outcomes;
113    // Exactly one subtask in → exactly one outcome out; `delivered()` reads
114    // `outcomes.first()` on the strength of this invariant.
115    debug_assert_eq!(outcomes.len(), 1);
116    outcomes
117}
118
119/// Run the full Foreman pipeline. `generate` drives the planner (decompose);
120/// `agent` executes each farmed-out subtask (or the single session). `config`'s
121/// `verify_command` is the per-worktree regression check and `union_verify_command`
122/// the integrated-union goal check; the single-session fallback is graded by the
123/// goal check (union, falling back to per-worktree).
124pub async fn run_foreman<F, Fut>(
125    repo_root: &Path,
126    goal: &str,
127    max_attempts: u32,
128    agent: &dyn WorktreeAgent,
129    config: &FarmOutConfig,
130    infra: &SharedInfra,
131    generate: F,
132) -> ForemanRunOutcome
133where
134    F: Fn(String) -> Fut,
135    Fut: Future<Output = Result<String, String>>,
136{
137    let plan = decompose(repo_root, goal, max_attempts, generate).await;
138
139    // Fall back to a single whole-goal session when the plan didn't decompose:
140    // either the planner couldn't produce a valid plan, or it found no
141    // parallelism worth the coordination (coupled work). Don't farm out pieces
142    // that can't be correct in isolation; don't just give up either.
143    if !plan.is_valid() || plan.prefer_single_session {
144        return ForemanRunOutcome {
145            plan,
146            mode: RunMode::SingleSession,
147            outcomes: single_session(repo_root, goal, agent, config, infra).await,
148            integration: None,
149        };
150    }
151
152    // Decomposable: farm out, then integrate the accepted patches and gate the
153    // union.
154    let farmed = run_farm_out(repo_root, &plan.subtasks, agent, config, infra).await;
155    let accepted: Vec<(String, String)> = farmed
156        .outcomes
157        .iter()
158        .filter(|o| o.is_accepted())
159        .filter_map(|o| o.patch.clone().map(|p| (o.subtask_id.clone(), p)))
160        .collect();
161    let integration = if accepted.is_empty() {
162        None
163    } else {
164        integrate_and_verify(repo_root, "foreman-run", &accepted, config, infra)
165            .await
166            .ok()
167    };
168
169    // The parallel path delivered a sound integrated result, OR the caller
170    // didn't opt into recovery — either way return the parallel outcome. When it
171    // didn't integrate cleanly, `delivered()` is false and `integration` carries
172    // the failure evidence, so the caller can decide whether to pay for a retry.
173    if integration.as_ref().is_some_and(|i| i.integrated_cleanly())
174        || !config.recover_via_single_session
175    {
176        return ForemanRunOutcome {
177            plan,
178            mode: RunMode::Parallel,
179            outcomes: farmed.outcomes,
180            integration,
181        };
182    }
183
184    // Opted-in recovery. The parallel union was rejected (an integration failure
185    // the planner didn't foresee). Prefer a REGIONAL replan: if blame localized
186    // the failure to a proper subset of the accepted subtasks, resume from the
187    // clean (non-implicated) patches and complete the goal in one session —
188    // redoing only the failing region while preserving the successful parallel
189    // work. Fall back to a whole-goal session when the failure can't be localized
190    // (build/test implicates everything, nothing accepted) or the regional
191    // attempt doesn't deliver. Either recovery is graded by the same gate.
192    let clean_patches: Vec<(String, String)> = match &integration {
193        Some(i) => {
194            let implicated = i
195                .blame
196                .as_ref()
197                .map(|b| b.implicated_subtasks())
198                .unwrap_or_default();
199            farmed
200                .outcomes
201                .iter()
202                .filter(|o| o.is_accepted() && !implicated.contains(&o.subtask_id))
203                .filter_map(|o| o.patch.clone().map(|p| (o.subtask_id.clone(), p)))
204                .collect()
205        }
206        None => Vec::new(),
207    };
208
209    // A regional replan only makes sense when there is clean work to preserve AND
210    // the implicated set is a proper subset (some accepted patch was dropped) —
211    // otherwise it degenerates to the whole-goal session below. (`accepted` was
212    // computed once above; reuse its length rather than re-filtering.)
213    if !clean_patches.is_empty() && clean_patches.len() < accepted.len() {
214        if let Some(outcome) =
215            regional_replan(repo_root, goal, &clean_patches, agent, config, infra).await
216        {
217            if outcome.is_accepted() {
218                return ForemanRunOutcome {
219                    plan,
220                    mode: RunMode::RegionalReplan,
221                    outcomes: vec![outcome],
222                    integration,
223                };
224            }
225        }
226    }
227
228    // Regional wasn't applicable or didn't deliver — re-run the whole goal as one
229    // session. NB: this spends a full extra session on top of the failed parallel
230    // (and any regional) spend. The failed parallel `integration` is retained.
231    ForemanRunOutcome {
232        plan,
233        mode: RunMode::ParallelThenSingleSession,
234        outcomes: single_session(repo_root, goal, agent, config, infra).await,
235        integration,
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::patterns::foreman::harness::{AgentRunSummary, ForemanError, WorktreeAgentRequest};
243    use async_trait::async_trait;
244    use std::path::{Path as StdPath, PathBuf};
245    use std::process::Command;
246    use std::sync::{Arc, Mutex};
247
248    struct StubAgent;
249
250    #[async_trait]
251    impl WorktreeAgent for StubAgent {
252        async fn run_in(
253            &self,
254            req: &WorktreeAgentRequest<'_>,
255        ) -> Result<AgentRunSummary, ForemanError> {
256            // Write exactly the declared symbols (stays inside the footprint so
257            // the gate's containment accepts); fall back to a free file when no
258            // footprint was declared (the single-session subtask).
259            match &req.subtask.footprint {
260                Some(fp) => {
261                    for w in &fp.writes {
262                        std::fs::write(
263                            req.cwd.join(&w.file),
264                            format!("pub fn {}() {{}}\n", w.symbol),
265                        )
266                        .map_err(|e| ForemanError::Agent(e.to_string()))?;
267                    }
268                }
269                None => {
270                    // The single-session subtask has no footprint. Write the
271                    // marker the goal check can look for.
272                    std::fs::write(req.cwd.join("good.txt"), &req.subtask.prompt)
273                        .map_err(|e| ForemanError::Agent(e.to_string()))?;
274                }
275            }
276            Ok(AgentRunSummary::default())
277        }
278    }
279
280    /// Behaves exactly like [`StubAgent`], and records the `mcp_config_dir` of
281    /// every request it is handed — including the ones the single-session
282    /// fallback and the post-integration recovery build.
283    struct DirRecorder(Arc<Mutex<Vec<Option<PathBuf>>>>);
284
285    #[async_trait]
286    impl WorktreeAgent for DirRecorder {
287        async fn run_in(
288            &self,
289            req: &WorktreeAgentRequest<'_>,
290        ) -> Result<AgentRunSummary, ForemanError> {
291            self.0.lock().unwrap().push(req.mcp_config_dir.clone());
292            StubAgent.run_in(req).await
293        }
294    }
295
296    fn git(cwd: &StdPath, args: &[&str]) {
297        Command::new("git")
298            .args(args)
299            .current_dir(cwd)
300            .output()
301            .unwrap();
302    }
303
304    fn repo() -> tempfile::TempDir {
305        let dir = tempfile::tempdir().unwrap();
306        let root = dir.path();
307        git(root, &["init", "-q", "-b", "main"]);
308        git(root, &["config", "user.email", "t@t.t"]);
309        git(root, &["config", "user.name", "t"]);
310        // Keep the seeded LF files byte-faithful so patches/diffs don't pick up
311        // spurious CRLF changes on Windows (global core.autocrlf=true).
312        git(root, &["config", "core.autocrlf", "false"]);
313        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
314        // Pre-existing symbols so the footprint analyzer can resolve them (an
315        // unknown symbol is fail-closed to uncertain → serialized).
316        std::fs::write(root.join("x.rs"), "pub fn x() {}\n").unwrap();
317        std::fs::write(root.join("y.rs"), "pub fn y() {}\n").unwrap();
318        git(root, &["add", "-A"]);
319        git(root, &["commit", "-qm", "base"]);
320        dir
321    }
322
323    fn cfg() -> FarmOutConfig {
324        FarmOutConfig {
325            verify_command: Some(crate::patterns::foreman::test_verify::pass()),
326            ..Default::default()
327        }
328    }
329
330    #[tokio::test]
331    async fn invalid_plan_falls_back_to_single_session_and_delivers() {
332        let dir = repo();
333        let infra = SharedInfra::new();
334        // Generator always returns an unparseable plan → decompose gives up →
335        // run_foreman must NOT bail; it runs one session and delivers.
336        let outcome = run_foreman(
337            dir.path(),
338            "do the thing",
339            2,
340            &StubAgent,
341            &cfg(),
342            &infra,
343            |_p| async move { Ok("not json at all".to_string()) },
344        )
345        .await;
346        assert_eq!(
347            outcome.mode,
348            RunMode::SingleSession,
349            "{:?}",
350            outcome.plan.issues
351        );
352        assert!(
353            outcome.delivered(),
354            "single-session fallback delivered: {outcome:?}"
355        );
356        assert!(!outcome.plan.is_valid());
357    }
358
359    #[tokio::test]
360    async fn disjoint_plan_runs_parallel_and_delivers() {
361        let dir = repo();
362        let infra = SharedInfra::new();
363        let plan = r#"{"subtasks":[
364            {"id":"x","prompt":"x","writes":[{"file":"x.rs","symbol":"x"}]},
365            {"id":"y","prompt":"y","writes":[{"file":"y.rs","symbol":"y"}]}
366        ]}"#;
367        let outcome = run_foreman(
368            dir.path(),
369            "two things",
370            2,
371            &StubAgent,
372            &cfg(),
373            &infra,
374            |_p| {
375                let plan = plan.to_string();
376                async move { Ok(plan) }
377            },
378        )
379        .await;
380        assert_eq!(outcome.mode, RunMode::Parallel, "{:?}", outcome.plan.issues);
381        assert!(outcome.delivered(), "parallel union delivered: {outcome:?}");
382    }
383
384    #[tokio::test]
385    async fn parallel_integration_failure_recovers_via_single_session() {
386        let dir = repo();
387        let infra = SharedInfra::new();
388        // Disjoint plan → runs parallel. Per-worktree gate ("true") accepts each
389        // subtask, but the GOAL check requires a `good.txt` the parallel subtasks
390        // never produce (they write x.rs/y.rs) → the union fails → recover with a
391        // single session, whose stub DOES write good.txt → delivers.
392        let cfg = FarmOutConfig {
393            verify_command: Some(crate::patterns::foreman::test_verify::pass()),
394            union_verify_command: Some(crate::patterns::foreman::test_verify::files_exist(&[
395                "good.txt",
396            ])),
397            recover_via_single_session: true, // opt in to recovery
398            ..Default::default()
399        };
400        let plan = r#"{"subtasks":[
401            {"id":"x","prompt":"x","writes":[{"file":"x.rs","symbol":"x"}]},
402            {"id":"y","prompt":"y","writes":[{"file":"y.rs","symbol":"y"}]}
403        ]}"#;
404        let outcome = run_foreman(
405            dir.path(),
406            "make good.txt",
407            2,
408            &StubAgent,
409            &cfg,
410            &infra,
411            |_p| {
412                let plan = plan.to_string();
413                async move { Ok(plan) }
414            },
415        )
416        .await;
417        assert_eq!(
418            outcome.mode,
419            RunMode::ParallelThenSingleSession,
420            "parallel failed → recovered: {outcome:?}"
421        );
422        assert!(
423            outcome.delivered(),
424            "single-session recovery delivered: {outcome:?}"
425        );
426        // The failed parallel attempt is retained as evidence.
427        assert!(outcome.integration.is_some());
428    }
429
430    /// **Fallback mode 1: planning fallback.** A plan that never decomposes runs
431    /// the whole goal as one session, and that session's request must still
432    /// carry the caller's MCP config directory. `single_session` rebuilds the
433    /// config from scratch rather than cloning the caller's, so a field added to
434    /// [`FarmOutConfig`] silently stops here unless it is copied across — and a
435    /// dropped directory puts the config back under the daemon's inherited
436    /// `$TMPDIR` (car#1494 / car#1534).
437    #[tokio::test]
438    async fn the_single_session_fallback_keeps_the_mcp_config_dir() {
439        let dir = repo();
440        let infra = SharedInfra::new();
441        let mcp_dir = PathBuf::from("/var/car/coder/state/mcp");
442        let seen = Arc::new(Mutex::new(Vec::new()));
443        let agent = DirRecorder(Arc::clone(&seen));
444        let cfg = FarmOutConfig {
445            mcp_config_dir: Some(mcp_dir.clone()),
446            ..cfg()
447        };
448        let outcome = run_foreman(
449            dir.path(),
450            "do the thing",
451            2,
452            &agent,
453            &cfg,
454            &infra,
455            |_p| async move { Ok("not json at all".to_string()) },
456        )
457        .await;
458
459        assert_eq!(outcome.mode, RunMode::SingleSession, "{outcome:?}");
460        let seen = seen.lock().unwrap().clone();
461        assert_eq!(
462            seen,
463            vec![Some(mcp_dir)],
464            "the fallback session's request must carry the directory"
465        );
466    }
467
468    /// **Fallback mode 2: post-integration recovery.** Same requirement on the
469    /// other path into `single_session` — the parallel union was rejected and the
470    /// whole goal is re-run as one session. Every request of the run, the
471    /// farmed-out ones and the recovery, carries the directory.
472    #[tokio::test]
473    async fn the_recovery_session_keeps_the_mcp_config_dir() {
474        let dir = repo();
475        let infra = SharedInfra::new();
476        let mcp_dir = PathBuf::from("/var/car/coder/state/mcp");
477        let seen = Arc::new(Mutex::new(Vec::new()));
478        let agent = DirRecorder(Arc::clone(&seen));
479        // Same shape as `parallel_integration_failure_recovers_via_single_session`:
480        // each subtask passes its own gate, the goal check wants a `good.txt`
481        // only the single-session stub writes, so the union fails and recovery
482        // runs.
483        let cfg = FarmOutConfig {
484            verify_command: Some(crate::patterns::foreman::test_verify::pass()),
485            union_verify_command: Some(crate::patterns::foreman::test_verify::files_exist(&[
486                "good.txt",
487            ])),
488            recover_via_single_session: true,
489            mcp_config_dir: Some(mcp_dir.clone()),
490            ..Default::default()
491        };
492        let plan = r#"{"subtasks":[
493            {"id":"x","prompt":"x","writes":[{"file":"x.rs","symbol":"x"}]},
494            {"id":"y","prompt":"y","writes":[{"file":"y.rs","symbol":"y"}]}
495        ]}"#;
496        let outcome = run_foreman(dir.path(), "make good.txt", 2, &agent, &cfg, &infra, |_p| {
497            let plan = plan.to_string();
498            async move { Ok(plan) }
499        })
500        .await;
501
502        assert_eq!(
503            outcome.mode,
504            RunMode::ParallelThenSingleSession,
505            "{outcome:?}"
506        );
507        let seen = seen.lock().unwrap().clone();
508        // The recovery runs strictly after the farm-out finishes, so it is the
509        // last request — whatever order the parallel level ran in.
510        assert_eq!(
511            seen.last(),
512            Some(&Some(mcp_dir.clone())),
513            "the recovery session's request must carry the directory: {seen:?}"
514        );
515        assert!(
516            seen.iter().all(|d| d.as_deref() == Some(mcp_dir.as_path())),
517            "and so must every farmed-out request: {seen:?}"
518        );
519    }
520
521    #[tokio::test]
522    async fn parallel_failure_recovers_via_regional_replan_when_blame_localizes() {
523        let dir = repo();
524        let infra = SharedInfra::new();
525        // x and y run parallel (disjoint NEW files, planner-accepted) and both
526        // pass the per-worktree gate. The union GOAL check fails AND its output
527        // names `xx.rs` (as a real compiler error would name the broken file) →
528        // blame localizes the failure to subtask x. So clean = {y} ⊊ accepted →
529        // REGIONAL replan: resume from y's preserved work; the stub completes the
530        // goal (writes good.txt) → the goal check now passes. No whole-goal rerun.
531        let cfg = FarmOutConfig {
532            verify_command: Some(crate::patterns::foreman::test_verify::pass()),
533            // Emits the blame-able "xx.rs" message (localize_build_failure reads
534            // it) AND exits on whether good.txt exists — cross-platform.
535            union_verify_command: Some(if cfg!(windows) {
536                vec![
537                    "cmd".into(),
538                    "/C".into(),
539                    "echo compile error in xx.rs & if exist good.txt (exit 0) else (exit 1)".into(),
540                ]
541            } else {
542                vec![
543                    "sh".into(),
544                    "-c".into(),
545                    "echo 'compile error in xx.rs'; test -f good.txt".into(),
546                ]
547            }),
548            recover_via_single_session: true,
549            ..Default::default()
550        };
551        // New files (not the repo's pre-existing x.rs/y.rs) so the stub's writes
552        // are real, contained patches the blame map can attribute.
553        let plan = r#"{"subtasks":[
554            {"id":"x","prompt":"x","writes":[{"file":"xx.rs","symbol":"fx"}]},
555            {"id":"y","prompt":"y","writes":[{"file":"yy.rs","symbol":"fy"}]}
556        ]}"#;
557        let outcome = run_foreman(
558            dir.path(),
559            "make good.txt",
560            2,
561            &StubAgent,
562            &cfg,
563            &infra,
564            |_p| {
565                let plan = plan.to_string();
566                async move { Ok(plan) }
567            },
568        )
569        .await;
570        assert_eq!(
571            outcome.mode,
572            RunMode::RegionalReplan,
573            "localized blame → regional, not whole-goal: {outcome:?}"
574        );
575        assert!(
576            outcome.delivered(),
577            "regional replan delivered: {outcome:?}"
578        );
579        // Clean work (y) was preserved into the regional result.
580        let patch = outcome.outcomes[0].patch.as_ref().unwrap();
581        assert!(patch.contains("yy.rs"), "y's clean work preserved: {patch}");
582        assert!(
583            outcome.integration.is_some(),
584            "failed parallel retained as evidence"
585        );
586    }
587}