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