Skip to main content

speccoder_runtime/pipeline/
lanes.rs

1//! Parallel lanes for `[P]` tasks.
2
3use std::path::Path;
4
5use speccoder_core::{CommandData, CommandResult, CommandStatus};
6
7use crate::pipeline::*;
8use crate::{git, sandbox, verify, worktree};
9
10/// Lane worktrees are created one at a time (git's worktree bookkeeping).
11pub(crate) static LANE_SETUP: std::sync::Mutex<()> = std::sync::Mutex::new(());
12
13/// Runs each task of `group` in its own lane, at once. Lanes never ask
14/// inline (prompts would interleave): a call that needs approval stops the
15/// lane, and the person decides with `speccoder report --approvals`.
16pub(crate) fn run_lanes(
17    path: &Path,
18    feature: &str,
19    group: &[String],
20    state: &Path,
21    provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
22    secret: Option<&str>,
23    options: &RunOptions,
24) -> Vec<(String, CommandResult)> {
25    std::thread::scope(|scope| {
26        let handles: Vec<_> = group
27            .iter()
28            .map(|task| {
29                let lane_options = RunOptions {
30                    max_tasks: None,
31                    approver: None,
32                    progress: options.progress.clone(),
33                    parallel: 1,
34                    lane: Some(task.clone()),
35                    cancel: None,
36                    ignore_checklists: options.ignore_checklists,
37                    fresh_acceptance: false,
38                    allow_failing_baseline: options.allow_failing_baseline,
39                    run_grants: std::sync::Arc::clone(&options.run_grants),
40                    run_id: std::sync::Arc::clone(&options.run_id),
41                    asked: std::sync::Arc::clone(&options.asked),
42                    in_place: false,
43                    session: None,
44                    bug_home: None,
45                    mode: options.mode,
46                    agent: options.agent.clone(),
47                };
48                let provider = std::sync::Arc::clone(provider);
49                scope.spawn(move || {
50                    let res = run_task(
51                        path,
52                        Some(feature),
53                        task,
54                        state,
55                        provider,
56                        secret,
57                        &lane_options,
58                    );
59                    (task.clone(), res)
60                })
61            })
62            .collect();
63        handles
64            .into_iter()
65            .zip(group)
66            .map(|(h, task)| {
67                h.join().unwrap_or_else(|_| {
68                    (
69                        task.clone(),
70                        CommandResult::new("run", CommandStatus::Error, "the lane panicked"),
71                    )
72                })
73            })
74            .collect()
75    })
76}
77
78/// What merging a parallel group did.
79#[derive(Default)]
80pub(crate) struct LanesMerged {
81    pub(crate) reports: Vec<speccoder_core::RunReport>,
82    pub(crate) lines: Vec<String>,
83    pub(crate) one_at_a_time: Vec<String>,
84    pub(crate) reverted: bool,
85    /// The first lane that stopped: its task and its whole result (its
86    /// code and fix lines are the stop's).
87    pub(crate) stop: Option<(String, CommandResult)>,
88}
89
90/// Merges each passed lane's commit into the feature branch, then runs the
91/// checks on the merged whole: tasks that pass alone can fail together.
92pub(crate) fn merge_lanes(
93    path: &Path,
94    feature_wt: &Path,
95    project: &Path,
96    feature: &str,
97    lanes: Vec<(String, CommandResult)>,
98) -> LanesMerged {
99    let mut out = LanesMerged::default();
100    let g = git::Git::at(feature_wt);
101    let pre = g.line(&["rev-parse", "HEAD"]).unwrap_or_default();
102    let repo_root = git::Git::at(path).toplevel().ok().flatten();
103    let (runner, _) = sandbox::runner_at(feature_wt).unwrap_or((sandbox::Runner::Plain, None));
104    let tier = Some(verify::config::Tier::Fast);
105    let baseline = verify::run_checks(project, tier, &runner).ok();
106    let mut merged: Vec<String> = Vec::new();
107    // (task, the lane's commit, the commit on the feature branch).
108    let mut landed: Vec<(String, String, String)> = Vec::new();
109    for (task, res) in lanes {
110        let report = match res.data() {
111            Some(CommandData::Run(r)) => Some((**r).clone()),
112            _ => None,
113        };
114        match (res.status(), report.as_ref().and_then(|r| r.commit.clone())) {
115            (CommandStatus::Ok, Some(commit)) => {
116                match merge_lane(feature_wt, project, feature, &task, &commit) {
117                    Ok(head) => {
118                        merged.push(task.clone());
119                        landed.push((task.clone(), commit, head));
120                    }
121                    Err(e) => {
122                        out.lines.push(format!(
123                            "{task}: not merged ({e}); it runs again on its own"
124                        ));
125                        out.one_at_a_time.push(task.clone());
126                    }
127                }
128            }
129            (CommandStatus::Ok, None) => {}
130            (_, _) => {
131                if out.stop.is_none() {
132                    out.stop = Some((task.clone(), res.clone()));
133                }
134            }
135        }
136        if let Some(r) = report {
137            out.reports.push(r);
138        }
139        if let Some(root) = &repo_root {
140            let _ = worktree::remove_lane(root, feature, &task);
141        }
142    }
143    if merged.is_empty() {
144        return out;
145    }
146    let after = verify::run_checks(project, tier, &runner).ok();
147    let together = match (&baseline, &after) {
148        (Some(b), Some(a)) => {
149            speccoder_core::verify::passes(&a.checks, Some(&speccoder_core::verify::compare(b, a)))
150        }
151        _ => false,
152    };
153    if together {
154        // The attestation follows the work onto the feature branch.
155        let attest = crate::verify::find_config(project)
156            .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
157            .and_then(|c| crate::substrate::run_config::parse(&c).ok())
158            .map(|c| c.attest)
159            .filter(|a| a.enabled);
160        if let Some(attest) = &attest {
161            for (task, lane_commit, head) in &landed {
162                let a = crate::attest::carry(feature_wt, lane_commit, head, attest);
163                if let Some(r) = out.reports.iter_mut().find(|r| r.task == *task) {
164                    r.commit = Some(head.clone());
165                    r.attestation = Some(a);
166                }
167            }
168        }
169        out.lines.push(format!(
170            "{} ran in parallel, merged and passed the checks together",
171            merged.join(", ")
172        ));
173    } else {
174        // The feature branch goes back to before the group; the lanes'
175        // work stays in their reports.
176        let _ = g.run(&["reset", "-q", "--hard", &pre]);
177        out.reverted = true;
178        out.one_at_a_time.extend(merged.iter().cloned());
179        out.lines.push(format!(
180            "{} passed alone but not together; the merge was undone and the remaining tasks run one at a time",
181            merged.join(", ")
182        ));
183    }
184    out
185}
186
187/// Applies a lane's task commit on the feature branch: its changes, and
188/// the feature's `tasks.md` with this task ticked (lanes tick neighbouring
189/// lines, which git would call a conflict).
190pub(crate) fn merge_lane(
191    feature_wt: &Path,
192    project: &Path,
193    feature: &str,
194    task: &str,
195    commit: &str,
196) -> Result<String, String> {
197    let g = git::Git::at(feature_wt);
198    let ident = [
199        "-c",
200        "user.name=speccoder",
201        "-c",
202        "user.email=speccoder@users.noreply.invalid",
203        "-c",
204        "commit.gpgsign=false",
205    ];
206    let format = crate::format::at(project);
207    let tasks_file = format
208        .inspect(project, Some(feature))
209        .and_then(|s| s.feature)
210        .and_then(|f| f.tasks)
211        .map(|t| project.join(t.path))
212        .ok_or_else(|| format!("the feature has no {}", format.messages().tasks_file))?;
213    let tasks_arg = tasks_file.to_string_lossy().into_owned();
214    // Untracked files the commit adds were generated here by speccoder's own
215    // check runs (a `Cargo.lock`, say); they would stop the cherry-pick.
216    let added = g
217        .run(&[
218            "diff",
219            "--name-only",
220            "--diff-filter=A",
221            &format!("{commit}^"),
222            commit,
223        ])
224        .map(|b| String::from_utf8_lossy(&b).into_owned())
225        .unwrap_or_default();
226    let untracked = g
227        .run(&["ls-files", "--others", "--exclude-standard"])
228        .map(|b| String::from_utf8_lossy(&b).into_owned())
229        .unwrap_or_default();
230    for f in added.lines().filter(|f| untracked.lines().any(|u| u == *f)) {
231        let _ = std::fs::remove_file(feature_wt.join(f));
232    }
233    let mut pick = ident.to_vec();
234    pick.extend(["cherry-pick", "-n", commit]);
235    let picked = g.run(&pick);
236    let conflicted = g
237        .run(&["diff", "--name-only", "--diff-filter=U"])
238        .map(|b| String::from_utf8_lossy(&b).into_owned())
239        .unwrap_or_default();
240    let tasks_rel = tasks_file
241        .strip_prefix(feature_wt)
242        .map(|p| p.to_string_lossy().into_owned())
243        .unwrap_or_default();
244    // A failure that left no conflict applied nothing (an empty merge would
245    // tick a task whose work is missing).
246    if let Err(e) = &picked
247        && conflicted.trim().is_empty()
248    {
249        let _ = g.run(&["reset", "-q", "--hard", "HEAD"]);
250        return Err(e
251            .0
252            .lines()
253            .next()
254            .unwrap_or("cherry-pick failed")
255            .to_owned());
256    }
257    let others: Vec<&str> = conflicted.lines().filter(|f| *f != tasks_rel).collect();
258    if !others.is_empty() {
259        let _ = g.run(&["reset", "-q", "--hard", "HEAD"]);
260        return Err(format!("conflicts in {}", others.join(", ")));
261    }
262    g.run(&["checkout", "HEAD", "--", &tasks_arg])
263        .map_err(|e| e.0)?;
264    let line_hash = format
265        .inspect(project, Some(feature))
266        .and_then(|s| s.feature)
267        .and_then(|f| f.tasks)
268        .and_then(|t| t.tasks.into_iter().find(|t| t.id == task))
269        .map(|t| t.line_hash)
270        .ok_or_else(|| {
271            format!(
272                "{task} is not in the feature's {}",
273                format.messages().tasks_file
274            )
275        })?;
276    format
277        .tick(&tasks_file, task, &line_hash)
278        .map_err(|e| e.to_string())?;
279    let excluded = crate::include::pathspecs(feature_wt);
280    let mut add = ident.to_vec();
281    add.extend(["add", "-A", "--", ":/"]);
282    add.extend(excluded.iter().map(String::as_str));
283    g.run(&add).map_err(|e| e.0)?;
284    crate::include::check_staged(feature_wt)?;
285    let mut c = ident.to_vec();
286    c.extend(["commit", "-q", "--no-verify", "-C", commit]);
287    g.run(&c).map_err(|e| e.0)?;
288    g.line(&["rev-parse", "HEAD"]).map_err(|e| e.0)
289}
290
291/// The worktree of `lane` for `feature`, started where the feature branch
292/// (`wt_path`) is now: `(lane root, lane project dir)`.
293pub(crate) fn lane_worktree(
294    path: &Path,
295    state: &Path,
296    feature: &str,
297    lane: &str,
298    wt_path: &str,
299    project_dir: &str,
300) -> Result<(String, String), String> {
301    let start = git::Git::at(Path::new(wt_path))
302        .line(&["rev-parse", "HEAD"])
303        .map_err(|e| e.0)?;
304    let repo_root = match git::Git::at(path).toplevel() {
305        Ok(Some(r)) => r,
306        _ => return Err("not a git repository".to_owned()),
307    };
308    let guard = LANE_SETUP.lock();
309    let made = worktree::ensure_lane(&repo_root, state, feature, lane, &start);
310    drop(guard);
311    let l = made.map_err(|e| e.message)?;
312    let rel = Path::new(project_dir)
313        .strip_prefix(wt_path)
314        .map(Path::to_path_buf)
315        .unwrap_or_default();
316    crate::seed::seed(
317        Path::new(wt_path),
318        Path::new(project_dir),
319        &l.path,
320        &l.path.join(&rel),
321    );
322    Ok((
323        l.path.display().to_string(),
324        l.path.join(rel).display().to_string(),
325    ))
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    /// A lane that stopped keeps its whole result: its code and the fix
333    /// lines it offers (a ruling) reach the feature run's stop.
334    #[test]
335    fn a_stopped_lane_keeps_its_code_and_fix_lines() {
336        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
337        let stopped = CommandResult::new("run", CommandStatus::NeedsHuman, "US1-2 still red")
338            .with_code("STORY-OPEN")
339            .with_fix(vec![
340                "speccoder task rule T003 --tests-wrong --scenario US1-2 --reason \"…\"".to_owned(),
341            ]);
342        let out = merge_lanes(
343            dir.path(),
344            dir.path(),
345            dir.path(),
346            "001-graph",
347            vec![("T003".to_owned(), stopped)],
348        );
349        let (task, res) = out.stop.unwrap_or_else(|| panic!("no stop"));
350        assert_eq!(task, "T003");
351        assert_eq!(res.code().map(|c| c.name), Some("STORY-OPEN"));
352        assert!(
353            res.fix()
354                .iter()
355                .any(|f| f.contains("--tests-wrong --scenario US1-2"))
356        );
357    }
358}