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, CommandStatus, String)>,
86}
87
88pub(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 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 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 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
185pub(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 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 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
289pub(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}