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}