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