Skip to main content

car_multi/patterns/foreman/
planner.rs

1//! B5 — the decomposition planner.
2//!
3//! Turns a natural-language goal into footprint-annotated [`Subtask`]s and a
4//! parallel schedule. Follows `car-builder`'s injected-generate pattern: the
5//! caller supplies a `generate` closure (tests pass a fake; the daemon passes
6//! inference), and the planner runs prompt → generate → parse → verify → repair.
7//! The *verify* step is the B4 footprint analyzer: if the model double-assigned a
8//! symbol (two subtasks declaring the same write), that surfaces as a conflict
9//! and is fed back for repair.
10//!
11//! Planning never bypasses the merge-verify gate — a produced plan is only a
12//! *proposal*; correctness is still established per-subtask and at the union by
13//! the gate during [`run_farm_out`](super::harness::run_farm_out). The planner's
14//! decomposability check is purely advisory: when no real parallelism is found
15//! it recommends a single session instead.
16
17use std::future::Future;
18use std::path::Path;
19
20use std::collections::HashSet;
21
22use car_ast::{
23    analyze, expand_footprint, FootprintSubtask, ProjectIndex, SymbolFootprint, SymbolRef,
24};
25use serde::Deserialize;
26
27use super::harness::{Subtask, FOOTPRINT_BLAST_DEPTH};
28
29/// Outcome of decomposing a goal.
30#[derive(Debug)]
31pub struct DecomposeResult {
32    /// Footprint-annotated subtasks (empty if no valid plan was produced).
33    pub subtasks: Vec<Subtask>,
34    /// The scheduled levels (subtask ids) the footprint analyzer produced.
35    pub levels: Vec<Vec<String>>,
36    /// `true` when farming out buys no parallel speedup (≤1 subtask, or every
37    /// level is a single subtask). Advisory only — a serialized multi-subtask
38    /// plan can still be worth farming out (context isolation, per-subtask model
39    /// choice, checkpointing), so the caller may override.
40    pub prefer_single_session: bool,
41    pub attempts: u32,
42    /// Parse / decomposition issues from the final attempt (empty on success).
43    pub issues: Vec<String>,
44}
45
46impl DecomposeResult {
47    pub fn is_valid(&self) -> bool {
48        !self.subtasks.is_empty() && self.issues.is_empty()
49    }
50}
51
52// ---- wire shape the model emits ----
53
54#[derive(Deserialize)]
55struct WireRef {
56    file: String,
57    symbol: String,
58}
59
60#[derive(Deserialize)]
61struct WireSubtask {
62    id: String,
63    #[serde(default)]
64    prompt: String,
65    #[serde(default)]
66    files: Vec<String>,
67    #[serde(default)]
68    writes: Vec<WireRef>,
69    #[serde(default)]
70    reads: Vec<WireRef>,
71}
72
73#[derive(Deserialize)]
74struct WirePlan {
75    subtasks: Vec<WireSubtask>,
76}
77
78/// Parse a model response into footprint-annotated subtasks. Tolerant of
79/// markdown fences / preamble: tries a direct parse, then the first `{...}`
80/// block.
81pub fn parse_plan(text: &str) -> Result<Vec<Subtask>, String> {
82    let wire: WirePlan = serde_json::from_str(text)
83        .or_else(|_| {
84            let start = text.find('{').ok_or("no JSON object found")?;
85            let end = text.rfind('}').ok_or("no closing brace")?;
86            if end <= start {
87                return Err("malformed JSON span".to_string());
88            }
89            serde_json::from_str(&text[start..=end]).map_err(|e| e.to_string())
90        })
91        .map_err(|e: String| format!("parse failed: {e}"))?;
92
93    if wire.subtasks.is_empty() {
94        return Err("plan has no subtasks".to_string());
95    }
96    let mut seen = HashSet::new();
97    for w in &wire.subtasks {
98        if !seen.insert(w.id.as_str()) {
99            return Err(format!("duplicate subtask id '{}'", w.id));
100        }
101    }
102
103    let subtasks = wire
104        .subtasks
105        .into_iter()
106        .map(|w| {
107            let footprint = if w.writes.is_empty() && w.reads.is_empty() {
108                None
109            } else {
110                Some(SymbolFootprint {
111                    writes: w
112                        .writes
113                        .iter()
114                        .map(|r| SymbolRef::new(r.file.clone(), r.symbol.clone()))
115                        .collect(),
116                    reads: w
117                        .reads
118                        .iter()
119                        .map(|r| SymbolRef::new(r.file.clone(), r.symbol.clone()))
120                        .collect(),
121                    uncertain: false,
122                })
123            };
124            // Files default to the union of footprint file paths when omitted, so
125            // the dumb partitioner still has a key if footprints are dropped.
126            let files = if w.files.is_empty() {
127                let mut fs: Vec<String> = w
128                    .writes
129                    .iter()
130                    .chain(&w.reads)
131                    .map(|r| r.file.clone())
132                    .collect();
133                fs.sort();
134                fs.dedup();
135                fs
136            } else {
137                w.files
138            };
139            Subtask {
140                id: w.id,
141                prompt: w.prompt,
142                files,
143                footprint,
144            }
145        })
146        .collect();
147    Ok(annotate_stub_dependencies(subtasks))
148}
149
150/// Tell a subtask, in its own prompt, that the symbols it declared a read on are
151/// still UNIMPLEMENTED in its working copy.
152///
153/// A declared read schedules the subtask into a later level, but every worktree
154/// is provisioned from `repo_root` HEAD and no upstream patch is staged into it
155/// (deliberately — see
156/// `docs/solutions/foreman-patches-must-stay-independent-diffs.md`; staging
157/// would make the merge-verify gate false-accept broken merges). So the agent
158/// opens its dependency and finds a stub.
159///
160/// Left unsaid, that has two bad outcomes and both look like coordination
161/// failures at the union: the agent reimplements the dependency inline, which is
162/// a containment violation against its declared footprint and is rejected before
163/// its build even runs; or it silently invents different semantics and the union
164/// gate catches a divergence that better instructions would have prevented.
165///
166/// This is prompt text only. It cannot affect what the gate sees, and every
167/// patch stays an independent diff from one base.
168fn annotate_stub_dependencies(mut subtasks: Vec<Subtask>) -> Vec<Subtask> {
169    // Only reads satisfied by ANOTHER subtask in this plan are stubs. A read of
170    // code that already exists in the repo is perfectly usable and must not be
171    // described as unimplemented.
172    let mut planned_writes: HashSet<SymbolRef> = HashSet::new();
173    for st in &subtasks {
174        if let Some(fp) = &st.footprint {
175            planned_writes.extend(fp.writes.iter().cloned());
176        }
177    }
178
179    for st in &mut subtasks {
180        let Some(fp) = &st.footprint else { continue };
181        let mut pending: Vec<String> = fp
182            .reads
183            .iter()
184            .filter(|r| planned_writes.contains(r))
185            .map(|r| format!("`{}` in {}", r.symbol, r.file))
186            .collect();
187        if pending.is_empty() {
188            continue;
189        }
190        pending.sort();
191        pending.dedup();
192        st.prompt = format!(
193            "{}\n\nNote: {} {} written by a different subtask in this plan and {} still an \
194             unimplemented stub in your working copy. Do NOT implement {} yourself — that is \
195             outside your declared footprint and will be rejected. Write against the documented \
196             contract (the signature and doc comment) instead.",
197            st.prompt,
198            pending.join(", "),
199            if pending.len() == 1 { "is" } else { "are" },
200            if pending.len() == 1 { "is" } else { "are" },
201            if pending.len() == 1 { "it" } else { "them" },
202        );
203    }
204    subtasks
205}
206
207/// Schedule the plan and detect double-assigned symbols (two subtasks declaring
208/// the same write — a planner mistake the gate would later catch, but cheaper to
209/// fix here). Returns `(levels, conflicts)`.
210fn evaluate(index: &ProjectIndex, subtasks: &[Subtask]) -> (Vec<Vec<String>>, Vec<String>) {
211    let fsubs: Vec<FootprintSubtask> = subtasks
212        .iter()
213        .filter_map(|s| {
214            s.footprint.as_ref().map(|fp| FootprintSubtask {
215                id: s.id.clone(),
216                footprint: expand_footprint(index, fp, FOOTPRINT_BLAST_DEPTH),
217            })
218        })
219        .collect();
220    if fsubs.len() != subtasks.len() {
221        // Some subtasks have no footprint — can't analyze symbolically; let the
222        // harness fall back to file partitioning, no conflicts surfaced here.
223        return (Vec::new(), Vec::new());
224    }
225    let plan = analyze(&fsubs);
226    let conflicts = plan
227        .conflicts
228        .iter()
229        .map(|(a, b)| format!("subtasks '{a}' and '{b}' write overlapping symbols — split, merge, or hoist a shared contract"))
230        .collect();
231    (plan.levels, conflicts)
232}
233
234/// Decompose `goal` into footprint-annotated subtasks. `generate(prompt)`
235/// returns the model's raw response. Repairs up to `max_attempts` times when the
236/// response won't parse or the declared footprints conflict.
237pub async fn decompose<F, Fut>(
238    repo_root: &Path,
239    goal: &str,
240    max_attempts: u32,
241    generate: F,
242) -> DecomposeResult
243where
244    F: Fn(String) -> Fut,
245    Fut: Future<Output = Result<String, String>>,
246{
247    let max_attempts = max_attempts.max(1);
248    let mut issues: Vec<String> = Vec::new();
249    let mut attempts = 0;
250    // Build the symbol index ONCE — the repo doesn't change across repair
251    // attempts, so rebuilding it per attempt just re-pays the index budget (the
252    // 5s/5000-file wall) for nothing.
253    let index = ProjectIndex::build(repo_root);
254
255    while attempts < max_attempts {
256        attempts += 1;
257        let prompt = build_prompt(goal, &issues);
258        let raw = match generate(prompt).await {
259            Ok(r) => r,
260            Err(e) => {
261                issues = vec![format!("generation failed: {e}")];
262                continue;
263            }
264        };
265        let subtasks = match parse_plan(&raw) {
266            Ok(s) => s,
267            Err(e) => {
268                issues = vec![e];
269                continue;
270            }
271        };
272        // Every subtask must declare a footprint. A missing one silently disables
273        // both symbolic scheduling and the conflict pre-check, so treat it as a
274        // repair-worthy defect rather than accepting a half-analyzed plan.
275        let missing: Vec<&str> = subtasks
276            .iter()
277            .filter(|s| s.footprint.is_none())
278            .map(|s| s.id.as_str())
279            .collect();
280        if !missing.is_empty() {
281            issues = vec![format!(
282                "subtasks {missing:?} declared no writes/reads; every subtask must declare its symbol footprint"
283            )];
284            continue;
285        }
286        let (levels, conflicts) = evaluate(&index, &subtasks);
287        if !conflicts.is_empty() {
288            issues = conflicts;
289            continue;
290        }
291
292        // Success.
293        let prefer_single_session =
294            subtasks.len() <= 1 || (!levels.is_empty() && levels.iter().all(|l| l.len() <= 1));
295        return DecomposeResult {
296            subtasks,
297            levels,
298            prefer_single_session,
299            attempts,
300            issues: Vec::new(),
301        };
302    }
303
304    DecomposeResult {
305        subtasks: Vec::new(),
306        levels: Vec::new(),
307        prefer_single_session: true,
308        attempts,
309        issues,
310    }
311}
312
313fn build_prompt(goal: &str, prior_issues: &[String]) -> String {
314    let mut p = String::new();
315    p.push_str("Decompose this coding goal into independent subtasks. Emit JSON:\n");
316    p.push_str(
317        "{\"subtasks\":[{\"id\":\"...\",\"prompt\":\"...\",\"writes\":[{\"file\":\"path\",\"symbol\":\"name\"}],\"reads\":[...]}]}\n",
318    );
319    p.push_str("Each subtask declares the symbols it WRITES (defines/modifies) and READS.\n");
320    p.push_str("Rules:\n");
321    // Curb over-decomposition: the model invented scaffolding/"registry"
322    // subtasks (e.g. one just for `pub mod ...;` wiring), which collide with the
323    // real subtasks. Fewer subtasks, no scaffolding.
324    p.push_str(
325        "- Use the FEWEST subtasks that cover the goal. Do NOT add scaffolding/setup/registry subtasks — module declarations, imports, and wiring already exist or belong to the subtask that needs them.\n",
326    );
327    // The conflict-vs-dependency distinction is the whole point: a symbol one
328    // subtask creates and another uses is a READ on the second, not a duplicate
329    // WRITE. Exactly ONE subtask writes each symbol.
330    p.push_str(
331        "- Exactly ONE subtask WRITES each symbol. If subtask B uses a symbol that subtask A defines, put that symbol in B's `reads` (NOT B's `writes`).\n",
332    );
333    p.push_str(
334        "- If two subtasks would have to modify the SAME symbol, they are not independent — merge them into one subtask.\n",
335    );
336    p.push_str("- One file per subtask is a good default.\n");
337    // Worked example of the read-vs-write distinction — the stubborn case where
338    // the model declares a *consumed* symbol as a write. Calling a function is a
339    // READ of it, not a WRITE.
340    p.push_str(
341        "Example — goal \"add `parse()` in util.rs, and `total()` in lib.rs that calls `parse()`\":\n",
342    );
343    p.push_str(
344        "  {\"subtasks\":[{\"id\":\"parse\",\"writes\":[{\"file\":\"util.rs\",\"symbol\":\"parse\"}]},{\"id\":\"total\",\"writes\":[{\"file\":\"lib.rs\",\"symbol\":\"total\"}],\"reads\":[{\"file\":\"util.rs\",\"symbol\":\"parse\"}]}]}\n",
345    );
346    p.push_str(
347        "  `total` calls `parse`, so `parse` is in `total`'s READS — NOT its writes. Only `parse`'s own subtask writes it.\n\nGOAL: ",
348    );
349    p.push_str(goal);
350    if !prior_issues.is_empty() {
351        p.push_str("\n\nFix these problems from the previous attempt:\n");
352        for issue in prior_issues {
353            p.push_str("- ");
354            p.push_str(issue);
355            p.push('\n');
356        }
357    }
358    p
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn repo() -> tempfile::TempDir {
366        let dir = tempfile::tempdir().unwrap();
367        let root = dir.path();
368        for args in [
369            vec!["init", "-q", "-b", "main"],
370            vec!["config", "user.email", "t@t.t"],
371            vec!["config", "user.name", "t"],
372        ] {
373            std::process::Command::new("git")
374                .args(&args)
375                .current_dir(root)
376                .output()
377                .unwrap();
378        }
379        std::fs::create_dir_all(root.join("src")).unwrap();
380        std::fs::write(root.join("src/a.rs"), "pub fn a() {}\n").unwrap();
381        std::fs::write(root.join("src/b.rs"), "pub fn b() {}\n").unwrap();
382        std::process::Command::new("git")
383            .args(["add", "-A"])
384            .current_dir(root)
385            .output()
386            .unwrap();
387        std::process::Command::new("git")
388            .args(["commit", "-qm", "base"])
389            .current_dir(root)
390            .output()
391            .unwrap();
392        dir
393    }
394
395    #[test]
396    fn parse_plan_extracts_footprints() {
397        let text = r#"prose... {"subtasks":[
398            {"id":"x","prompt":"do x","writes":[{"file":"src/a.rs","symbol":"a"}]},
399            {"id":"y","prompt":"do y","reads":[{"file":"src/a.rs","symbol":"a"}]}
400        ]} trailing"#;
401        let subs = parse_plan(text).unwrap();
402        assert_eq!(subs.len(), 2);
403        assert!(subs[0]
404            .footprint
405            .as_ref()
406            .unwrap()
407            .writes
408            .iter()
409            .any(|r| r.symbol == "a"));
410        assert_eq!(subs[0].files, vec!["src/a.rs".to_string()]);
411    }
412
413    #[tokio::test]
414    async fn decompose_accepts_disjoint_plan() {
415        let dir = repo();
416        let json = r#"{"subtasks":[
417            {"id":"x","prompt":"edit a","writes":[{"file":"src/a.rs","symbol":"a"}]},
418            {"id":"y","prompt":"edit b","writes":[{"file":"src/b.rs","symbol":"b"}]}
419        ]}"#;
420        let result = decompose(dir.path(), "do both", 3, |_p| {
421            let j = json.to_string();
422            async move { Ok(j) }
423        })
424        .await;
425        assert!(result.is_valid(), "{result:?}");
426        assert_eq!(result.subtasks.len(), 2);
427        // Disjoint writes → one parallel level → parallelism is worth it.
428        assert!(!result.prefer_single_session, "{:?}", result.levels);
429    }
430
431    #[tokio::test]
432    async fn decompose_repairs_conflicting_plan_then_gives_up() {
433        let dir = repo();
434        // Always returns two subtasks writing the SAME symbol → conflict every
435        // attempt → exhausts attempts and recommends single session.
436        let bad = r#"{"subtasks":[
437            {"id":"x","prompt":"p","writes":[{"file":"src/a.rs","symbol":"a"}]},
438            {"id":"y","prompt":"q","writes":[{"file":"src/a.rs","symbol":"a"}]}
439        ]}"#;
440        let result = decompose(dir.path(), "g", 3, |_p| {
441            let j = bad.to_string();
442            async move { Ok(j) }
443        })
444        .await;
445        assert_eq!(result.attempts, 3, "retried on conflict");
446        assert!(!result.is_valid());
447        assert!(result.prefer_single_session);
448        assert!(result.issues.iter().any(|i| i.contains("overlapping")));
449    }
450
451    /// A subtask that reads what ANOTHER subtask in the plan writes must be told
452    /// its dependency is a stub, or it reimplements it (containment violation)
453    /// or invents different semantics (union divergence) — and both read as
454    /// coordination failures when they are really missing instructions.
455    #[test]
456    fn dependent_subtask_is_warned_its_dependency_is_a_stub() {
457        let wire = r#"{"subtasks":[
458            {"id":"x","prompt":"implement a","writes":[{"file":"src/a.rs","symbol":"a"}]},
459            {"id":"y","prompt":"implement b using a","writes":[{"file":"src/b.rs","symbol":"b"}],"reads":[{"file":"src/a.rs","symbol":"a"}]}
460        ]}"#;
461        let subs = parse_plan(wire).expect("plan parses");
462        let y = subs.iter().find(|s| s.id == "y").unwrap();
463        assert!(
464            y.prompt.contains("unimplemented stub"),
465            "dependent subtask must be told: {:?}",
466            y.prompt
467        );
468        assert!(
469            y.prompt.contains("`a` in src/a.rs"),
470            "the warning must name the symbol: {:?}",
471            y.prompt
472        );
473        // Negative control: the subtask that writes it gets no such note, so the
474        // assertion above cannot be passing because every prompt is annotated.
475        let x = subs.iter().find(|s| s.id == "x").unwrap();
476        assert!(
477            !x.prompt.contains("unimplemented stub"),
478            "the writer must not be warned about its own symbol: {:?}",
479            x.prompt
480        );
481    }
482
483    /// A read of code that ALREADY EXISTS in the repo is usable and must not be
484    /// described as unimplemented. Only reads another subtask writes are stubs.
485    #[test]
486    fn reads_not_written_by_the_plan_are_not_called_stubs() {
487        let wire = r#"{"subtasks":[
488            {"id":"y","prompt":"implement b using existing helper","writes":[{"file":"src/b.rs","symbol":"b"}],"reads":[{"file":"src/existing.rs","symbol":"helper"}]}
489        ]}"#;
490        let subs = parse_plan(wire).expect("plan parses");
491        assert!(
492            !subs[0].prompt.contains("unimplemented stub"),
493            "a pre-existing dependency is real code, not a stub: {:?}",
494            subs[0].prompt
495        );
496    }
497
498    #[test]
499    fn parse_plan_rejects_duplicate_ids() {
500        let text = r#"{"subtasks":[
501            {"id":"x","writes":[{"file":"a.rs","symbol":"a"}]},
502            {"id":"x","writes":[{"file":"b.rs","symbol":"b"}]}
503        ]}"#;
504        let err = parse_plan(text).unwrap_err();
505        assert!(err.contains("duplicate subtask id"), "{err}");
506    }
507
508    #[tokio::test]
509    async fn decompose_treats_missing_footprint_as_repairworthy() {
510        let dir = repo();
511        // A subtask with neither writes nor reads → no footprint → must be
512        // repaired, not silently accepted as a half-analyzed plan.
513        let no_fp = r#"{"subtasks":[{"id":"x","prompt":"p"}]}"#;
514        let result = decompose(dir.path(), "g", 2, |_p| {
515            let j = no_fp.to_string();
516            async move { Ok(j) }
517        })
518        .await;
519        assert!(!result.is_valid());
520        assert!(result
521            .issues
522            .iter()
523            .any(|i| i.contains("declared no writes/reads")));
524    }
525
526    #[tokio::test]
527    async fn decompose_repairs_bad_json_then_succeeds() {
528        let dir = repo();
529        let calls = std::sync::atomic::AtomicU32::new(0);
530        let good =
531            r#"{"subtasks":[{"id":"x","prompt":"p","writes":[{"file":"src/a.rs","symbol":"a"}]}]}"#;
532        let result = decompose(dir.path(), "g", 3, |_p| {
533            let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
534            let good = good.to_string();
535            async move {
536                if n == 0 {
537                    Ok("not json".to_string())
538                } else {
539                    Ok(good)
540                }
541            }
542        })
543        .await;
544        assert!(result.is_valid(), "{result:?}");
545        assert_eq!(result.attempts, 2, "first attempt bad json, second good");
546        // Single subtask → single session recommended.
547        assert!(result.prefer_single_session);
548    }
549}