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>, attempts, status: CommandStatus, stop: String| {
181        CommandResult::new("run", status, &stop).with_data(CommandData::Feature(Box::new(
182            FeatureReport {
183                feature: name.unwrap_or_default(),
184                attempts,
185                stop,
186                converge: Vec::new(),
187                hooks: hook_lines.borrow().clone(),
188                parallel: parallel_lines.borrow().clone(),
189                full_tier: full_tier.borrow().clone(),
190            },
191        )))
192    };
193    // Tasks that must run on their own after a parallel group did not merge.
194    let mut one_at_a_time: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
195    let mut parallel_off = false;
196    let mut budget = max_tasks.unwrap_or(usize::MAX);
197    // Unfinished checklists stop the run before anything runs.
198    if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
199        return *stop;
200    }
201    // The format's `before_implement` hooks.
202    match hosting::run_hooks(
203        "before_implement",
204        "",
205        path,
206        state,
207        provider,
208        secret,
209        options,
210    ) {
211        Ok(lines) => hook_lines.borrow_mut().extend(lines),
212        Err(stop) => {
213            return finish(
214                feature_name,
215                attempts,
216                stop.status(),
217                stop.message().to_owned(),
218            );
219        }
220    }
221    let mut planned = false;
222    while budget > 0 {
223        if options
224            .cancel
225            .as_ref()
226            .is_some_and(|c| c.load(std::sync::atomic::Ordering::SeqCst))
227        {
228            return finish(
229                feature_name,
230                attempts,
231                CommandStatus::NeedsHuman,
232                "cancelled by the person before the next task".to_owned(),
233            );
234        }
235        // The next ready task, read from the worktree.
236        let wt = workspace(path, feature_name.as_deref(), state, options);
237        let Some(CommandData::Worktree(w)) = wt.data() else {
238            return CommandResult::carried("run", &wt);
239        };
240        if wt.status() != CommandStatus::Ok {
241            return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
242        }
243        feature_name = Some(w.feature.clone());
244        let project = std::path::PathBuf::from(w.project_dir.clone().unwrap_or_default());
245        let format = crate::format::at(&project);
246        let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
247            return CommandResult::new(
248                "run",
249                CommandStatus::NotAProject,
250                format.messages().no_project_in_worktree,
251            );
252        };
253        let schedule = snap
254            .feature
255            .as_ref()
256            .and_then(|f| f.schedule.clone())
257            .unwrap_or_else(|| speccoder_core::snapshot::Schedule {
258                tasks: Vec::new(),
259                totals: speccoder_core::snapshot::ScheduleTotals::default(),
260            });
261        if !planned {
262            planned = true;
263            if let Some(p) = &options.progress {
264                let skipped = controls::skipped(&project, &w.feature);
265                let titles: std::collections::HashMap<&str, &str> = snap
266                    .feature
267                    .as_ref()
268                    .and_then(|f| f.tasks.as_ref())
269                    .map(|t| {
270                        t.tasks
271                            .iter()
272                            .map(|x| (x.id.as_str(), x.title.as_str()))
273                            .collect()
274                    })
275                    .unwrap_or_default();
276                p.event(&crate::progress::Event::RunStarted {
277                    feature: w.feature.clone(),
278                    tasks: schedule
279                        .tasks
280                        .iter()
281                        .map(|t| crate::progress::PlannedTask {
282                            id: t.id.clone(),
283                            title: titles
284                                .get(t.id.as_str())
285                                .copied()
286                                .unwrap_or_default()
287                                .to_owned(),
288                            state: if skipped.contains_key(&t.id) {
289                                "skipped".to_owned()
290                            } else {
291                                match t.state {
292                                    TaskState::Done => "done",
293                                    TaskState::Ready => "ready",
294                                    _ => "blocked",
295                                }
296                                .to_owned()
297                            },
298                        })
299                        .collect(),
300                });
301            }
302        }
303        // Skipped tasks are passed by; what depends on them stays blocked.
304        let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
305        let Some(next) = schedule
306            .tasks
307            .iter()
308            .find(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
309        else {
310            let open: Vec<String> = schedule
311                .tasks
312                .iter()
313                .filter(|t| t.state != TaskState::Done)
314                .map(|t| {
315                    if skipped.contains_key(&t.id) {
316                        format!("{} (skipped)", t.id)
317                    } else {
318                        t.id.clone()
319                    }
320                })
321                .collect();
322            return if open.is_empty() {
323                // The `full` tier, once, when this run ticked the last task:
324                // a failure is reported and stops `pr`; the ticks stand.
325                if attempts
326                    .iter()
327                    .any(|a: &speccoder_core::RunReport| a.ticked)
328                {
329                    let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
330                    let tier = full::run_once(&project, &wt_root);
331                    full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
332                    let failed = (!tier.passed).then(|| tier.failed.join(", "));
333                    *full_tier.borrow_mut() = Some(tier);
334                    if let Some(failed) = failed {
335                        return finish(
336                            feature_name,
337                            attempts,
338                            CommandStatus::VerificationFailed,
339                            format!(
340                                "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
341                            ),
342                        );
343                    }
344                }
345                // The held-out sets, now that no later task can read them.
346                if let (Some(name), Ok(opened)) =
347                    (feature_name.as_deref(), crate::format::open(path, feature))
348                {
349                    let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
350                    let plane = crate::worktree::project_state_dir(
351                        state,
352                        std::path::Path::new(&opened.snapshot.root),
353                    );
354                    if let Err(e) =
355                        crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
356                    {
357                        hook_lines
358                            .borrow_mut()
359                            .push(format!("held-out sets not published: {e}"));
360                    }
361                }
362                // The format's `after_implement` hooks.
363                match hosting::run_hooks(
364                    "after_implement",
365                    "",
366                    path,
367                    state,
368                    provider,
369                    secret,
370                    options,
371                ) {
372                    Ok(lines) => hook_lines.borrow_mut().extend(lines),
373                    Err(stop) => {
374                        return finish(
375                            feature_name,
376                            attempts,
377                            stop.status(),
378                            stop.message().to_owned(),
379                        );
380                    }
381                }
382                finish(
383                    feature_name,
384                    attempts,
385                    CommandStatus::Ok,
386                    "feature done: every task is ticked".to_owned(),
387                )
388            } else {
389                finish(
390                    feature_name,
391                    attempts,
392                    CommandStatus::TaskNotReady,
393                    format!("no task can run; open: {}", open.join(", ")),
394                )
395            };
396        };
397        // ready [P] tasks run at once, each in its own lane.
398        let parallel_ids: std::collections::BTreeSet<String> = snap
399            .feature
400            .as_ref()
401            .and_then(|f| f.tasks.as_ref())
402            .map(|t| {
403                t.tasks
404                    .iter()
405                    .filter(|t| t.parallel)
406                    .map(|t| t.id.clone())
407                    .collect()
408            })
409            .unwrap_or_default();
410        let group: Vec<String> = schedule
411            .tasks
412            .iter()
413            .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
414            .filter(|t| parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id))
415            .map(|t| t.id.clone())
416            .take(options.parallel.min(budget))
417            .collect();
418        if !parallel_off && group.len() >= 2 {
419            budget = budget.saturating_sub(group.len());
420            let feature = feature_name.clone().unwrap_or_default();
421            let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
422            let lanes = run_lanes(path, &feature, &group, state, provider, secret, options);
423            let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
424            attempts.extend(outcome.reports);
425            parallel_lines.borrow_mut().extend(outcome.lines);
426            one_at_a_time.extend(outcome.one_at_a_time);
427            parallel_off |= outcome.reverted;
428            if let Some((task, status, message)) = outcome.stop {
429                return finish(feature_name, attempts, status, format!("{task}: {message}"));
430            }
431            continue;
432        }
433        budget -= 1;
434        let task = next.id.clone();
435        let res = run_task(
436            path,
437            feature_name.as_deref(),
438            &task,
439            state,
440            std::sync::Arc::clone(provider),
441            secret,
442            options,
443        );
444        if let Some(CommandData::Run(r)) = res.data() {
445            attempts.push((**r).clone());
446        }
447        if res.status() != CommandStatus::Ok {
448            return finish(
449                feature_name,
450                attempts,
451                res.status(),
452                format!("{task}: {}", res.message()),
453            );
454        }
455    }
456    finish(
457        feature_name,
458        attempts,
459        CommandStatus::Ok,
460        "stopped at --max-tasks".to_owned(),
461    )
462}