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