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 let cap = match cap_of(path, options) {
106 Ok(c) => c,
107 Err(stop) => return *stop,
108 };
109 loop {
110 let round = RunOptions {
112 cap: cap.map(|c| c.cap.less(spent(&c, &attempts))),
113 ..options.clone()
114 };
115 let res = run_feature_with(path, feature, state, provider, secret, &round);
116 let Some(CommandData::Feature(fr)) = res.data() else {
117 return res;
118 };
119 let mut fr = (**fr).clone();
120 attempts.append(&mut fr.attempts);
121 let finished = res.status() == CommandStatus::Ok && fr.stop.starts_with("feature done");
122 if !finished {
123 fr.attempts = attempts;
124 fr.converge = rounds;
125 return CommandResult::carried("run", &res)
126 .with_data(CommandData::Feature(Box::new(fr)));
127 }
128 if let Some(p) = &options.progress {
129 p.event(&progress::Event::Notice {
130 text: format!("converge: assessing {} against its spec", fr.feature),
131 });
132 }
133 let c = convergence::converge_with_options(
134 path,
135 feature,
136 state,
137 Arc::clone(provider),
138 secret,
139 options,
140 );
141 if let Some(p) = &options.progress {
142 p.event(&progress::Event::Notice {
143 text: format!("converge: {}", c.message()),
144 });
145 }
146 let outcome = match c.data() {
147 Some(CommandData::Converge(r)) => {
148 let outcome = r.outcome.clone();
149 rounds.push((**r).clone());
150 outcome
151 }
152 _ => String::new(),
153 };
154 if outcome != "tasks_appended" {
155 fr.attempts = attempts;
156 fr.converge = rounds;
157 fr.stop = format!("converge: {}", c.message());
158 return CommandResult::new("run", c.status(), &fr.stop.clone())
159 .with_data(CommandData::Feature(Box::new(fr)));
160 }
161 }
162}
163
164pub fn run_feature_with(
166 path: &Path,
167 feature: Option<&str>,
168 state: &Path,
169 provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
170 secret: Option<&str>,
171 options: &RunOptions,
172) -> CommandResult {
173 if options.in_place && options.session.is_none() {
175 return in_session("run", path, feature, state, options, |o| {
176 run_feature_with(path, feature, state, provider, secret, o)
177 });
178 }
179 let delivering = crate::hooks::deliver::Deliverer::start(path, state);
181 if delivering.is_some()
183 && let Some(p) = &options.progress
184 && let Some(text) = crate::hooks::report::untrusted_notice(path, state)
185 {
186 p.event(&progress::Event::Notice { text });
187 }
188 if let Some(slug) = bug_spec(path, feature) {
190 return crate::bug::run_with(
191 path,
192 Some(&slug),
193 None,
194 state,
195 Arc::clone(provider),
196 secret,
197 options,
198 );
199 }
200 let max_tasks = options.max_tasks;
201 use speccoder_core::FeatureReport;
202 use speccoder_core::snapshot::TaskState;
203
204 let mut attempts = Vec::new();
205 let mut feature_name = feature.map(str::to_owned);
206 let hook_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
207 let parallel_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
208 let full_tier: std::cell::RefCell<Option<speccoder_core::FullTier>> =
209 std::cell::RefCell::new(None);
210 let finish = |name: Option<String>,
211 attempts: Vec<speccoder_core::RunReport>,
212 status: CommandStatus,
213 stop: String| {
214 let autopilot_denied: Vec<speccoder_core::oracle::AutopilotDenial> =
216 if options.mode == speccoder_core::oracle::RunMode::Autopilot {
217 attempts
218 .iter()
219 .flat_map(|a| crate::oracle::autopilot_denials(&a.task, &a.tool_calls))
220 .collect()
221 } else {
222 Vec::new()
223 };
224 if let Some(n) = name.as_deref() {
225 crate::oracle::record_denials(
226 &worktree::project_state_dir(state, path),
227 n,
228 &autopilot_denied,
229 );
230 }
231 let stop = if autopilot_denied.is_empty() {
232 stop
233 } else {
234 format!(
235 "{stop} — autopilot refused {} call(s) instead of asking; they are listed for you (speccoder inbox)",
236 autopilot_denied.len()
237 )
238 };
239 CommandResult::new("run", status, &stop).with_data(CommandData::Feature(Box::new(
240 FeatureReport {
241 feature: name.unwrap_or_default(),
242 attempts,
243 stop,
244 converge: Vec::new(),
245 hooks: hook_lines.borrow().clone(),
246 parallel: parallel_lines.borrow().clone(),
247 full_tier: full_tier.borrow().clone(),
248 mode: options.mode,
249 autopilot_denied,
250 },
251 )))
252 };
253 let mut hinted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
255 let mut passed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
258 let mut one_at_a_time: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
260 let mut parallel_off = false;
261 let mut budget = max_tasks.unwrap_or(usize::MAX);
262 if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
264 return *stop;
265 }
266 let cap = match cap_of(path, options) {
268 Ok(c) => c,
269 Err(stop) => return *stop,
270 };
271 let mut told_uncounted = false;
273 match hosting::run_hooks(
275 "before_implement",
276 "",
277 path,
278 state,
279 provider,
280 secret,
281 options,
282 ) {
283 Ok(lines) => hook_lines.borrow_mut().extend(lines),
284 Err(stop) => {
285 return finish(
286 feature_name,
287 attempts,
288 stop.status(),
289 stop.message().to_owned(),
290 );
291 }
292 }
293 let mut planned = false;
294 while budget > 0 {
295 if options
296 .cancel
297 .as_ref()
298 .is_some_and(|c| c.load(std::sync::atomic::Ordering::SeqCst))
299 {
300 return finish(
301 feature_name,
302 attempts,
303 CommandStatus::NeedsHuman,
304 "cancelled by the person before the next task".to_owned(),
305 );
306 }
307 let wt = workspace(path, feature_name.as_deref(), state, options);
309 let Some(CommandData::Worktree(w)) = wt.data() else {
310 return CommandResult::carried("run", &wt);
311 };
312 if wt.status() != CommandStatus::Ok {
313 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
314 }
315 feature_name = Some(w.feature.clone());
316 let project = std::path::PathBuf::from(w.project_dir.clone().unwrap_or_default());
317 let format = crate::format::at(&project);
318 let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
319 return CommandResult::new(
320 "run",
321 CommandStatus::NotAProject,
322 format.messages().no_project_in_worktree,
323 );
324 };
325 let schedule = snap
326 .feature
327 .as_ref()
328 .and_then(|f| f.schedule.clone())
329 .unwrap_or_else(|| speccoder_core::snapshot::Schedule {
330 tasks: Vec::new(),
331 totals: speccoder_core::snapshot::ScheduleTotals::default(),
332 });
333 if !planned {
334 planned = true;
335 if let Some(p) = &options.progress {
336 let skipped = controls::skipped(&project, &w.feature);
337 let titles: std::collections::HashMap<&str, &str> = snap
338 .feature
339 .as_ref()
340 .and_then(|f| f.tasks.as_ref())
341 .map(|t| {
342 t.tasks
343 .iter()
344 .map(|x| (x.id.as_str(), x.title.as_str()))
345 .collect()
346 })
347 .unwrap_or_default();
348 p.event(&crate::progress::Event::RunStarted {
349 feature: w.feature.clone(),
350 tasks: schedule
351 .tasks
352 .iter()
353 .map(|t| crate::progress::PlannedTask {
354 id: t.id.clone(),
355 title: titles
356 .get(t.id.as_str())
357 .copied()
358 .unwrap_or_default()
359 .to_owned(),
360 state: if skipped.contains_key(&t.id) {
361 "skipped".to_owned()
362 } else {
363 match t.state {
364 TaskState::Done => "done",
365 TaskState::Ready => "ready",
366 _ => "blocked",
367 }
368 .to_owned()
369 },
370 })
371 .collect(),
372 });
373 let open = schedule
375 .tasks
376 .iter()
377 .filter(|t| t.state != TaskState::Done && !skipped.contains_key(&t.id))
378 .count();
379 if let Some(line) = controls::spend_line(path, open, cap.map(|c| c.cap)) {
380 p.event(&crate::progress::Event::Notice { text: line });
381 }
382 if let Some(l) = crate::status::lanes(path, Some(&w.feature)) {
384 p.event(&crate::progress::Event::Notice { text: l.line() });
385 }
386 let warnings = crate::plan_lint::lints(format, &snap, &project).len();
389 if warnings > 0 {
390 p.event(&crate::progress::Event::Notice {
391 text: format!(
392 "plan: {warnings} warning{} — speccoder doctor --feature {}",
393 if warnings == 1 { "" } else { "s" },
394 w.feature
395 ),
396 });
397 }
398 }
399 }
400 if let Some(stop) = snap.feature.as_ref().and_then(|f| no_tasks(format, f)) {
403 return finish(
404 feature_name,
405 attempts,
406 CommandStatus::TaskNotReady,
407 stop.message,
408 )
409 .with_code("NO-TASKS")
410 .with_fix(stop.fix)
411 .with_next(stop.next);
412 }
413 let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
415 let parsed: Vec<&speccoder_core::snapshot::Task> = snap
419 .feature
420 .as_ref()
421 .and_then(|f| f.tasks.as_ref())
422 .map(|t| t.tasks.iter().collect())
423 .unwrap_or_default();
424 let saved = crate::attempts::list(&project, &w.feature);
425 let stopped = |id: &str| {
426 saved
427 .iter()
428 .find(|a| a.task == id && !controls::is_set_aside(a))
429 .filter(|a| a.outcome == "blocked")
430 .map(|a| a.reason.clone())
431 };
432 let yours = |id: &str| {
433 !hinted.contains(id)
434 && (parsed.iter().any(|t| t.id == id && t.yours) || stopped(id).is_some())
435 };
436 let Some(next) = schedule.tasks.iter().find(|t| {
437 t.state == TaskState::Ready && !skipped.contains_key(&t.id) && !passed.contains(&t.id)
438 }) else {
439 let open: Vec<String> = schedule
440 .tasks
441 .iter()
442 .filter(|t| t.state != TaskState::Done)
443 .map(|t| {
444 if skipped.contains_key(&t.id) {
445 format!("{} (skipped)", t.id)
446 } else {
447 t.id.clone()
448 }
449 })
450 .collect();
451 return if open.is_empty() {
452 if attempts
455 .iter()
456 .any(|a: &speccoder_core::RunReport| a.ticked)
457 {
458 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
459 let tier = full::run_once(&project, &wt_root);
460 full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
461 let failed = (!tier.passed).then(|| tier.failed.join(", "));
462 *full_tier.borrow_mut() = Some(tier);
463 if let Some(failed) = failed {
464 return finish(
465 feature_name,
466 attempts,
467 CommandStatus::VerificationFailed,
468 format!(
469 "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
470 ),
471 );
472 }
473 }
474 if let (Some(name), Ok(opened)) =
476 (feature_name.as_deref(), crate::format::open(path, feature))
477 {
478 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
479 let plane = crate::worktree::project_state_dir(
480 state,
481 std::path::Path::new(&opened.snapshot.root),
482 );
483 if let Err(e) =
484 crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
485 {
486 hook_lines
487 .borrow_mut()
488 .push(format!("held-out sets not published: {e}"));
489 }
490 }
491 match hosting::run_hooks(
493 "after_implement",
494 "",
495 path,
496 state,
497 provider,
498 secret,
499 options,
500 ) {
501 Ok(lines) => hook_lines.borrow_mut().extend(lines),
502 Err(stop) => {
503 return finish(
504 feature_name,
505 attempts,
506 stop.status(),
507 stop.message().to_owned(),
508 );
509 }
510 }
511 finish(
512 feature_name,
513 attempts,
514 CommandStatus::Ok,
515 "feature done: every task is ticked".to_owned(),
516 )
517 } else if let Some(first) = passed.iter().next() {
518 let fix = speccoder_core::codes::lookup("TASK-YOURS")
520 .map(|e| {
521 speccoder_core::codes::fixes(e, "run", Some(first), feature_name.as_deref())
522 })
523 .unwrap_or_default();
524 let mine: Vec<String> = passed.iter().cloned().collect();
525 let message = match mine.as_slice() {
526 [one] => format!(
527 "{one} is yours — {}: a step only a person can take",
528 parsed
529 .iter()
530 .find(|t| t.id == *one)
531 .map(|t| t.title.as_str())
532 .unwrap_or_default()
533 ),
534 many => format!(
535 "{} are yours: steps only a person can take",
536 many.join(", ")
537 ),
538 };
539 finish(feature_name, attempts, CommandStatus::NeedsHuman, message)
540 .with_code("TASK-YOURS")
541 .with_fix(fix)
542 } else {
543 finish(
544 feature_name,
545 attempts,
546 CommandStatus::TaskNotReady,
547 format!("no task can run; open: {}", open.join(", ")),
548 )
549 };
550 };
551 let title_of = |id: &str| {
552 parsed
553 .iter()
554 .find(|t| t.id == id)
555 .map(|t| t.title.clone())
556 .unwrap_or_default()
557 };
558 if yours(&next.id) {
559 let id = next.id.clone();
560 let stop = crate::approvals::StopRequest {
561 task: format!("{}/{id}", w.feature),
562 title: title_of(&id),
563 claim: stopped(&id),
564 dependents: speccoder_core::graph::dependents(&schedule, &id),
565 };
566 if let Some(failed) = settle_stop(
567 path,
568 feature_name.as_deref(),
569 state,
570 options,
571 &stop,
572 &mut hinted,
573 &mut passed,
574 ) {
575 return finish(
576 feature_name,
577 attempts,
578 failed.status(),
579 format!("{id}: {}", failed.message()),
580 )
581 .caused_by(&failed);
582 }
583 continue;
584 }
585 if let Some(c) = cap
588 && let Some(stop) = capped(&c, &attempts, &schedule, &skipped, &w.feature)
589 {
590 return finish(feature_name, attempts, CommandStatus::NeedsHuman, stop.0)
591 .with_code("RUN-CAP")
592 .with_fix(vec![stop.1]);
593 }
594 let left = cap.map(|c| c.cap.less(spent(&c, &attempts)));
595 let parallel_ids: std::collections::BTreeSet<String> = snap
597 .feature
598 .as_ref()
599 .and_then(|f| f.tasks.as_ref())
600 .map(|t| {
601 t.tasks
602 .iter()
603 .filter(|t| t.parallel)
604 .map(|t| t.id.clone())
605 .collect()
606 })
607 .unwrap_or_default();
608 let group: Vec<String> = schedule
609 .tasks
610 .iter()
611 .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
612 .filter(|t| {
613 parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id) && !yours(&t.id)
614 })
615 .filter(|t| {
617 !super::story::oracle_pending(
618 std::path::Path::new(w.path.as_deref().unwrap_or_default()),
619 &crate::worktree::project_state_dir(state, path),
620 format,
621 &snap,
622 &t.id,
623 )
624 })
625 .map(|t| t.id.clone())
626 .take(options.parallel.min(budget))
627 .collect();
628 if !parallel_off && group.len() >= 2 {
629 budget = budget.saturating_sub(group.len());
630 let feature = feature_name.clone().unwrap_or_default();
631 let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
632 let shared = RunOptions {
633 left: left.map(|l| l.less(l.amount() - l.amount() / group.len() as u64)),
634 ..options.clone()
635 };
636 let lanes = run_lanes(path, &feature, &group, state, provider, secret, &shared);
637 let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
638 attempts.extend(outcome.reports);
639 parallel_lines.borrow_mut().extend(outcome.lines);
640 one_at_a_time.extend(outcome.one_at_a_time);
641 parallel_off |= outcome.reverted;
642 if let Some((task, res)) = outcome.stop {
643 let claim = attempts
645 .iter()
646 .rev()
647 .find(|a| a.task == task && a.attempt_status == "blocked")
648 .map(|a| a.reason.clone().unwrap_or_default());
649 let Some(claim) = claim else {
650 return finish(
651 feature_name,
652 attempts,
653 res.status(),
654 format!("{task}: {}", res.message()),
655 )
656 .caused_by(&res);
657 };
658 let stop = crate::approvals::StopRequest {
659 task: format!("{}/{task}", w.feature),
660 title: title_of(&task),
661 claim: Some(claim),
662 dependents: speccoder_core::graph::dependents(&schedule, &task),
663 };
664 if let Some(failed) = settle_stop(
665 path,
666 feature_name.as_deref(),
667 state,
668 options,
669 &stop,
670 &mut hinted,
671 &mut passed,
672 ) {
673 return finish(
674 feature_name,
675 attempts,
676 failed.status(),
677 format!("{task}: {}", failed.message()),
678 )
679 .caused_by(&failed);
680 }
681 }
682 continue;
683 }
684 budget -= 1;
685 let task = next.id.clone();
686 let res = run_task(
687 path,
688 feature_name.as_deref(),
689 &task,
690 state,
691 std::sync::Arc::clone(provider),
692 secret,
693 &RunOptions {
694 left,
695 ..options.clone()
696 },
697 );
698 if let Some(CommandData::Run(r)) = res.data() {
699 if cap.is_some()
700 && !told_uncounted
701 && !r.run_id.is_empty()
702 && super::commit::all_usage(r).is_none()
703 && let Some(p) = &options.progress
704 {
705 told_uncounted = true;
706 p.event(&crate::progress::Event::Notice {
707 text: "this run's agent reports no usage: the cap cannot count its attempts"
708 .to_owned(),
709 });
710 }
711 attempts.push((**r).clone());
712 }
713 if let Some(c) = cap
716 && res.status() != CommandStatus::Ok
717 && let Some(stop) = capped(&c, &attempts, &schedule, &skipped, &w.feature)
718 {
719 return finish(feature_name, attempts, CommandStatus::NeedsHuman, stop.0)
720 .with_code("RUN-CAP")
721 .with_fix(vec![stop.1]);
722 }
723 let claim = match res.data() {
726 Some(CommandData::Run(r)) if r.attempt_status == "blocked" => {
727 Some(r.reason.clone().unwrap_or_default())
728 }
729 _ => None,
730 };
731 if let Some(claim) = claim.filter(|_| res.status() != CommandStatus::Ok) {
732 let stop = crate::approvals::StopRequest {
733 task: format!("{}/{task}", w.feature),
734 title: title_of(&task),
735 claim: Some(claim),
736 dependents: speccoder_core::graph::dependents(&schedule, &task),
737 };
738 if let Some(failed) = settle_stop(
739 path,
740 feature_name.as_deref(),
741 state,
742 options,
743 &stop,
744 &mut hinted,
745 &mut passed,
746 ) {
747 return finish(
748 feature_name,
749 attempts,
750 failed.status(),
751 format!("{task}: {}", failed.message()),
752 )
753 .caused_by(&failed);
754 }
755 continue;
756 }
757 if res.status() != CommandStatus::Ok {
758 return finish(
759 feature_name,
760 attempts,
761 res.status(),
762 format!("{task}: {}", res.message()),
763 )
764 .caused_by(&res);
765 }
766 }
767 finish(
768 feature_name,
769 attempts,
770 CommandStatus::Ok,
771 "stopped at --max-tasks".to_owned(),
772 )
773}
774
775fn cap_of(
779 path: &Path,
780 options: &RunOptions,
781) -> Result<Option<substrate::attempt::Left>, Box<CommandResult>> {
782 let text = crate::verify::find_config(path)
783 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
784 .unwrap_or_default();
785 let configured = substrate::run_config::parse(&text).ok().and_then(|c| c.cap);
786 let Some(cap) = options.cap.or(configured) else {
787 return Ok(None);
788 };
789 let prices = substrate::model_config::parse(&text)
790 .ok()
791 .flatten()
792 .and_then(|m| m.prices);
793 if matches!(cap, substrate::run_config::Cap::Usd(_)) && prices.is_none() {
794 let fix = speccoder_core::codes::lookup("RUN-CAP-UNPRICED")
795 .map(|e| speccoder_core::codes::fixes(e, "run", None, None))
796 .unwrap_or_default();
797 return Err(Box::new(
798 CommandResult::new(
799 "run",
800 CommandStatus::ConfigError,
801 &format!(
802 "a cap of {cap} needs the model's prices: set them in [model], or cap tokens"
803 ),
804 )
805 .with_code("RUN-CAP-UNPRICED")
806 .with_fix(fix),
807 ));
808 }
809 Ok(Some(substrate::attempt::Left { cap, prices }))
810}
811
812fn spent(cap: &substrate::attempt::Left, attempts: &[speccoder_core::RunReport]) -> u64 {
815 attempts
816 .iter()
817 .map(|a| {
818 cap.spent(&substrate::attempt::usage_of(
819 super::commit::all_usage(a).as_ref(),
820 ))
821 })
822 .sum()
823}
824
825fn capped(
828 cap: &substrate::attempt::Left,
829 attempts: &[speccoder_core::RunReport],
830 schedule: &speccoder_core::snapshot::Schedule,
831 skipped: &std::collections::BTreeMap<String, String>,
832 feature: &str,
833) -> Option<(String, String)> {
834 let used = spent(cap, attempts);
835 if used < cap.cap.amount() {
836 return None;
837 }
838 let open: Vec<&str> = schedule
839 .tasks
840 .iter()
841 .filter(|t| {
842 t.state != speccoder_core::snapshot::TaskState::Done && !skipped.contains_key(&t.id)
843 })
844 .map(|t| t.id.as_str())
845 .collect();
846 let used = cap.cap.with(used);
847 Some((
848 format!(
849 "the run spent its cap: {} of {}; {} left — a new run starts its cap over",
850 used.rounded(),
851 cap.cap,
852 if open.is_empty() {
853 "nothing".to_owned()
854 } else {
855 open.join(", ")
856 }
857 ),
858 format!(
859 "speccoder run --cap {} --feature {feature}",
860 cap.cap.doubled()
861 ),
862 ))
863}
864
865fn settle_stop(
869 path: &Path,
870 feature: Option<&str>,
871 state: &Path,
872 options: &RunOptions,
873 stop: &crate::approvals::StopRequest,
874 hinted: &mut std::collections::BTreeSet<String>,
875 passed: &mut std::collections::BTreeSet<String>,
876) -> Option<CommandResult> {
877 let id = stop
878 .task
879 .rsplit('/')
880 .next()
881 .unwrap_or(&stop.task)
882 .to_owned();
883 match ask_at_stop(path, feature, state, options, stop) {
884 Some((answer, done)) if done.status() == CommandStatus::Ok => {
885 if matches!(answer, crate::approvals::StopAnswer::Hint(_)) {
886 hinted.insert(id);
887 }
888 None
889 }
890 Some((_, failed)) => Some(failed),
891 None => {
892 passed.insert(id);
893 None
894 }
895 }
896}
897
898fn ask_at_stop(
903 path: &Path,
904 feature: Option<&str>,
905 state: &Path,
906 options: &RunOptions,
907 stop: &crate::approvals::StopRequest,
908) -> Option<(crate::approvals::StopAnswer, CommandResult)> {
909 let approver = options
910 .approver
911 .as_ref()
912 .filter(|_| options.lane.is_none())?;
913 let answer = approver.at_stop(stop)?;
914 let task = stop.task.rsplit('/').next().unwrap_or(&stop.task);
915 let done = crate::actions::answered_at_stop(
916 path,
917 feature,
918 task,
919 &answer,
920 (approver.via(), options),
921 state,
922 );
923 Some((answer, done))
924}
925
926#[derive(Debug, Clone)]
928pub(crate) struct NoTasks {
929 pub message: String,
931 pub fix: Vec<String>,
933 pub next: Vec<speccoder_core::next::NextStep>,
935}
936
937pub(crate) fn no_tasks(
940 format: &dyn crate::format::Adapter,
941 feature: &speccoder_core::snapshot::FeatureSnapshot,
942) -> Option<NoTasks> {
943 if feature.tasks.as_ref().is_some_and(|t| !t.tasks.is_empty()) {
944 return None;
945 }
946 let messages = format.messages();
947 let skills: Vec<String> = (format.planning().toward_tasks)(feature.artifacts.plan)
948 .iter()
949 .map(|s| (*s).to_owned())
950 .collect();
951 let name = &feature.feature.name;
952 Some(NoTasks {
953 message: format!(
954 "no tasks yet: {name} {}; {}",
955 if feature.tasks.is_some() {
956 format!("has a {} without a task", messages.tasks_file)
957 } else {
958 format!("has no {}", messages.tasks_file)
959 },
960 messages.make_tasks
961 ),
962 fix: skills
963 .iter()
964 .map(|s| format!("speccoder spec {s}"))
965 .collect(),
966 next: speccoder_core::next::next(
967 &speccoder_core::next::Outcome::Planning { skills },
968 Some(name),
969 ),
970 })
971}