1use std::path::Path;
4
5use speccoder_core::{CommandData, CommandResult, CommandStatus};
6
7use crate::pipeline::*;
8use crate::{git, sandbox, verify, worktree};
9
10pub(crate) static LANE_SETUP: std::sync::Mutex<()> = std::sync::Mutex::new(());
12
13pub(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 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#[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 pub(crate) stop: Option<(String, CommandResult)>,
91}
92
93pub(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 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 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 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
190pub(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 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 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
294pub(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 #[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}