Skip to main content

speccoder_runtime/pipeline/
feature.rs

1//! `speccoder run` over a whole feature, and `converge`.
2
3use 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/// `speccoder run` without `--task`: attempts ready tasks in document order
12/// (read from the worktree) until the feature is done or an attempt does
13/// not pass. The provider comes from `[model]` and its key variable.
14#[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/// `speccoder converge`: assess the finished feature and append what remains
31///. The provider comes from `[model]` and its key variable.
32#[must_use]
33pub fn converge(path: &Path, feature: Option<&str>) -> CommandResult {
34    converge_in(path, feature, false)
35}
36
37/// [`converge`], in the person's checkout when `in_place`.
38#[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
56/// Opens the configured model driver (key from the environment only) and
57/// hands it to `f` with the state directory.
58pub(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
86/// `run --converge`: work the feature through, converge, and work through
87/// what convergence appended — until it converges, stops making progress,
88/// reaches `[converge] max_rounds`, or an attempt needs a person.
89pub 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    // In place: one session over every round.
98    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        // One cap over every round: a round gets what earlier ones left.
111        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
164/// [`run_feature`] with an explicit state directory and model driver.
165pub 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    // In place: one session around the whole feature.
174    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        // An autopilot run lists every call it refused instead of asking.
194        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    // `[YOU]` tasks the person gave a hint for: attempted after all.
233    let mut hinted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
234    // Tasks that are yours and nobody answered for: passed by like a
235    // skipped one, listed at the end with their three actions.
236    let mut passed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
237    // Tasks that must run on their own after a parallel group did not merge.
238    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    // Unfinished checklists stop the run before anything runs.
242    if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
243        return *stop;
244    }
245    // What the run may spend, and the prices a dollar cap is counted at.
246    let cap = match cap_of(path, options) {
247        Ok(c) => c,
248        Err(stop) => return *stop,
249    };
250    // An agent that reports no usage is said once: the cap cannot count it.
251    let mut told_uncounted = false;
252    // The format's `before_implement` hooks.
253    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        // The next ready task, read from the worktree.
287        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                // What the run will likely cost, and what it may.
353                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                // Who does what, said once: information, not a warning.
362                if let Some(l) = crate::status::lanes(path, Some(&w.feature)) {
363                    p.event(&crate::progress::Event::Notice { text: l.line() });
364                }
365                // The plan checks, said once per run start; they never
366                // stop, delay or re-order it.
367                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        // No task to work: the feature is not done, its tasks are not
380        // written yet (no task list, or one without a task).
381        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        // Skipped tasks are passed by; what depends on them stays blocked.
393        let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
394        // A task that is yours — marked `[YOU]`, or its last attempt stopped
395        // `BLOCKED` — is not attempted: the person is asked, or it is passed
396        // by until they answer (`run --task` and `task hint` attempt it).
397        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                // The `full` tier, once, when this run ticked the last task:
432                // a failure is reported and stops `pr`; the ticks stand.
433                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                // The held-out sets, now that no later task can read them.
454                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                // The format's `after_implement` hooks.
471                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                // What waits is the person's: named, with its three actions.
498                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        // The run's cap: nothing starts once it is spent; what is left is
565        // the next task's, or split evenly across a group's lanes.
566        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        // ready [P] tasks run at once, each in its own lane.
575        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            // A story whose oracle is still to be written goes first, alone.
595            .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                // An honest stop in a lane is asked about like any other.
623                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        // The cap ran out in this task: the stop is the cap's, whatever
693        // the attempt's own ending.
694        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        // An honest stop: the person is asked in place — I did it, a hint
703        // and a new attempt, or skip — or it is passed by; the run goes on.
704        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
754/// The run's cap (`run --cap` over `[budget] cap`) with the prices a dollar
755/// cap is counted at; a dollar cap without prices stops the run before
756/// anything runs (`RUN-CAP-UNPRICED`).
757fn 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
791/// What the run's finished tasks spent — every model call of each — in the
792/// cap's unit.
793fn 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
804/// The stop when the cap is spent: its words, and the command that goes on
805/// with twice the cap.
806fn 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
844/// A task that needs the person, settled: asked ([`ask_at_stop`]) — after
845/// a hint the run attempts it again — or, unanswered, passed by. `Some`:
846/// taking the answer failed, and the run ends with that.
847fn 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
877/// Asks the person at a task that needs them — at a terminal, never in a
878/// lane — and takes the answer — I did it, a hint, skip — through the one
879/// dispatcher ([`crate::actions::answered_at_stop`]); after a hint the run
880/// attempts the task again. `None`: nobody answered; the task waits in Needs You.
881fn 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/// Why a unit has nothing to run: no task list, or one without a task.
906#[derive(Debug, Clone)]
907pub(crate) struct NoTasks {
908    /// `no tasks yet: …`, naming the format's file and how to write it.
909    pub message: String,
910    /// The planning skills that write the tasks, as commands.
911    pub fix: Vec<String>,
912    /// The same as next steps, then the run.
913    pub next: Vec<speccoder_core::next::NextStep>,
914}
915
916/// [`NoTasks`] for `feature`, unless it has a task: such a unit is never
917/// done (code `NO-TASKS`).
918pub(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}