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 let warnings = crate::plan_lint::lints(format, &snap, &project).len();
334 if warnings > 0 {
335 p.event(&crate::progress::Event::Notice {
336 text: format!(
337 "plan: {warnings} warning{} — speccoder doctor --feature {}",
338 if warnings == 1 { "" } else { "s" },
339 w.feature
340 ),
341 });
342 }
343 }
344 }
345 if let Some(stop) = snap.feature.as_ref().and_then(|f| no_tasks(format, f)) {
348 return finish(
349 feature_name,
350 attempts,
351 CommandStatus::TaskNotReady,
352 stop.message,
353 )
354 .with_code("NO-TASKS")
355 .with_fix(stop.fix)
356 .with_next(stop.next);
357 }
358 let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
360 let Some(next) = schedule
361 .tasks
362 .iter()
363 .find(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
364 else {
365 let open: Vec<String> = schedule
366 .tasks
367 .iter()
368 .filter(|t| t.state != TaskState::Done)
369 .map(|t| {
370 if skipped.contains_key(&t.id) {
371 format!("{} (skipped)", t.id)
372 } else {
373 t.id.clone()
374 }
375 })
376 .collect();
377 return if open.is_empty() {
378 if attempts
381 .iter()
382 .any(|a: &speccoder_core::RunReport| a.ticked)
383 {
384 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
385 let tier = full::run_once(&project, &wt_root);
386 full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
387 let failed = (!tier.passed).then(|| tier.failed.join(", "));
388 *full_tier.borrow_mut() = Some(tier);
389 if let Some(failed) = failed {
390 return finish(
391 feature_name,
392 attempts,
393 CommandStatus::VerificationFailed,
394 format!(
395 "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
396 ),
397 );
398 }
399 }
400 if let (Some(name), Ok(opened)) =
402 (feature_name.as_deref(), crate::format::open(path, feature))
403 {
404 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
405 let plane = crate::worktree::project_state_dir(
406 state,
407 std::path::Path::new(&opened.snapshot.root),
408 );
409 if let Err(e) =
410 crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
411 {
412 hook_lines
413 .borrow_mut()
414 .push(format!("held-out sets not published: {e}"));
415 }
416 }
417 match hosting::run_hooks(
419 "after_implement",
420 "",
421 path,
422 state,
423 provider,
424 secret,
425 options,
426 ) {
427 Ok(lines) => hook_lines.borrow_mut().extend(lines),
428 Err(stop) => {
429 return finish(
430 feature_name,
431 attempts,
432 stop.status(),
433 stop.message().to_owned(),
434 );
435 }
436 }
437 finish(
438 feature_name,
439 attempts,
440 CommandStatus::Ok,
441 "feature done: every task is ticked".to_owned(),
442 )
443 } else {
444 finish(
445 feature_name,
446 attempts,
447 CommandStatus::TaskNotReady,
448 format!("no task can run; open: {}", open.join(", ")),
449 )
450 };
451 };
452 let parallel_ids: std::collections::BTreeSet<String> = snap
454 .feature
455 .as_ref()
456 .and_then(|f| f.tasks.as_ref())
457 .map(|t| {
458 t.tasks
459 .iter()
460 .filter(|t| t.parallel)
461 .map(|t| t.id.clone())
462 .collect()
463 })
464 .unwrap_or_default();
465 let group: Vec<String> = schedule
466 .tasks
467 .iter()
468 .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
469 .filter(|t| parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id))
470 .map(|t| t.id.clone())
471 .take(options.parallel.min(budget))
472 .collect();
473 if !parallel_off && group.len() >= 2 {
474 budget = budget.saturating_sub(group.len());
475 let feature = feature_name.clone().unwrap_or_default();
476 let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
477 let lanes = run_lanes(path, &feature, &group, state, provider, secret, options);
478 let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
479 attempts.extend(outcome.reports);
480 parallel_lines.borrow_mut().extend(outcome.lines);
481 one_at_a_time.extend(outcome.one_at_a_time);
482 parallel_off |= outcome.reverted;
483 if let Some((task, status, message)) = outcome.stop {
484 return finish(feature_name, attempts, status, format!("{task}: {message}"));
485 }
486 continue;
487 }
488 budget -= 1;
489 let task = next.id.clone();
490 let res = run_task(
491 path,
492 feature_name.as_deref(),
493 &task,
494 state,
495 std::sync::Arc::clone(provider),
496 secret,
497 options,
498 );
499 if let Some(CommandData::Run(r)) = res.data() {
500 attempts.push((**r).clone());
501 }
502 if res.status() != CommandStatus::Ok {
503 return finish(
504 feature_name,
505 attempts,
506 res.status(),
507 format!("{task}: {}", res.message()),
508 )
509 .caused_by(&res);
510 }
511 }
512 finish(
513 feature_name,
514 attempts,
515 CommandStatus::Ok,
516 "stopped at --max-tasks".to_owned(),
517 )
518}
519
520#[derive(Debug, Clone)]
522pub(crate) struct NoTasks {
523 pub message: String,
525 pub fix: Vec<String>,
527 pub next: Vec<speccoder_core::next::NextStep>,
529}
530
531pub(crate) fn no_tasks(
534 format: &dyn crate::format::Adapter,
535 feature: &speccoder_core::snapshot::FeatureSnapshot,
536) -> Option<NoTasks> {
537 if feature.tasks.as_ref().is_some_and(|t| !t.tasks.is_empty()) {
538 return None;
539 }
540 let messages = format.messages();
541 let skills: Vec<String> = (format.planning().toward_tasks)(feature.artifacts.plan)
542 .iter()
543 .map(|s| (*s).to_owned())
544 .collect();
545 let name = &feature.feature.name;
546 Some(NoTasks {
547 message: format!(
548 "no tasks yet: {name} {}; {}",
549 if feature.tasks.is_some() {
550 format!("has a {} without a task", messages.tasks_file)
551 } else {
552 format!("has no {}", messages.tasks_file)
553 },
554 messages.make_tasks
555 ),
556 fix: skills
557 .iter()
558 .map(|s| format!("speccoder spec {s}"))
559 .collect(),
560 next: speccoder_core::next::next(
561 &speccoder_core::next::Outcome::Planning { skills },
562 Some(name),
563 ),
564 })
565}