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>,
181 attempts: Vec<speccoder_core::RunReport>,
182 status: CommandStatus,
183 stop: String| {
184 let autopilot_denied: Vec<speccoder_core::oracle::AutopilotDenial> =
186 if options.mode == speccoder_core::oracle::RunMode::Autopilot {
187 attempts
188 .iter()
189 .flat_map(|a| crate::oracle::autopilot_denials(&a.task, &a.tool_calls))
190 .collect()
191 } else {
192 Vec::new()
193 };
194 if let Some(n) = name.as_deref() {
195 crate::oracle::record_denials(
196 &worktree::project_state_dir(state, path),
197 n,
198 &autopilot_denied,
199 );
200 }
201 let stop = if autopilot_denied.is_empty() {
202 stop
203 } else {
204 format!(
205 "{stop} — autopilot refused {} call(s) instead of asking; they are listed for you (speccoder inbox)",
206 autopilot_denied.len()
207 )
208 };
209 CommandResult::new("run", status, &stop).with_data(CommandData::Feature(Box::new(
210 FeatureReport {
211 feature: name.unwrap_or_default(),
212 attempts,
213 stop,
214 converge: Vec::new(),
215 hooks: hook_lines.borrow().clone(),
216 parallel: parallel_lines.borrow().clone(),
217 full_tier: full_tier.borrow().clone(),
218 mode: options.mode,
219 autopilot_denied,
220 },
221 )))
222 };
223 let mut one_at_a_time: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
225 let mut parallel_off = false;
226 let mut budget = max_tasks.unwrap_or(usize::MAX);
227 if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
229 return *stop;
230 }
231 match hosting::run_hooks(
233 "before_implement",
234 "",
235 path,
236 state,
237 provider,
238 secret,
239 options,
240 ) {
241 Ok(lines) => hook_lines.borrow_mut().extend(lines),
242 Err(stop) => {
243 return finish(
244 feature_name,
245 attempts,
246 stop.status(),
247 stop.message().to_owned(),
248 );
249 }
250 }
251 let mut planned = false;
252 while budget > 0 {
253 if options
254 .cancel
255 .as_ref()
256 .is_some_and(|c| c.load(std::sync::atomic::Ordering::SeqCst))
257 {
258 return finish(
259 feature_name,
260 attempts,
261 CommandStatus::NeedsHuman,
262 "cancelled by the person before the next task".to_owned(),
263 );
264 }
265 let wt = workspace(path, feature_name.as_deref(), state, options);
267 let Some(CommandData::Worktree(w)) = wt.data() else {
268 return CommandResult::carried("run", &wt);
269 };
270 if wt.status() != CommandStatus::Ok {
271 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
272 }
273 feature_name = Some(w.feature.clone());
274 let project = std::path::PathBuf::from(w.project_dir.clone().unwrap_or_default());
275 let format = crate::format::at(&project);
276 let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
277 return CommandResult::new(
278 "run",
279 CommandStatus::NotAProject,
280 format.messages().no_project_in_worktree,
281 );
282 };
283 let schedule = snap
284 .feature
285 .as_ref()
286 .and_then(|f| f.schedule.clone())
287 .unwrap_or_else(|| speccoder_core::snapshot::Schedule {
288 tasks: Vec::new(),
289 totals: speccoder_core::snapshot::ScheduleTotals::default(),
290 });
291 if !planned {
292 planned = true;
293 if let Some(p) = &options.progress {
294 let skipped = controls::skipped(&project, &w.feature);
295 let titles: std::collections::HashMap<&str, &str> = snap
296 .feature
297 .as_ref()
298 .and_then(|f| f.tasks.as_ref())
299 .map(|t| {
300 t.tasks
301 .iter()
302 .map(|x| (x.id.as_str(), x.title.as_str()))
303 .collect()
304 })
305 .unwrap_or_default();
306 p.event(&crate::progress::Event::RunStarted {
307 feature: w.feature.clone(),
308 tasks: schedule
309 .tasks
310 .iter()
311 .map(|t| crate::progress::PlannedTask {
312 id: t.id.clone(),
313 title: titles
314 .get(t.id.as_str())
315 .copied()
316 .unwrap_or_default()
317 .to_owned(),
318 state: if skipped.contains_key(&t.id) {
319 "skipped".to_owned()
320 } else {
321 match t.state {
322 TaskState::Done => "done",
323 TaskState::Ready => "ready",
324 _ => "blocked",
325 }
326 .to_owned()
327 },
328 })
329 .collect(),
330 });
331 }
332 }
333 let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
335 let Some(next) = schedule
336 .tasks
337 .iter()
338 .find(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
339 else {
340 let open: Vec<String> = schedule
341 .tasks
342 .iter()
343 .filter(|t| t.state != TaskState::Done)
344 .map(|t| {
345 if skipped.contains_key(&t.id) {
346 format!("{} (skipped)", t.id)
347 } else {
348 t.id.clone()
349 }
350 })
351 .collect();
352 return if open.is_empty() {
353 if attempts
356 .iter()
357 .any(|a: &speccoder_core::RunReport| a.ticked)
358 {
359 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
360 let tier = full::run_once(&project, &wt_root);
361 full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
362 let failed = (!tier.passed).then(|| tier.failed.join(", "));
363 *full_tier.borrow_mut() = Some(tier);
364 if let Some(failed) = failed {
365 return finish(
366 feature_name,
367 attempts,
368 CommandStatus::VerificationFailed,
369 format!(
370 "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
371 ),
372 );
373 }
374 }
375 if let (Some(name), Ok(opened)) =
377 (feature_name.as_deref(), crate::format::open(path, feature))
378 {
379 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
380 let plane = crate::worktree::project_state_dir(
381 state,
382 std::path::Path::new(&opened.snapshot.root),
383 );
384 if let Err(e) =
385 crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
386 {
387 hook_lines
388 .borrow_mut()
389 .push(format!("held-out sets not published: {e}"));
390 }
391 }
392 match hosting::run_hooks(
394 "after_implement",
395 "",
396 path,
397 state,
398 provider,
399 secret,
400 options,
401 ) {
402 Ok(lines) => hook_lines.borrow_mut().extend(lines),
403 Err(stop) => {
404 return finish(
405 feature_name,
406 attempts,
407 stop.status(),
408 stop.message().to_owned(),
409 );
410 }
411 }
412 finish(
413 feature_name,
414 attempts,
415 CommandStatus::Ok,
416 "feature done: every task is ticked".to_owned(),
417 )
418 } else {
419 finish(
420 feature_name,
421 attempts,
422 CommandStatus::TaskNotReady,
423 format!("no task can run; open: {}", open.join(", ")),
424 )
425 };
426 };
427 let parallel_ids: std::collections::BTreeSet<String> = snap
429 .feature
430 .as_ref()
431 .and_then(|f| f.tasks.as_ref())
432 .map(|t| {
433 t.tasks
434 .iter()
435 .filter(|t| t.parallel)
436 .map(|t| t.id.clone())
437 .collect()
438 })
439 .unwrap_or_default();
440 let group: Vec<String> = schedule
441 .tasks
442 .iter()
443 .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
444 .filter(|t| parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id))
445 .map(|t| t.id.clone())
446 .take(options.parallel.min(budget))
447 .collect();
448 if !parallel_off && group.len() >= 2 {
449 budget = budget.saturating_sub(group.len());
450 let feature = feature_name.clone().unwrap_or_default();
451 let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
452 let lanes = run_lanes(path, &feature, &group, state, provider, secret, options);
453 let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
454 attempts.extend(outcome.reports);
455 parallel_lines.borrow_mut().extend(outcome.lines);
456 one_at_a_time.extend(outcome.one_at_a_time);
457 parallel_off |= outcome.reverted;
458 if let Some((task, status, message)) = outcome.stop {
459 return finish(feature_name, attempts, status, format!("{task}: {message}"));
460 }
461 continue;
462 }
463 budget -= 1;
464 let task = next.id.clone();
465 let res = run_task(
466 path,
467 feature_name.as_deref(),
468 &task,
469 state,
470 std::sync::Arc::clone(provider),
471 secret,
472 options,
473 );
474 if let Some(CommandData::Run(r)) = res.data() {
475 attempts.push((**r).clone());
476 }
477 if res.status() != CommandStatus::Ok {
478 return finish(
479 feature_name,
480 attempts,
481 res.status(),
482 format!("{task}: {}", res.message()),
483 )
484 .caused_by(&res);
485 }
486 }
487 finish(
488 feature_name,
489 attempts,
490 CommandStatus::Ok,
491 "stopped at --max-tasks".to_owned(),
492 )
493}