speccoder_runtime/pipeline/
lanes.rs1use 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 in_place: false,
41 session: None,
42 };
43 let provider = std::sync::Arc::clone(provider);
44 scope.spawn(move || {
45 let res = run_task(
46 path,
47 Some(feature),
48 task,
49 state,
50 provider,
51 secret,
52 &lane_options,
53 );
54 (task.clone(), res)
55 })
56 })
57 .collect();
58 handles
59 .into_iter()
60 .zip(group)
61 .map(|(h, task)| {
62 h.join().unwrap_or_else(|_| {
63 (
64 task.clone(),
65 CommandResult::new("run", CommandStatus::Error, "the lane panicked"),
66 )
67 })
68 })
69 .collect()
70 })
71}
72
73#[derive(Default)]
75pub(crate) struct LanesMerged {
76 pub(crate) reports: Vec<speccoder_core::RunReport>,
77 pub(crate) lines: Vec<String>,
78 pub(crate) one_at_a_time: Vec<String>,
79 pub(crate) reverted: bool,
80 pub(crate) stop: Option<(String, CommandStatus, String)>,
81}
82
83pub(crate) fn merge_lanes(
86 path: &Path,
87 feature_wt: &Path,
88 project: &Path,
89 feature: &str,
90 lanes: Vec<(String, CommandResult)>,
91) -> LanesMerged {
92 let mut out = LanesMerged::default();
93 let g = git::Git::at(feature_wt);
94 let pre = g.line(&["rev-parse", "HEAD"]).unwrap_or_default();
95 let repo_root = git::Git::at(path).toplevel().ok().flatten();
96 let (runner, _) = sandbox::runner_at(feature_wt).unwrap_or((sandbox::Runner::Plain, None));
97 let tier = Some(verify::config::Tier::Fast);
98 let baseline = verify::run_checks(project, tier, &runner).ok();
99 let mut merged: Vec<String> = Vec::new();
100 let mut landed: Vec<(String, String, String)> = Vec::new();
102 for (task, res) in lanes {
103 let report = match res.data() {
104 Some(CommandData::Run(r)) => Some((**r).clone()),
105 _ => None,
106 };
107 match (res.status(), report.as_ref().and_then(|r| r.commit.clone())) {
108 (CommandStatus::Ok, Some(commit)) => {
109 match merge_lane(feature_wt, project, feature, &task, &commit) {
110 Ok(head) => {
111 merged.push(task.clone());
112 landed.push((task.clone(), commit, head));
113 }
114 Err(e) => {
115 out.lines.push(format!(
116 "{task}: not merged ({e}); it runs again on its own"
117 ));
118 out.one_at_a_time.push(task.clone());
119 }
120 }
121 }
122 (CommandStatus::Ok, None) => {}
123 (status, _) => {
124 if out.stop.is_none() {
125 out.stop = Some((task.clone(), status, res.message().to_owned()));
126 }
127 }
128 }
129 if let Some(r) = report {
130 out.reports.push(r);
131 }
132 if let Some(root) = &repo_root {
133 let _ = worktree::remove_lane(root, feature, &task);
134 }
135 }
136 if merged.is_empty() {
137 return out;
138 }
139 let after = verify::run_checks(project, tier, &runner).ok();
140 let together = match (&baseline, &after) {
141 (Some(b), Some(a)) => {
142 speccoder_core::verify::passes(&a.checks, Some(&speccoder_core::verify::compare(b, a)))
143 }
144 _ => false,
145 };
146 if together {
147 let attest = crate::verify::find_config(project)
149 .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
150 .and_then(|c| crate::substrate::run_config::parse(&c).ok())
151 .map(|c| c.attest)
152 .filter(|a| a.enabled);
153 if let Some(attest) = &attest {
154 for (task, lane_commit, head) in &landed {
155 let a = crate::attest::carry(feature_wt, lane_commit, head, attest);
156 if let Some(r) = out.reports.iter_mut().find(|r| r.task == *task) {
157 r.commit = Some(head.clone());
158 r.attestation = Some(a);
159 }
160 }
161 }
162 out.lines.push(format!(
163 "{} ran in parallel, merged and passed the checks together",
164 merged.join(", ")
165 ));
166 } else {
167 let _ = g.run(&["reset", "-q", "--hard", &pre]);
170 out.reverted = true;
171 out.one_at_a_time.extend(merged.iter().cloned());
172 out.lines.push(format!(
173 "{} passed alone but not together; the merge was undone and the remaining tasks run one at a time",
174 merged.join(", ")
175 ));
176 }
177 out
178}
179
180pub(crate) fn merge_lane(
184 feature_wt: &Path,
185 project: &Path,
186 feature: &str,
187 task: &str,
188 commit: &str,
189) -> Result<String, String> {
190 let g = git::Git::at(feature_wt);
191 let ident = [
192 "-c",
193 "user.name=speccoder",
194 "-c",
195 "user.email=speccoder@users.noreply.invalid",
196 "-c",
197 "commit.gpgsign=false",
198 ];
199 let format = crate::format::at(project);
200 let tasks_file = format
201 .inspect(project, Some(feature))
202 .and_then(|s| s.feature)
203 .and_then(|f| f.tasks)
204 .map(|t| project.join(t.path))
205 .ok_or_else(|| format!("the feature has no {}", format.messages().tasks_file))?;
206 let tasks_arg = tasks_file.to_string_lossy().into_owned();
207 let added = g
210 .run(&[
211 "diff",
212 "--name-only",
213 "--diff-filter=A",
214 &format!("{commit}^"),
215 commit,
216 ])
217 .map(|b| String::from_utf8_lossy(&b).into_owned())
218 .unwrap_or_default();
219 let untracked = g
220 .run(&["ls-files", "--others", "--exclude-standard"])
221 .map(|b| String::from_utf8_lossy(&b).into_owned())
222 .unwrap_or_default();
223 for f in added.lines().filter(|f| untracked.lines().any(|u| u == *f)) {
224 let _ = std::fs::remove_file(feature_wt.join(f));
225 }
226 let mut pick = ident.to_vec();
227 pick.extend(["cherry-pick", "-n", commit]);
228 let picked = g.run(&pick);
229 let conflicted = g
230 .run(&["diff", "--name-only", "--diff-filter=U"])
231 .map(|b| String::from_utf8_lossy(&b).into_owned())
232 .unwrap_or_default();
233 let tasks_rel = tasks_file
234 .strip_prefix(feature_wt)
235 .map(|p| p.to_string_lossy().into_owned())
236 .unwrap_or_default();
237 if let Err(e) = &picked
240 && conflicted.trim().is_empty()
241 {
242 let _ = g.run(&["reset", "-q", "--hard", "HEAD"]);
243 return Err(e
244 .0
245 .lines()
246 .next()
247 .unwrap_or("cherry-pick failed")
248 .to_owned());
249 }
250 let others: Vec<&str> = conflicted.lines().filter(|f| *f != tasks_rel).collect();
251 if !others.is_empty() {
252 let _ = g.run(&["reset", "-q", "--hard", "HEAD"]);
253 return Err(format!("conflicts in {}", others.join(", ")));
254 }
255 g.run(&["checkout", "HEAD", "--", &tasks_arg])
256 .map_err(|e| e.0)?;
257 let line_hash = format
258 .inspect(project, Some(feature))
259 .and_then(|s| s.feature)
260 .and_then(|f| f.tasks)
261 .and_then(|t| t.tasks.into_iter().find(|t| t.id == task))
262 .map(|t| t.line_hash)
263 .ok_or_else(|| {
264 format!(
265 "{task} is not in the feature's {}",
266 format.messages().tasks_file
267 )
268 })?;
269 format
270 .tick(&tasks_file, task, &line_hash)
271 .map_err(|e| e.to_string())?;
272 let mut add = ident.to_vec();
273 add.extend(["add", "-A"]);
274 g.run(&add).map_err(|e| e.0)?;
275 let mut c = ident.to_vec();
276 c.extend(["commit", "-q", "--no-verify", "-C", commit]);
277 g.run(&c).map_err(|e| e.0)?;
278 g.line(&["rev-parse", "HEAD"]).map_err(|e| e.0)
279}
280
281pub(crate) fn lane_worktree(
284 path: &Path,
285 state: &Path,
286 feature: &str,
287 lane: &str,
288 wt_path: &str,
289 project_dir: &str,
290) -> Result<(String, String), String> {
291 let start = git::Git::at(Path::new(wt_path))
292 .line(&["rev-parse", "HEAD"])
293 .map_err(|e| e.0)?;
294 let repo_root = match git::Git::at(path).toplevel() {
295 Ok(Some(r)) => r,
296 _ => return Err("not a git repository".to_owned()),
297 };
298 let guard = LANE_SETUP.lock();
299 let made = worktree::ensure_lane(&repo_root, state, feature, lane, &start);
300 drop(guard);
301 let l = made.map_err(|e| e.message)?;
302 let rel = Path::new(project_dir)
303 .strip_prefix(wt_path)
304 .map(Path::to_path_buf)
305 .unwrap_or_default();
306 Ok((
307 l.path.display().to_string(),
308 l.path.join(rel).display().to_string(),
309 ))
310}