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    loop {
106        let res = run_feature_with(path, feature, state, provider, secret, options);
107        let Some(CommandData::Feature(fr)) = res.data() else {
108            return res;
109        };
110        let mut fr = (**fr).clone();
111        attempts.append(&mut fr.attempts);
112        let finished = res.status() == CommandStatus::Ok && fr.stop.starts_with("feature done");
113        if !finished {
114            fr.attempts = attempts;
115            fr.converge = rounds;
116            return CommandResult::carried("run", &res)
117                .with_data(CommandData::Feature(Box::new(fr)));
118        }
119        if let Some(p) = &options.progress {
120            p.event(&progress::Event::Notice {
121                text: format!("converge: assessing {} against its spec", fr.feature),
122            });
123        }
124        let c = convergence::converge_with_options(
125            path,
126            feature,
127            state,
128            Arc::clone(provider),
129            secret,
130            options,
131        );
132        if let Some(p) = &options.progress {
133            p.event(&progress::Event::Notice {
134                text: format!("converge: {}", c.message()),
135            });
136        }
137        let outcome = match c.data() {
138            Some(CommandData::Converge(r)) => {
139                let outcome = r.outcome.clone();
140                rounds.push((**r).clone());
141                outcome
142            }
143            _ => String::new(),
144        };
145        if outcome != "tasks_appended" {
146            fr.attempts = attempts;
147            fr.converge = rounds;
148            fr.stop = format!("converge: {}", c.message());
149            return CommandResult::new("run", c.status(), &fr.stop.clone())
150                .with_data(CommandData::Feature(Box::new(fr)));
151        }
152    }
153}
154
155/// [`run_feature`] with an explicit state directory and model driver.
156pub fn run_feature_with(
157    path: &Path,
158    feature: Option<&str>,
159    state: &Path,
160    provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
161    secret: Option<&str>,
162    options: &RunOptions,
163) -> CommandResult {
164    // In place: one session around the whole feature.
165    if options.in_place && options.session.is_none() {
166        return in_session("run", path, feature, state, options, |o| {
167            run_feature_with(path, feature, state, provider, secret, o)
168        });
169    }
170    let max_tasks = options.max_tasks;
171    use speccoder_core::FeatureReport;
172    use speccoder_core::snapshot::TaskState;
173
174    let mut attempts = Vec::new();
175    let mut feature_name = feature.map(str::to_owned);
176    let hook_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
177    let parallel_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
178    let full_tier: std::cell::RefCell<Option<speccoder_core::FullTier>> =
179        std::cell::RefCell::new(None);
180    let finish = |name: Option<String>,
181                  attempts: Vec<speccoder_core::RunReport>,
182                  status: CommandStatus,
183                  stop: String| {
184        // An autopilot run lists every call it refused instead of asking.
185        let autopilot_denied: Vec<speccoder_core::oracle::AutopilotDenial> =
186            if options.mode == speccoder_core::oracle::RunMode::Autopilot {
187                attempts
188                    .iter()
189                    .flat_map(|a| crate::oracle::autopilot_denials(&a.task, &a.tool_calls))
190                    .collect()
191            } else {
192                Vec::new()
193            };
194        if let Some(n) = name.as_deref() {
195            crate::oracle::record_denials(
196                &worktree::project_state_dir(state, path),
197                n,
198                &autopilot_denied,
199            );
200        }
201        let stop = if autopilot_denied.is_empty() {
202            stop
203        } else {
204            format!(
205                "{stop} — autopilot refused {} call(s) instead of asking; they are listed for you (speccoder inbox)",
206                autopilot_denied.len()
207            )
208        };
209        CommandResult::new("run", status, &stop).with_data(CommandData::Feature(Box::new(
210            FeatureReport {
211                feature: name.unwrap_or_default(),
212                attempts,
213                stop,
214                converge: Vec::new(),
215                hooks: hook_lines.borrow().clone(),
216                parallel: parallel_lines.borrow().clone(),
217                full_tier: full_tier.borrow().clone(),
218                mode: options.mode,
219                autopilot_denied,
220            },
221        )))
222    };
223    // Tasks that must run on their own after a parallel group did not merge.
224    let mut one_at_a_time: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
225    let mut parallel_off = false;
226    let mut budget = max_tasks.unwrap_or(usize::MAX);
227    // Unfinished checklists stop the run before anything runs.
228    if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
229        return *stop;
230    }
231    // The format's `before_implement` hooks.
232    match hosting::run_hooks(
233        "before_implement",
234        "",
235        path,
236        state,
237        provider,
238        secret,
239        options,
240    ) {
241        Ok(lines) => hook_lines.borrow_mut().extend(lines),
242        Err(stop) => {
243            return finish(
244                feature_name,
245                attempts,
246                stop.status(),
247                stop.message().to_owned(),
248            );
249        }
250    }
251    let mut planned = false;
252    while budget > 0 {
253        if options
254            .cancel
255            .as_ref()
256            .is_some_and(|c| c.load(std::sync::atomic::Ordering::SeqCst))
257        {
258            return finish(
259                feature_name,
260                attempts,
261                CommandStatus::NeedsHuman,
262                "cancelled by the person before the next task".to_owned(),
263            );
264        }
265        // The next ready task, read from the worktree.
266        let wt = workspace(path, feature_name.as_deref(), state, options);
267        let Some(CommandData::Worktree(w)) = wt.data() else {
268            return CommandResult::carried("run", &wt);
269        };
270        if wt.status() != CommandStatus::Ok {
271            return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
272        }
273        feature_name = Some(w.feature.clone());
274        let project = std::path::PathBuf::from(w.project_dir.clone().unwrap_or_default());
275        let format = crate::format::at(&project);
276        let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
277            return CommandResult::new(
278                "run",
279                CommandStatus::NotAProject,
280                format.messages().no_project_in_worktree,
281            );
282        };
283        let schedule = snap
284            .feature
285            .as_ref()
286            .and_then(|f| f.schedule.clone())
287            .unwrap_or_else(|| speccoder_core::snapshot::Schedule {
288                tasks: Vec::new(),
289                totals: speccoder_core::snapshot::ScheduleTotals::default(),
290            });
291        if !planned {
292            planned = true;
293            if let Some(p) = &options.progress {
294                let skipped = controls::skipped(&project, &w.feature);
295                let titles: std::collections::HashMap<&str, &str> = snap
296                    .feature
297                    .as_ref()
298                    .and_then(|f| f.tasks.as_ref())
299                    .map(|t| {
300                        t.tasks
301                            .iter()
302                            .map(|x| (x.id.as_str(), x.title.as_str()))
303                            .collect()
304                    })
305                    .unwrap_or_default();
306                p.event(&crate::progress::Event::RunStarted {
307                    feature: w.feature.clone(),
308                    tasks: schedule
309                        .tasks
310                        .iter()
311                        .map(|t| crate::progress::PlannedTask {
312                            id: t.id.clone(),
313                            title: titles
314                                .get(t.id.as_str())
315                                .copied()
316                                .unwrap_or_default()
317                                .to_owned(),
318                            state: if skipped.contains_key(&t.id) {
319                                "skipped".to_owned()
320                            } else {
321                                match t.state {
322                                    TaskState::Done => "done",
323                                    TaskState::Ready => "ready",
324                                    _ => "blocked",
325                                }
326                                .to_owned()
327                            },
328                        })
329                        .collect(),
330                });
331            }
332        }
333        // Skipped tasks are passed by; what depends on them stays blocked.
334        let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
335        let Some(next) = schedule
336            .tasks
337            .iter()
338            .find(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
339        else {
340            let open: Vec<String> = schedule
341                .tasks
342                .iter()
343                .filter(|t| t.state != TaskState::Done)
344                .map(|t| {
345                    if skipped.contains_key(&t.id) {
346                        format!("{} (skipped)", t.id)
347                    } else {
348                        t.id.clone()
349                    }
350                })
351                .collect();
352            return if open.is_empty() {
353                // The `full` tier, once, when this run ticked the last task:
354                // a failure is reported and stops `pr`; the ticks stand.
355                if attempts
356                    .iter()
357                    .any(|a: &speccoder_core::RunReport| a.ticked)
358                {
359                    let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
360                    let tier = full::run_once(&project, &wt_root);
361                    full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
362                    let failed = (!tier.passed).then(|| tier.failed.join(", "));
363                    *full_tier.borrow_mut() = Some(tier);
364                    if let Some(failed) = failed {
365                        return finish(
366                            feature_name,
367                            attempts,
368                            CommandStatus::VerificationFailed,
369                            format!(
370                                "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
371                            ),
372                        );
373                    }
374                }
375                // The held-out sets, now that no later task can read them.
376                if let (Some(name), Ok(opened)) =
377                    (feature_name.as_deref(), crate::format::open(path, feature))
378                {
379                    let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
380                    let plane = crate::worktree::project_state_dir(
381                        state,
382                        std::path::Path::new(&opened.snapshot.root),
383                    );
384                    if let Err(e) =
385                        crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
386                    {
387                        hook_lines
388                            .borrow_mut()
389                            .push(format!("held-out sets not published: {e}"));
390                    }
391                }
392                // The format's `after_implement` hooks.
393                match hosting::run_hooks(
394                    "after_implement",
395                    "",
396                    path,
397                    state,
398                    provider,
399                    secret,
400                    options,
401                ) {
402                    Ok(lines) => hook_lines.borrow_mut().extend(lines),
403                    Err(stop) => {
404                        return finish(
405                            feature_name,
406                            attempts,
407                            stop.status(),
408                            stop.message().to_owned(),
409                        );
410                    }
411                }
412                finish(
413                    feature_name,
414                    attempts,
415                    CommandStatus::Ok,
416                    "feature done: every task is ticked".to_owned(),
417                )
418            } else {
419                finish(
420                    feature_name,
421                    attempts,
422                    CommandStatus::TaskNotReady,
423                    format!("no task can run; open: {}", open.join(", ")),
424                )
425            };
426        };
427        // ready [P] tasks run at once, each in its own lane.
428        let parallel_ids: std::collections::BTreeSet<String> = snap
429            .feature
430            .as_ref()
431            .and_then(|f| f.tasks.as_ref())
432            .map(|t| {
433                t.tasks
434                    .iter()
435                    .filter(|t| t.parallel)
436                    .map(|t| t.id.clone())
437                    .collect()
438            })
439            .unwrap_or_default();
440        let group: Vec<String> = schedule
441            .tasks
442            .iter()
443            .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
444            .filter(|t| parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id))
445            .map(|t| t.id.clone())
446            .take(options.parallel.min(budget))
447            .collect();
448        if !parallel_off && group.len() >= 2 {
449            budget = budget.saturating_sub(group.len());
450            let feature = feature_name.clone().unwrap_or_default();
451            let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
452            let lanes = run_lanes(path, &feature, &group, state, provider, secret, options);
453            let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
454            attempts.extend(outcome.reports);
455            parallel_lines.borrow_mut().extend(outcome.lines);
456            one_at_a_time.extend(outcome.one_at_a_time);
457            parallel_off |= outcome.reverted;
458            if let Some((task, status, message)) = outcome.stop {
459                return finish(feature_name, attempts, status, format!("{task}: {message}"));
460            }
461            continue;
462        }
463        budget -= 1;
464        let task = next.id.clone();
465        let res = run_task(
466            path,
467            feature_name.as_deref(),
468            &task,
469            state,
470            std::sync::Arc::clone(provider),
471            secret,
472            options,
473        );
474        if let Some(CommandData::Run(r)) = res.data() {
475            attempts.push((**r).clone());
476        }
477        if res.status() != CommandStatus::Ok {
478            return finish(
479                feature_name,
480                attempts,
481                res.status(),
482                format!("{task}: {}", res.message()),
483            )
484            .caused_by(&res);
485        }
486    }
487    finish(
488        feature_name,
489        attempts,
490        CommandStatus::Ok,
491        "stopped at --max-tasks".to_owned(),
492    )
493}