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}