1use std::path::Path;
4use std::sync::Arc;
5
6use speccoder_core::{CommandData, CommandResult, CommandStatus};
7
8use crate::pipeline::*;
9use crate::{controls, convergence, hosting, progress, substrate, worktree};
10
11#[must_use]
15pub fn run_feature(
16 path: &Path,
17 feature: Option<&str>,
18 options: &RunOptions,
19 converge: bool,
20) -> CommandResult {
21 with_driver("run", path, feature, |state, provider, key| {
22 if converge {
23 run_converged_with(path, feature, state, provider, Some(key), options)
24 } else {
25 run_feature_with(path, feature, state, provider, Some(key), options)
26 }
27 })
28}
29
30#[must_use]
33pub fn converge(path: &Path, feature: Option<&str>) -> CommandResult {
34 converge_in(path, feature, false)
35}
36
37#[must_use]
39pub fn converge_in(path: &Path, feature: Option<&str>, in_place: bool) -> CommandResult {
40 let options = RunOptions {
41 in_place,
42 ..RunOptions::default()
43 };
44 with_driver("converge", path, feature, |state, provider, key| {
45 convergence::converge_with_options(
46 path,
47 feature,
48 state,
49 Arc::clone(provider),
50 Some(key),
51 &options,
52 )
53 })
54}
55
56pub(crate) fn with_driver(
59 command: &str,
60 path: &Path,
61 feature: Option<&str>,
62 f: impl FnOnce(&Path, &Arc<dyn agentplane::model::ModelProvider>, &str) -> CommandResult,
63) -> CommandResult {
64 let Some(state) = worktree::state_dir() else {
65 return CommandResult::new(
66 command,
67 CommandStatus::ConfigError,
68 "no state directory (set SPECCODER_STATE_DIR or HOME)",
69 )
70 .with_code("NO-STATE-DIR");
71 };
72 let model = match setup(path, feature, &state, command) {
73 Ok(s) => s.model,
74 Err(r) => return *r,
75 };
76 let (driver, key) = match substrate::model_config::open_driver(&model) {
77 Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
78 Err(e) => return substrate::model_config::refused(command, &e),
79 };
80 match driver {
81 Ok(provider) => f(&state, &provider, &key),
82 Err(e) => CommandResult::new(command, CommandStatus::ConfigError, &redact(&e, Some(&key))),
83 }
84}
85
86pub fn run_converged_with(
90 path: &Path,
91 feature: Option<&str>,
92 state: &Path,
93 provider: &Arc<dyn agentplane::model::ModelProvider>,
94 secret: Option<&str>,
95 options: &RunOptions,
96) -> CommandResult {
97 if options.in_place && options.session.is_none() {
99 return in_session("run", path, feature, state, options, |o| {
100 run_converged_with(path, feature, state, provider, secret, o)
101 });
102 }
103 let mut attempts = Vec::new();
104 let mut rounds = Vec::new();
105 loop {
106 let res = run_feature_with(path, feature, state, provider, secret, options);
107 let Some(CommandData::Feature(fr)) = res.data() else {
108 return res;
109 };
110 let mut fr = (**fr).clone();
111 attempts.append(&mut fr.attempts);
112 let finished = res.status() == CommandStatus::Ok && fr.stop.starts_with("feature done");
113 if !finished {
114 fr.attempts = attempts;
115 fr.converge = rounds;
116 return CommandResult::carried("run", &res)
117 .with_data(CommandData::Feature(Box::new(fr)));
118 }
119 if let Some(p) = &options.progress {
120 p.event(&progress::Event::Notice {
121 text: format!("converge: assessing {} against its spec", fr.feature),
122 });
123 }
124 let c = convergence::converge_with_options(
125 path,
126 feature,
127 state,
128 Arc::clone(provider),
129 secret,
130 options,
131 );
132 if let Some(p) = &options.progress {
133 p.event(&progress::Event::Notice {
134 text: format!("converge: {}", c.message()),
135 });
136 }
137 let outcome = match c.data() {
138 Some(CommandData::Converge(r)) => {
139 let outcome = r.outcome.clone();
140 rounds.push((**r).clone());
141 outcome
142 }
143 _ => String::new(),
144 };
145 if outcome != "tasks_appended" {
146 fr.attempts = attempts;
147 fr.converge = rounds;
148 fr.stop = format!("converge: {}", c.message());
149 return CommandResult::new("run", c.status(), &fr.stop.clone())
150 .with_data(CommandData::Feature(Box::new(fr)));
151 }
152 }
153}
154
155pub fn run_feature_with(
157 path: &Path,
158 feature: Option<&str>,
159 state: &Path,
160 provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
161 secret: Option<&str>,
162 options: &RunOptions,
163) -> CommandResult {
164 if options.in_place && options.session.is_none() {
166 return in_session("run", path, feature, state, options, |o| {
167 run_feature_with(path, feature, state, provider, secret, o)
168 });
169 }
170 let max_tasks = options.max_tasks;
171 use speccoder_core::FeatureReport;
172 use speccoder_core::snapshot::TaskState;
173
174 let mut attempts = Vec::new();
175 let mut feature_name = feature.map(str::to_owned);
176 let hook_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
177 let parallel_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
178 let full_tier: std::cell::RefCell<Option<speccoder_core::FullTier>> =
179 std::cell::RefCell::new(None);
180 let finish = |name: Option<String>, attempts, status: CommandStatus, stop: String| {
181 CommandResult::new("run", status, &stop).with_data(CommandData::Feature(Box::new(
182 FeatureReport {
183 feature: name.unwrap_or_default(),
184 attempts,
185 stop,
186 converge: Vec::new(),
187 hooks: hook_lines.borrow().clone(),
188 parallel: parallel_lines.borrow().clone(),
189 full_tier: full_tier.borrow().clone(),
190 },
191 )))
192 };
193 let mut one_at_a_time: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
195 let mut parallel_off = false;
196 let mut budget = max_tasks.unwrap_or(usize::MAX);
197 if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
199 return *stop;
200 }
201 match hosting::run_hooks(
203 "before_implement",
204 "",
205 path,
206 state,
207 provider,
208 secret,
209 options,
210 ) {
211 Ok(lines) => hook_lines.borrow_mut().extend(lines),
212 Err(stop) => {
213 return finish(
214 feature_name,
215 attempts,
216 stop.status(),
217 stop.message().to_owned(),
218 );
219 }
220 }
221 let mut planned = false;
222 while budget > 0 {
223 if options
224 .cancel
225 .as_ref()
226 .is_some_and(|c| c.load(std::sync::atomic::Ordering::SeqCst))
227 {
228 return finish(
229 feature_name,
230 attempts,
231 CommandStatus::NeedsHuman,
232 "cancelled by the person before the next task".to_owned(),
233 );
234 }
235 let wt = workspace(path, feature_name.as_deref(), state, options);
237 let Some(CommandData::Worktree(w)) = wt.data() else {
238 return CommandResult::carried("run", &wt);
239 };
240 if wt.status() != CommandStatus::Ok {
241 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
242 }
243 feature_name = Some(w.feature.clone());
244 let project = std::path::PathBuf::from(w.project_dir.clone().unwrap_or_default());
245 let format = crate::format::at(&project);
246 let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
247 return CommandResult::new(
248 "run",
249 CommandStatus::NotAProject,
250 format.messages().no_project_in_worktree,
251 );
252 };
253 let schedule = snap
254 .feature
255 .as_ref()
256 .and_then(|f| f.schedule.clone())
257 .unwrap_or_else(|| speccoder_core::snapshot::Schedule {
258 tasks: Vec::new(),
259 totals: speccoder_core::snapshot::ScheduleTotals::default(),
260 });
261 if !planned {
262 planned = true;
263 if let Some(p) = &options.progress {
264 let skipped = controls::skipped(&project, &w.feature);
265 let titles: std::collections::HashMap<&str, &str> = snap
266 .feature
267 .as_ref()
268 .and_then(|f| f.tasks.as_ref())
269 .map(|t| {
270 t.tasks
271 .iter()
272 .map(|x| (x.id.as_str(), x.title.as_str()))
273 .collect()
274 })
275 .unwrap_or_default();
276 p.event(&crate::progress::Event::RunStarted {
277 feature: w.feature.clone(),
278 tasks: schedule
279 .tasks
280 .iter()
281 .map(|t| crate::progress::PlannedTask {
282 id: t.id.clone(),
283 title: titles
284 .get(t.id.as_str())
285 .copied()
286 .unwrap_or_default()
287 .to_owned(),
288 state: if skipped.contains_key(&t.id) {
289 "skipped".to_owned()
290 } else {
291 match t.state {
292 TaskState::Done => "done",
293 TaskState::Ready => "ready",
294 _ => "blocked",
295 }
296 .to_owned()
297 },
298 })
299 .collect(),
300 });
301 }
302 }
303 let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
305 let Some(next) = schedule
306 .tasks
307 .iter()
308 .find(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
309 else {
310 let open: Vec<String> = schedule
311 .tasks
312 .iter()
313 .filter(|t| t.state != TaskState::Done)
314 .map(|t| {
315 if skipped.contains_key(&t.id) {
316 format!("{} (skipped)", t.id)
317 } else {
318 t.id.clone()
319 }
320 })
321 .collect();
322 return if open.is_empty() {
323 if attempts
326 .iter()
327 .any(|a: &speccoder_core::RunReport| a.ticked)
328 {
329 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
330 let tier = full::run_once(&project, &wt_root);
331 full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
332 let failed = (!tier.passed).then(|| tier.failed.join(", "));
333 *full_tier.borrow_mut() = Some(tier);
334 if let Some(failed) = failed {
335 return finish(
336 feature_name,
337 attempts,
338 CommandStatus::VerificationFailed,
339 format!(
340 "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
341 ),
342 );
343 }
344 }
345 if let (Some(name), Ok(opened)) =
347 (feature_name.as_deref(), crate::format::open(path, feature))
348 {
349 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
350 let plane = crate::worktree::project_state_dir(
351 state,
352 std::path::Path::new(&opened.snapshot.root),
353 );
354 if let Err(e) =
355 crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
356 {
357 hook_lines
358 .borrow_mut()
359 .push(format!("held-out sets not published: {e}"));
360 }
361 }
362 match hosting::run_hooks(
364 "after_implement",
365 "",
366 path,
367 state,
368 provider,
369 secret,
370 options,
371 ) {
372 Ok(lines) => hook_lines.borrow_mut().extend(lines),
373 Err(stop) => {
374 return finish(
375 feature_name,
376 attempts,
377 stop.status(),
378 stop.message().to_owned(),
379 );
380 }
381 }
382 finish(
383 feature_name,
384 attempts,
385 CommandStatus::Ok,
386 "feature done: every task is ticked".to_owned(),
387 )
388 } else {
389 finish(
390 feature_name,
391 attempts,
392 CommandStatus::TaskNotReady,
393 format!("no task can run; open: {}", open.join(", ")),
394 )
395 };
396 };
397 let parallel_ids: std::collections::BTreeSet<String> = snap
399 .feature
400 .as_ref()
401 .and_then(|f| f.tasks.as_ref())
402 .map(|t| {
403 t.tasks
404 .iter()
405 .filter(|t| t.parallel)
406 .map(|t| t.id.clone())
407 .collect()
408 })
409 .unwrap_or_default();
410 let group: Vec<String> = schedule
411 .tasks
412 .iter()
413 .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
414 .filter(|t| parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id))
415 .map(|t| t.id.clone())
416 .take(options.parallel.min(budget))
417 .collect();
418 if !parallel_off && group.len() >= 2 {
419 budget = budget.saturating_sub(group.len());
420 let feature = feature_name.clone().unwrap_or_default();
421 let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
422 let lanes = run_lanes(path, &feature, &group, state, provider, secret, options);
423 let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
424 attempts.extend(outcome.reports);
425 parallel_lines.borrow_mut().extend(outcome.lines);
426 one_at_a_time.extend(outcome.one_at_a_time);
427 parallel_off |= outcome.reverted;
428 if let Some((task, status, message)) = outcome.stop {
429 return finish(feature_name, attempts, status, format!("{task}: {message}"));
430 }
431 continue;
432 }
433 budget -= 1;
434 let task = next.id.clone();
435 let res = run_task(
436 path,
437 feature_name.as_deref(),
438 &task,
439 state,
440 std::sync::Arc::clone(provider),
441 secret,
442 options,
443 );
444 if let Some(CommandData::Run(r)) = res.data() {
445 attempts.push((**r).clone());
446 }
447 if res.status() != CommandStatus::Ok {
448 return finish(
449 feature_name,
450 attempts,
451 res.status(),
452 format!("{task}: {}", res.message()),
453 );
454 }
455 }
456 finish(
457 feature_name,
458 attempts,
459 CommandStatus::Ok,
460 "stopped at --max-tasks".to_owned(),
461 )
462}