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                    in_place: false,
41                    session: None,
42                };
43                let provider = std::sync::Arc::clone(provider);
44                scope.spawn(move || {
45                    let res = run_task(
46                        path,
47                        Some(feature),
48                        task,
49                        state,
50                        provider,
51                        secret,
52                        &lane_options,
53                    );
54                    (task.clone(), res)
55                })
56            })
57            .collect();
58        handles
59            .into_iter()
60            .zip(group)
61            .map(|(h, task)| {
62                h.join().unwrap_or_else(|_| {
63                    (
64                        task.clone(),
65                        CommandResult::new("run", CommandStatus::Error, "the lane panicked"),
66                    )
67                })
68            })
69            .collect()
70    })
71}
72
73/// What merging a parallel group did.
74#[derive(Default)]
75pub(crate) struct LanesMerged {
76    pub(crate) reports: Vec<speccoder_core::RunReport>,
77    pub(crate) lines: Vec<String>,
78    pub(crate) one_at_a_time: Vec<String>,
79    pub(crate) reverted: bool,
80    pub(crate) stop: Option<(String, CommandStatus, String)>,
81}
82
83/// Merges each passed lane's commit into the feature branch, then runs the
84/// checks on the merged whole: tasks that pass alone can fail together.
85pub(crate) fn merge_lanes(
86    path: &Path,
87    feature_wt: &Path,
88    project: &Path,
89    feature: &str,
90    lanes: Vec<(String, CommandResult)>,
91) -> LanesMerged {
92    let mut out = LanesMerged::default();
93    let g = git::Git::at(feature_wt);
94    let pre = g.line(&["rev-parse", "HEAD"]).unwrap_or_default();
95    let repo_root = git::Git::at(path).toplevel().ok().flatten();
96    let (runner, _) = sandbox::runner_at(feature_wt).unwrap_or((sandbox::Runner::Plain, None));
97    let tier = Some(verify::config::Tier::Fast);
98    let baseline = verify::run_checks(project, tier, &runner).ok();
99    let mut merged: Vec<String> = Vec::new();
100    // (task, the lane's commit, the commit on the feature branch).
101    let mut landed: Vec<(String, String, String)> = Vec::new();
102    for (task, res) in lanes {
103        let report = match res.data() {
104            Some(CommandData::Run(r)) => Some((**r).clone()),
105            _ => None,
106        };
107        match (res.status(), report.as_ref().and_then(|r| r.commit.clone())) {
108            (CommandStatus::Ok, Some(commit)) => {
109                match merge_lane(feature_wt, project, feature, &task, &commit) {
110                    Ok(head) => {
111                        merged.push(task.clone());
112                        landed.push((task.clone(), commit, head));
113                    }
114                    Err(e) => {
115                        out.lines.push(format!(
116                            "{task}: not merged ({e}); it runs again on its own"
117                        ));
118                        out.one_at_a_time.push(task.clone());
119                    }
120                }
121            }
122            (CommandStatus::Ok, None) => {}
123            (status, _) => {
124                if out.stop.is_none() {
125                    out.stop = Some((task.clone(), status, res.message().to_owned()));
126                }
127            }
128        }
129        if let Some(r) = report {
130            out.reports.push(r);
131        }
132        if let Some(root) = &repo_root {
133            let _ = worktree::remove_lane(root, feature, &task);
134        }
135    }
136    if merged.is_empty() {
137        return out;
138    }
139    let after = verify::run_checks(project, tier, &runner).ok();
140    let together = match (&baseline, &after) {
141        (Some(b), Some(a)) => {
142            speccoder_core::verify::passes(&a.checks, Some(&speccoder_core::verify::compare(b, a)))
143        }
144        _ => false,
145    };
146    if together {
147        // The attestation follows the work onto the feature branch.
148        let attest = crate::verify::find_config(project)
149            .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
150            .and_then(|c| crate::substrate::run_config::parse(&c).ok())
151            .map(|c| c.attest)
152            .filter(|a| a.enabled);
153        if let Some(attest) = &attest {
154            for (task, lane_commit, head) in &landed {
155                let a = crate::attest::carry(feature_wt, lane_commit, head, attest);
156                if let Some(r) = out.reports.iter_mut().find(|r| r.task == *task) {
157                    r.commit = Some(head.clone());
158                    r.attestation = Some(a);
159                }
160            }
161        }
162        out.lines.push(format!(
163            "{} ran in parallel, merged and passed the checks together",
164            merged.join(", ")
165        ));
166    } else {
167        // The feature branch goes back to before the group; the lanes'
168        // work stays in their reports.
169        let _ = g.run(&["reset", "-q", "--hard", &pre]);
170        out.reverted = true;
171        out.one_at_a_time.extend(merged.iter().cloned());
172        out.lines.push(format!(
173            "{} passed alone but not together; the merge was undone and the remaining tasks run one at a time",
174            merged.join(", ")
175        ));
176    }
177    out
178}
179
180/// Applies a lane's task commit on the feature branch: its changes, and
181/// the feature's `tasks.md` with this task ticked (lanes tick neighbouring
182/// lines, which git would call a conflict).
183pub(crate) fn merge_lane(
184    feature_wt: &Path,
185    project: &Path,
186    feature: &str,
187    task: &str,
188    commit: &str,
189) -> Result<String, String> {
190    let g = git::Git::at(feature_wt);
191    let ident = [
192        "-c",
193        "user.name=speccoder",
194        "-c",
195        "user.email=speccoder@users.noreply.invalid",
196        "-c",
197        "commit.gpgsign=false",
198    ];
199    let format = crate::format::at(project);
200    let tasks_file = format
201        .inspect(project, Some(feature))
202        .and_then(|s| s.feature)
203        .and_then(|f| f.tasks)
204        .map(|t| project.join(t.path))
205        .ok_or_else(|| format!("the feature has no {}", format.messages().tasks_file))?;
206    let tasks_arg = tasks_file.to_string_lossy().into_owned();
207    // Untracked files the commit adds were generated here by speccoder's own
208    // check runs (a `Cargo.lock`, say); they would stop the cherry-pick.
209    let added = g
210        .run(&[
211            "diff",
212            "--name-only",
213            "--diff-filter=A",
214            &format!("{commit}^"),
215            commit,
216        ])
217        .map(|b| String::from_utf8_lossy(&b).into_owned())
218        .unwrap_or_default();
219    let untracked = g
220        .run(&["ls-files", "--others", "--exclude-standard"])
221        .map(|b| String::from_utf8_lossy(&b).into_owned())
222        .unwrap_or_default();
223    for f in added.lines().filter(|f| untracked.lines().any(|u| u == *f)) {
224        let _ = std::fs::remove_file(feature_wt.join(f));
225    }
226    let mut pick = ident.to_vec();
227    pick.extend(["cherry-pick", "-n", commit]);
228    let picked = g.run(&pick);
229    let conflicted = g
230        .run(&["diff", "--name-only", "--diff-filter=U"])
231        .map(|b| String::from_utf8_lossy(&b).into_owned())
232        .unwrap_or_default();
233    let tasks_rel = tasks_file
234        .strip_prefix(feature_wt)
235        .map(|p| p.to_string_lossy().into_owned())
236        .unwrap_or_default();
237    // A failure that left no conflict applied nothing (an empty merge would
238    // tick a task whose work is missing).
239    if let Err(e) = &picked
240        && conflicted.trim().is_empty()
241    {
242        let _ = g.run(&["reset", "-q", "--hard", "HEAD"]);
243        return Err(e
244            .0
245            .lines()
246            .next()
247            .unwrap_or("cherry-pick failed")
248            .to_owned());
249    }
250    let others: Vec<&str> = conflicted.lines().filter(|f| *f != tasks_rel).collect();
251    if !others.is_empty() {
252        let _ = g.run(&["reset", "-q", "--hard", "HEAD"]);
253        return Err(format!("conflicts in {}", others.join(", ")));
254    }
255    g.run(&["checkout", "HEAD", "--", &tasks_arg])
256        .map_err(|e| e.0)?;
257    let line_hash = format
258        .inspect(project, Some(feature))
259        .and_then(|s| s.feature)
260        .and_then(|f| f.tasks)
261        .and_then(|t| t.tasks.into_iter().find(|t| t.id == task))
262        .map(|t| t.line_hash)
263        .ok_or_else(|| {
264            format!(
265                "{task} is not in the feature's {}",
266                format.messages().tasks_file
267            )
268        })?;
269    format
270        .tick(&tasks_file, task, &line_hash)
271        .map_err(|e| e.to_string())?;
272    let mut add = ident.to_vec();
273    add.extend(["add", "-A"]);
274    g.run(&add).map_err(|e| e.0)?;
275    let mut c = ident.to_vec();
276    c.extend(["commit", "-q", "--no-verify", "-C", commit]);
277    g.run(&c).map_err(|e| e.0)?;
278    g.line(&["rev-parse", "HEAD"]).map_err(|e| e.0)
279}
280
281/// The worktree of `lane` for `feature`, started where the feature branch
282/// (`wt_path`) is now: `(lane root, lane project dir)`.
283pub(crate) fn lane_worktree(
284    path: &Path,
285    state: &Path,
286    feature: &str,
287    lane: &str,
288    wt_path: &str,
289    project_dir: &str,
290) -> Result<(String, String), String> {
291    let start = git::Git::at(Path::new(wt_path))
292        .line(&["rev-parse", "HEAD"])
293        .map_err(|e| e.0)?;
294    let repo_root = match git::Git::at(path).toplevel() {
295        Ok(Some(r)) => r,
296        _ => return Err("not a git repository".to_owned()),
297    };
298    let guard = LANE_SETUP.lock();
299    let made = worktree::ensure_lane(&repo_root, state, feature, lane, &start);
300    drop(guard);
301    let l = made.map_err(|e| e.message)?;
302    let rel = Path::new(project_dir)
303        .strip_prefix(wt_path)
304        .map(Path::to_path_buf)
305        .unwrap_or_default();
306    Ok((
307        l.path.display().to_string(),
308        l.path.join(rel).display().to_string(),
309    ))
310}