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    // Observers hear the run's ticks and stops beside it (spec 095).
180    let delivering = crate::hooks::deliver::Deliverer::start(path, state);
181    // An observer nobody trusted hears nothing: said once per run.
182    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    // A unit that is a bug of the format's own structure runs as that bug.
189    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        // An autopilot run lists every call it refused instead of asking.
215        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    // `[YOU]` tasks the person gave a hint for: attempted after all.
254    let mut hinted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
255    // Tasks that are yours and nobody answered for: passed by like a
256    // skipped one, listed at the end with their three actions.
257    let mut passed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
258    // Tasks that must run on their own after a parallel group did not merge.
259    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    // Unfinished checklists stop the run before anything runs.
263    if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
264        return *stop;
265    }
266    // What the run may spend, and the prices a dollar cap is counted at.
267    let cap = match cap_of(path, options) {
268        Ok(c) => c,
269        Err(stop) => return *stop,
270    };
271    // An agent that reports no usage is said once: the cap cannot count it.
272    let mut told_uncounted = false;
273    // The format's `before_implement` hooks.
274    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        // The next ready task, read from the worktree.
308        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                // What the run will likely cost, and what it may.
374                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                // Who does what, said once: information, not a warning.
383                if let Some(l) = crate::status::lanes(path, Some(&w.feature)) {
384                    p.event(&crate::progress::Event::Notice { text: l.line() });
385                }
386                // The plan checks, said once per run start; they never
387                // stop, delay or re-order it.
388                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        // No task to work: the feature is not done, its tasks are not
401        // written yet (no task list, or one without a task).
402        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        // Skipped tasks are passed by; what depends on them stays blocked.
414        let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
415        // A task that is yours — marked `[YOU]`, or its last attempt stopped
416        // `BLOCKED` — is not attempted: the person is asked, or it is passed
417        // by until they answer (`run --task` and `task hint` attempt it).
418        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                // The `full` tier, once, when this run ticked the last task:
453                // a failure is reported and stops `pr`; the ticks stand.
454                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                // The held-out sets, now that no later task can read them.
475                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                // The format's `after_implement` hooks.
492                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                // What waits is the person's: named, with its three actions.
519                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        // The run's cap: nothing starts once it is spent; what is left is
586        // the next task's, or split evenly across a group's lanes.
587        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        // ready [P] tasks run at once, each in its own lane.
596        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            // A story whose oracle is still to be written goes first, alone.
616            .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                // An honest stop in a lane is asked about like any other.
644                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        // The cap ran out in this task: the stop is the cap's, whatever
714        // the attempt's own ending.
715        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        // An honest stop: the person is asked in place — I did it, a hint
724        // and a new attempt, or skip — or it is passed by; the run goes on.
725        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
775/// The run's cap (`run --cap` over `[budget] cap`) with the prices a dollar
776/// cap is counted at; a dollar cap without prices stops the run before
777/// anything runs (`RUN-CAP-UNPRICED`).
778fn 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
812/// What the run's finished tasks spent — every model call of each — in the
813/// cap's unit.
814fn 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
825/// The stop when the cap is spent: its words, and the command that goes on
826/// with twice the cap.
827fn 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
865/// A task that needs the person, settled: asked ([`ask_at_stop`]) — after
866/// a hint the run attempts it again — or, unanswered, passed by. `Some`:
867/// taking the answer failed, and the run ends with that.
868fn 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
898/// Asks the person at a task that needs them — at a terminal, never in a
899/// lane — and takes the answer — I did it, a hint, skip — through the one
900/// dispatcher ([`crate::actions::answered_at_stop`]); after a hint the run
901/// attempts the task again. `None`: nobody answered; the task waits in Needs You.
902fn 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/// Why a unit has nothing to run: no task list, or one without a task.
927#[derive(Debug, Clone)]
928pub(crate) struct NoTasks {
929    /// `no tasks yet: …`, naming the format's file and how to write it.
930    pub message: String,
931    /// The planning skills that write the tasks, as commands.
932    pub fix: Vec<String>,
933    /// The same as next steps, then the run.
934    pub next: Vec<speccoder_core::next::NextStep>,
935}
936
937/// [`NoTasks`] for `feature`, unless it has a task: such a unit is never
938/// done (code `NO-TASKS`).
939pub(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}