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 };
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#[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
87pub(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 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 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 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
184pub(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 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 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
285pub(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}