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 hinted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
225 let mut passed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
228 let mut one_at_a_time: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
230 let mut parallel_off = false;
231 let mut budget = max_tasks.unwrap_or(usize::MAX);
232 if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
234 return *stop;
235 }
236 match hosting::run_hooks(
238 "before_implement",
239 "",
240 path,
241 state,
242 provider,
243 secret,
244 options,
245 ) {
246 Ok(lines) => hook_lines.borrow_mut().extend(lines),
247 Err(stop) => {
248 return finish(
249 feature_name,
250 attempts,
251 stop.status(),
252 stop.message().to_owned(),
253 );
254 }
255 }
256 let mut planned = false;
257 while budget > 0 {
258 if options
259 .cancel
260 .as_ref()
261 .is_some_and(|c| c.load(std::sync::atomic::Ordering::SeqCst))
262 {
263 return finish(
264 feature_name,
265 attempts,
266 CommandStatus::NeedsHuman,
267 "cancelled by the person before the next task".to_owned(),
268 );
269 }
270 let wt = workspace(path, feature_name.as_deref(), state, options);
272 let Some(CommandData::Worktree(w)) = wt.data() else {
273 return CommandResult::carried("run", &wt);
274 };
275 if wt.status() != CommandStatus::Ok {
276 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
277 }
278 feature_name = Some(w.feature.clone());
279 let project = std::path::PathBuf::from(w.project_dir.clone().unwrap_or_default());
280 let format = crate::format::at(&project);
281 let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
282 return CommandResult::new(
283 "run",
284 CommandStatus::NotAProject,
285 format.messages().no_project_in_worktree,
286 );
287 };
288 let schedule = snap
289 .feature
290 .as_ref()
291 .and_then(|f| f.schedule.clone())
292 .unwrap_or_else(|| speccoder_core::snapshot::Schedule {
293 tasks: Vec::new(),
294 totals: speccoder_core::snapshot::ScheduleTotals::default(),
295 });
296 if !planned {
297 planned = true;
298 if let Some(p) = &options.progress {
299 let skipped = controls::skipped(&project, &w.feature);
300 let titles: std::collections::HashMap<&str, &str> = snap
301 .feature
302 .as_ref()
303 .and_then(|f| f.tasks.as_ref())
304 .map(|t| {
305 t.tasks
306 .iter()
307 .map(|x| (x.id.as_str(), x.title.as_str()))
308 .collect()
309 })
310 .unwrap_or_default();
311 p.event(&crate::progress::Event::RunStarted {
312 feature: w.feature.clone(),
313 tasks: schedule
314 .tasks
315 .iter()
316 .map(|t| crate::progress::PlannedTask {
317 id: t.id.clone(),
318 title: titles
319 .get(t.id.as_str())
320 .copied()
321 .unwrap_or_default()
322 .to_owned(),
323 state: if skipped.contains_key(&t.id) {
324 "skipped".to_owned()
325 } else {
326 match t.state {
327 TaskState::Done => "done",
328 TaskState::Ready => "ready",
329 _ => "blocked",
330 }
331 .to_owned()
332 },
333 })
334 .collect(),
335 });
336 if let Some(l) = crate::status::lanes(path, Some(&w.feature)) {
338 p.event(&crate::progress::Event::Notice { text: l.line() });
339 }
340 let warnings = crate::plan_lint::lints(format, &snap, &project).len();
343 if warnings > 0 {
344 p.event(&crate::progress::Event::Notice {
345 text: format!(
346 "plan: {warnings} warning{} — speccoder doctor --feature {}",
347 if warnings == 1 { "" } else { "s" },
348 w.feature
349 ),
350 });
351 }
352 }
353 }
354 if let Some(stop) = snap.feature.as_ref().and_then(|f| no_tasks(format, f)) {
357 return finish(
358 feature_name,
359 attempts,
360 CommandStatus::TaskNotReady,
361 stop.message,
362 )
363 .with_code("NO-TASKS")
364 .with_fix(stop.fix)
365 .with_next(stop.next);
366 }
367 let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
369 let parsed: Vec<&speccoder_core::snapshot::Task> = snap
373 .feature
374 .as_ref()
375 .and_then(|f| f.tasks.as_ref())
376 .map(|t| t.tasks.iter().collect())
377 .unwrap_or_default();
378 let saved = crate::attempts::list(&project, &w.feature);
379 let stopped = |id: &str| {
380 saved
381 .iter()
382 .find(|a| a.task == id && !controls::is_set_aside(a))
383 .filter(|a| a.outcome == "blocked")
384 .map(|a| a.reason.clone())
385 };
386 let yours = |id: &str| {
387 !hinted.contains(id)
388 && (parsed.iter().any(|t| t.id == id && t.yours) || stopped(id).is_some())
389 };
390 let Some(next) = schedule.tasks.iter().find(|t| {
391 t.state == TaskState::Ready && !skipped.contains_key(&t.id) && !passed.contains(&t.id)
392 }) else {
393 let open: Vec<String> = schedule
394 .tasks
395 .iter()
396 .filter(|t| t.state != TaskState::Done)
397 .map(|t| {
398 if skipped.contains_key(&t.id) {
399 format!("{} (skipped)", t.id)
400 } else {
401 t.id.clone()
402 }
403 })
404 .collect();
405 return if open.is_empty() {
406 if attempts
409 .iter()
410 .any(|a: &speccoder_core::RunReport| a.ticked)
411 {
412 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
413 let tier = full::run_once(&project, &wt_root);
414 full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
415 let failed = (!tier.passed).then(|| tier.failed.join(", "));
416 *full_tier.borrow_mut() = Some(tier);
417 if let Some(failed) = failed {
418 return finish(
419 feature_name,
420 attempts,
421 CommandStatus::VerificationFailed,
422 format!(
423 "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
424 ),
425 );
426 }
427 }
428 if let (Some(name), Ok(opened)) =
430 (feature_name.as_deref(), crate::format::open(path, feature))
431 {
432 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
433 let plane = crate::worktree::project_state_dir(
434 state,
435 std::path::Path::new(&opened.snapshot.root),
436 );
437 if let Err(e) =
438 crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
439 {
440 hook_lines
441 .borrow_mut()
442 .push(format!("held-out sets not published: {e}"));
443 }
444 }
445 match hosting::run_hooks(
447 "after_implement",
448 "",
449 path,
450 state,
451 provider,
452 secret,
453 options,
454 ) {
455 Ok(lines) => hook_lines.borrow_mut().extend(lines),
456 Err(stop) => {
457 return finish(
458 feature_name,
459 attempts,
460 stop.status(),
461 stop.message().to_owned(),
462 );
463 }
464 }
465 finish(
466 feature_name,
467 attempts,
468 CommandStatus::Ok,
469 "feature done: every task is ticked".to_owned(),
470 )
471 } else if let Some(first) = passed.iter().next() {
472 let fix = speccoder_core::codes::lookup("TASK-YOURS")
474 .map(|e| {
475 speccoder_core::codes::fixes(e, "run", Some(first), feature_name.as_deref())
476 })
477 .unwrap_or_default();
478 let mine: Vec<String> = passed.iter().cloned().collect();
479 let message = match mine.as_slice() {
480 [one] => format!(
481 "{one} is yours — {}: a step only a person can take",
482 parsed
483 .iter()
484 .find(|t| t.id == *one)
485 .map(|t| t.title.as_str())
486 .unwrap_or_default()
487 ),
488 many => format!(
489 "{} are yours: steps only a person can take",
490 many.join(", ")
491 ),
492 };
493 finish(feature_name, attempts, CommandStatus::NeedsHuman, message)
494 .with_code("TASK-YOURS")
495 .with_fix(fix)
496 } else {
497 finish(
498 feature_name,
499 attempts,
500 CommandStatus::TaskNotReady,
501 format!("no task can run; open: {}", open.join(", ")),
502 )
503 };
504 };
505 let title_of = |id: &str| {
506 parsed
507 .iter()
508 .find(|t| t.id == id)
509 .map(|t| t.title.clone())
510 .unwrap_or_default()
511 };
512 if yours(&next.id) {
513 let id = next.id.clone();
514 let stop = crate::approvals::StopRequest {
515 task: format!("{}/{id}", w.feature),
516 title: title_of(&id),
517 claim: stopped(&id),
518 dependents: speccoder_core::graph::dependents(&schedule, &id),
519 };
520 if let Some(failed) = settle_stop(
521 path,
522 feature_name.as_deref(),
523 state,
524 options,
525 &stop,
526 &mut hinted,
527 &mut passed,
528 ) {
529 return finish(
530 feature_name,
531 attempts,
532 failed.status(),
533 format!("{id}: {}", failed.message()),
534 )
535 .caused_by(&failed);
536 }
537 continue;
538 }
539 let parallel_ids: std::collections::BTreeSet<String> = snap
541 .feature
542 .as_ref()
543 .and_then(|f| f.tasks.as_ref())
544 .map(|t| {
545 t.tasks
546 .iter()
547 .filter(|t| t.parallel)
548 .map(|t| t.id.clone())
549 .collect()
550 })
551 .unwrap_or_default();
552 let group: Vec<String> = schedule
553 .tasks
554 .iter()
555 .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
556 .filter(|t| {
557 parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id) && !yours(&t.id)
558 })
559 .filter(|t| {
561 !super::story::oracle_pending(
562 std::path::Path::new(w.path.as_deref().unwrap_or_default()),
563 &crate::worktree::project_state_dir(state, path),
564 format,
565 &snap,
566 &t.id,
567 )
568 })
569 .map(|t| t.id.clone())
570 .take(options.parallel.min(budget))
571 .collect();
572 if !parallel_off && group.len() >= 2 {
573 budget = budget.saturating_sub(group.len());
574 let feature = feature_name.clone().unwrap_or_default();
575 let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
576 let lanes = run_lanes(path, &feature, &group, state, provider, secret, options);
577 let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
578 attempts.extend(outcome.reports);
579 parallel_lines.borrow_mut().extend(outcome.lines);
580 one_at_a_time.extend(outcome.one_at_a_time);
581 parallel_off |= outcome.reverted;
582 if let Some((task, status, message)) = outcome.stop {
583 let claim = attempts
585 .iter()
586 .rev()
587 .find(|a| a.task == task && a.attempt_status == "blocked")
588 .map(|a| a.reason.clone().unwrap_or_default());
589 let Some(claim) = claim else {
590 return finish(feature_name, attempts, status, format!("{task}: {message}"));
591 };
592 let stop = crate::approvals::StopRequest {
593 task: format!("{}/{task}", w.feature),
594 title: title_of(&task),
595 claim: Some(claim),
596 dependents: speccoder_core::graph::dependents(&schedule, &task),
597 };
598 if let Some(failed) = settle_stop(
599 path,
600 feature_name.as_deref(),
601 state,
602 options,
603 &stop,
604 &mut hinted,
605 &mut passed,
606 ) {
607 return finish(
608 feature_name,
609 attempts,
610 failed.status(),
611 format!("{task}: {}", failed.message()),
612 )
613 .caused_by(&failed);
614 }
615 }
616 continue;
617 }
618 budget -= 1;
619 let task = next.id.clone();
620 let res = run_task(
621 path,
622 feature_name.as_deref(),
623 &task,
624 state,
625 std::sync::Arc::clone(provider),
626 secret,
627 options,
628 );
629 if let Some(CommandData::Run(r)) = res.data() {
630 attempts.push((**r).clone());
631 }
632 let claim = match res.data() {
635 Some(CommandData::Run(r)) if r.attempt_status == "blocked" => {
636 Some(r.reason.clone().unwrap_or_default())
637 }
638 _ => None,
639 };
640 if let Some(claim) = claim.filter(|_| res.status() != CommandStatus::Ok) {
641 let stop = crate::approvals::StopRequest {
642 task: format!("{}/{task}", w.feature),
643 title: title_of(&task),
644 claim: Some(claim),
645 dependents: speccoder_core::graph::dependents(&schedule, &task),
646 };
647 if let Some(failed) = settle_stop(
648 path,
649 feature_name.as_deref(),
650 state,
651 options,
652 &stop,
653 &mut hinted,
654 &mut passed,
655 ) {
656 return finish(
657 feature_name,
658 attempts,
659 failed.status(),
660 format!("{task}: {}", failed.message()),
661 )
662 .caused_by(&failed);
663 }
664 continue;
665 }
666 if res.status() != CommandStatus::Ok {
667 return finish(
668 feature_name,
669 attempts,
670 res.status(),
671 format!("{task}: {}", res.message()),
672 )
673 .caused_by(&res);
674 }
675 }
676 finish(
677 feature_name,
678 attempts,
679 CommandStatus::Ok,
680 "stopped at --max-tasks".to_owned(),
681 )
682}
683
684fn settle_stop(
688 path: &Path,
689 feature: Option<&str>,
690 state: &Path,
691 options: &RunOptions,
692 stop: &crate::approvals::StopRequest,
693 hinted: &mut std::collections::BTreeSet<String>,
694 passed: &mut std::collections::BTreeSet<String>,
695) -> Option<CommandResult> {
696 let id = stop
697 .task
698 .rsplit('/')
699 .next()
700 .unwrap_or(&stop.task)
701 .to_owned();
702 match ask_at_stop(path, feature, state, options, stop) {
703 Some((answer, done)) if done.status() == CommandStatus::Ok => {
704 if matches!(answer, crate::approvals::StopAnswer::Hint(_)) {
705 hinted.insert(id);
706 }
707 None
708 }
709 Some((_, failed)) => Some(failed),
710 None => {
711 passed.insert(id);
712 None
713 }
714 }
715}
716
717fn ask_at_stop(
722 path: &Path,
723 feature: Option<&str>,
724 state: &Path,
725 options: &RunOptions,
726 stop: &crate::approvals::StopRequest,
727) -> Option<(crate::approvals::StopAnswer, CommandResult)> {
728 let approver = options
729 .approver
730 .as_ref()
731 .filter(|_| options.lane.is_none())?;
732 let answer = approver.at_stop(stop)?;
733 let task = stop.task.rsplit('/').next().unwrap_or(&stop.task);
734 let done = crate::actions::answered_at_stop(
735 path,
736 feature,
737 task,
738 &answer,
739 (approver.via(), options),
740 state,
741 );
742 Some((answer, done))
743}
744
745#[derive(Debug, Clone)]
747pub(crate) struct NoTasks {
748 pub message: String,
750 pub fix: Vec<String>,
752 pub next: Vec<speccoder_core::next::NextStep>,
754}
755
756pub(crate) fn no_tasks(
759 format: &dyn crate::format::Adapter,
760 feature: &speccoder_core::snapshot::FeatureSnapshot,
761) -> Option<NoTasks> {
762 if feature.tasks.as_ref().is_some_and(|t| !t.tasks.is_empty()) {
763 return None;
764 }
765 let messages = format.messages();
766 let skills: Vec<String> = (format.planning().toward_tasks)(feature.artifacts.plan)
767 .iter()
768 .map(|s| (*s).to_owned())
769 .collect();
770 let name = &feature.feature.name;
771 Some(NoTasks {
772 message: format!(
773 "no tasks yet: {name} {}; {}",
774 if feature.tasks.is_some() {
775 format!("has a {} without a task", messages.tasks_file)
776 } else {
777 format!("has no {}", messages.tasks_file)
778 },
779 messages.make_tasks
780 ),
781 fix: skills
782 .iter()
783 .map(|s| format!("speccoder spec {s}"))
784 .collect(),
785 next: speccoder_core::next::next(
786 &speccoder_core::next::Outcome::Planning { skills },
787 Some(name),
788 ),
789 })
790}