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