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