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 };
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#[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, CommandResult)>,
88}
89
90pub(crate) fn merge_lanes(
93 path: &Path,
94 feature_wt: &Path,
95 project: &Path,
96 feature: &str,
97 lanes: Vec<(String, CommandResult)>,
98) -> LanesMerged {
99 let mut out = LanesMerged::default();
100 let g = git::Git::at(feature_wt);
101 let pre = g.line(&["rev-parse", "HEAD"]).unwrap_or_default();
102 let repo_root = git::Git::at(path).toplevel().ok().flatten();
103 let (runner, _) = sandbox::runner_at(feature_wt).unwrap_or((sandbox::Runner::Plain, None));
104 let tier = Some(verify::config::Tier::Fast);
105 let baseline = verify::run_checks(project, tier, &runner).ok();
106 let mut merged: Vec<String> = Vec::new();
107 let mut landed: Vec<(String, String, String)> = Vec::new();
109 for (task, res) in lanes {
110 let report = match res.data() {
111 Some(CommandData::Run(r)) => Some((**r).clone()),
112 _ => None,
113 };
114 match (res.status(), report.as_ref().and_then(|r| r.commit.clone())) {
115 (CommandStatus::Ok, Some(commit)) => {
116 match merge_lane(feature_wt, project, feature, &task, &commit) {
117 Ok(head) => {
118 merged.push(task.clone());
119 landed.push((task.clone(), commit, head));
120 }
121 Err(e) => {
122 out.lines.push(format!(
123 "{task}: not merged ({e}); it runs again on its own"
124 ));
125 out.one_at_a_time.push(task.clone());
126 }
127 }
128 }
129 (CommandStatus::Ok, None) => {}
130 (_, _) => {
131 if out.stop.is_none() {
132 out.stop = Some((task.clone(), res.clone()));
133 }
134 }
135 }
136 if let Some(r) = report {
137 out.reports.push(r);
138 }
139 if let Some(root) = &repo_root {
140 let _ = worktree::remove_lane(root, feature, &task);
141 }
142 }
143 if merged.is_empty() {
144 return out;
145 }
146 let after = verify::run_checks(project, tier, &runner).ok();
147 let together = match (&baseline, &after) {
148 (Some(b), Some(a)) => {
149 speccoder_core::verify::passes(&a.checks, Some(&speccoder_core::verify::compare(b, a)))
150 }
151 _ => false,
152 };
153 if together {
154 let attest = crate::verify::find_config(project)
156 .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
157 .and_then(|c| crate::substrate::run_config::parse(&c).ok())
158 .map(|c| c.attest)
159 .filter(|a| a.enabled);
160 if let Some(attest) = &attest {
161 for (task, lane_commit, head) in &landed {
162 let a = crate::attest::carry(feature_wt, lane_commit, head, attest);
163 if let Some(r) = out.reports.iter_mut().find(|r| r.task == *task) {
164 r.commit = Some(head.clone());
165 r.attestation = Some(a);
166 }
167 }
168 }
169 out.lines.push(format!(
170 "{} ran in parallel, merged and passed the checks together",
171 merged.join(", ")
172 ));
173 } else {
174 let _ = g.run(&["reset", "-q", "--hard", &pre]);
177 out.reverted = true;
178 out.one_at_a_time.extend(merged.iter().cloned());
179 out.lines.push(format!(
180 "{} passed alone but not together; the merge was undone and the remaining tasks run one at a time",
181 merged.join(", ")
182 ));
183 }
184 out
185}
186
187pub(crate) fn merge_lane(
191 feature_wt: &Path,
192 project: &Path,
193 feature: &str,
194 task: &str,
195 commit: &str,
196) -> Result<String, String> {
197 let g = git::Git::at(feature_wt);
198 let ident = [
199 "-c",
200 "user.name=speccoder",
201 "-c",
202 "user.email=speccoder@users.noreply.invalid",
203 "-c",
204 "commit.gpgsign=false",
205 ];
206 let format = crate::format::at(project);
207 let tasks_file = format
208 .inspect(project, Some(feature))
209 .and_then(|s| s.feature)
210 .and_then(|f| f.tasks)
211 .map(|t| project.join(t.path))
212 .ok_or_else(|| format!("the feature has no {}", format.messages().tasks_file))?;
213 let tasks_arg = tasks_file.to_string_lossy().into_owned();
214 let added = g
217 .run(&[
218 "diff",
219 "--name-only",
220 "--diff-filter=A",
221 &format!("{commit}^"),
222 commit,
223 ])
224 .map(|b| String::from_utf8_lossy(&b).into_owned())
225 .unwrap_or_default();
226 let untracked = g
227 .run(&["ls-files", "--others", "--exclude-standard"])
228 .map(|b| String::from_utf8_lossy(&b).into_owned())
229 .unwrap_or_default();
230 for f in added.lines().filter(|f| untracked.lines().any(|u| u == *f)) {
231 let _ = std::fs::remove_file(feature_wt.join(f));
232 }
233 let mut pick = ident.to_vec();
234 pick.extend(["cherry-pick", "-n", commit]);
235 let picked = g.run(&pick);
236 let conflicted = g
237 .run(&["diff", "--name-only", "--diff-filter=U"])
238 .map(|b| String::from_utf8_lossy(&b).into_owned())
239 .unwrap_or_default();
240 let tasks_rel = tasks_file
241 .strip_prefix(feature_wt)
242 .map(|p| p.to_string_lossy().into_owned())
243 .unwrap_or_default();
244 if let Err(e) = &picked
247 && conflicted.trim().is_empty()
248 {
249 let _ = g.run(&["reset", "-q", "--hard", "HEAD"]);
250 return Err(e
251 .0
252 .lines()
253 .next()
254 .unwrap_or("cherry-pick failed")
255 .to_owned());
256 }
257 let others: Vec<&str> = conflicted.lines().filter(|f| *f != tasks_rel).collect();
258 if !others.is_empty() {
259 let _ = g.run(&["reset", "-q", "--hard", "HEAD"]);
260 return Err(format!("conflicts in {}", others.join(", ")));
261 }
262 g.run(&["checkout", "HEAD", "--", &tasks_arg])
263 .map_err(|e| e.0)?;
264 let line_hash = format
265 .inspect(project, Some(feature))
266 .and_then(|s| s.feature)
267 .and_then(|f| f.tasks)
268 .and_then(|t| t.tasks.into_iter().find(|t| t.id == task))
269 .map(|t| t.line_hash)
270 .ok_or_else(|| {
271 format!(
272 "{task} is not in the feature's {}",
273 format.messages().tasks_file
274 )
275 })?;
276 format
277 .tick(&tasks_file, task, &line_hash)
278 .map_err(|e| e.to_string())?;
279 let excluded = crate::include::pathspecs(feature_wt);
280 let mut add = ident.to_vec();
281 add.extend(["add", "-A", "--", ":/"]);
282 add.extend(excluded.iter().map(String::as_str));
283 g.run(&add).map_err(|e| e.0)?;
284 crate::include::check_staged(feature_wt)?;
285 let mut c = ident.to_vec();
286 c.extend(["commit", "-q", "--no-verify", "-C", commit]);
287 g.run(&c).map_err(|e| e.0)?;
288 g.line(&["rev-parse", "HEAD"]).map_err(|e| e.0)
289}
290
291pub(crate) fn lane_worktree(
294 path: &Path,
295 state: &Path,
296 feature: &str,
297 lane: &str,
298 wt_path: &str,
299 project_dir: &str,
300) -> Result<(String, String), String> {
301 let start = git::Git::at(Path::new(wt_path))
302 .line(&["rev-parse", "HEAD"])
303 .map_err(|e| e.0)?;
304 let repo_root = match git::Git::at(path).toplevel() {
305 Ok(Some(r)) => r,
306 _ => return Err("not a git repository".to_owned()),
307 };
308 let guard = LANE_SETUP.lock();
309 let made = worktree::ensure_lane(&repo_root, state, feature, lane, &start);
310 drop(guard);
311 let l = made.map_err(|e| e.message)?;
312 let rel = Path::new(project_dir)
313 .strip_prefix(wt_path)
314 .map(Path::to_path_buf)
315 .unwrap_or_default();
316 crate::seed::seed(
317 Path::new(wt_path),
318 Path::new(project_dir),
319 &l.path,
320 &l.path.join(&rel),
321 );
322 Ok((
323 l.path.display().to_string(),
324 l.path.join(rel).display().to_string(),
325 ))
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
335 fn a_stopped_lane_keeps_its_code_and_fix_lines() {
336 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
337 let stopped = CommandResult::new("run", CommandStatus::NeedsHuman, "US1-2 still red")
338 .with_code("STORY-OPEN")
339 .with_fix(vec![
340 "speccoder task rule T003 --tests-wrong --scenario US1-2 --reason \"…\"".to_owned(),
341 ]);
342 let out = merge_lanes(
343 dir.path(),
344 dir.path(),
345 dir.path(),
346 "001-graph",
347 vec![("T003".to_owned(), stopped)],
348 );
349 let (task, res) = out.stop.unwrap_or_else(|| panic!("no stop"));
350 assert_eq!(task, "T003");
351 assert_eq!(res.code().map(|c| c.name), Some("STORY-OPEN"));
352 assert!(
353 res.fix()
354 .iter()
355 .any(|f| f.contains("--tests-wrong --scenario US1-2"))
356 );
357 }
358}