Skip to main content

speccoder_runtime/pipeline/
mod.rs

1//! The run pipeline: one task ([`task`]), a feature ([`feature`]), and the
2//! pieces they share.
3
4use std::path::Path;
5use std::sync::Arc;
6
7use speccoder_core::snapshot::{Diagnostic, ProjectSnapshot, Severity};
8use speccoder_core::{CommandData, CommandResult, CommandStatus};
9
10use crate::commands::*;
11use crate::{approvals, progress, substrate, verify, worktree};
12
13pub mod acceptance;
14pub mod bug;
15pub mod commit;
16pub mod feature;
17pub mod full;
18pub mod judge;
19pub mod lanes;
20pub mod outcome;
21pub mod samples;
22pub mod story;
23pub mod task;
24
25pub(crate) use acceptance::*;
26pub(crate) use bug::{reject as bug_reject, triad_now as bug_triad_now};
27pub(crate) use commit::*;
28pub use feature::*;
29pub(crate) use judge::*;
30pub(crate) use lanes::*;
31pub(crate) use outcome::*;
32pub(crate) use samples::*;
33pub use task::*;
34
35/// Where a project's run state lives, and its model configuration.
36pub(crate) struct Setup {
37    pub(crate) snapshot: Box<speccoder_core::snapshot::ProjectSnapshot>,
38    pub(crate) model: substrate::model_config::ModelConfig,
39    pub(crate) plane_dir: std::path::PathBuf,
40    /// The project's format.
41    pub(crate) format: &'static dyn crate::format::Adapter,
42}
43
44/// The selected unit, when it is a bug of the format's own bug structure
45/// : its slug.
46pub(crate) fn bug_spec(path: &Path, feature: Option<&str>) -> Option<String> {
47    let format = crate::format::at(path);
48    format.bug_spec(&format.open(path, feature)?)
49}
50
51pub(crate) fn setup(
52    path: &Path,
53    feature: Option<&str>,
54    state: &Path,
55    command: &str,
56) -> Result<Setup, Box<CommandResult>> {
57    let inspected = inspect(path, feature);
58    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
59        return Err(Box::new(CommandResult::carried(command, &inspected)));
60    };
61    let snapshot = snapshot.clone();
62    preflight(&snapshot, command)?;
63    let config_error =
64        |m: String| Box::new(CommandResult::new(command, CommandStatus::ConfigError, &m));
65    let root = Path::new(&snapshot.root);
66    let config_dir = verify::find_config(root).ok_or_else(|| {
67        config_error(format!(
68            "no speccoder.toml at or above {}; add [model] provider = \"anthropic\", model = \"…\"",
69            root.display()
70        ))
71    })?;
72    let text = std::fs::read_to_string(config_dir.join(verify::config::FILE))
73        .map_err(|e| config_error(format!("speccoder.toml: {e}")))?;
74    let model = substrate::model_config::parse(&text)
75        .map_err(|e| config_error(format!("speccoder.toml: {e}")))?
76        .ok_or_else(|| config_error("speccoder.toml has no [model] section".to_owned()))?;
77    Ok(Setup {
78        format: crate::format::of(&snapshot),
79        plane_dir: worktree::project_state_dir(state, root),
80        snapshot,
81        model,
82    })
83}
84
85/// The format's preflight: errors of the selected unit (and, for `run`,
86/// what the format's own installed tools say) stop a run or proposal before
87/// any model call.
88fn preflight(snapshot: &ProjectSnapshot, command: &str) -> Result<(), Box<CommandResult>> {
89    if !matches!(command, "run" | "propose") {
90        return Ok(());
91    }
92    let format = crate::format::of(snapshot);
93    let mut extra = Vec::new();
94    if command == "run" {
95        match crate::sandbox::runner_at(Path::new(&snapshot.root)) {
96            Ok((runner, _)) => extra = format.crosscheck(snapshot, &runner),
97            Err(e) => extra.push(Diagnostic::new(
98                Severity::Info,
99                "crosscheck_skipped",
100                format!("cross-check skipped: {e}"),
101            )),
102        }
103    }
104    let problems = format.blocking(snapshot).len()
105        + extra
106            .iter()
107            .filter(|d| d.severity == Severity::Error)
108            .count();
109    if problems == 0 {
110        return Ok(());
111    }
112    let mut shown = snapshot.clone();
113    shown.diagnostics.extend(extra);
114    let name = shown
115        .feature
116        .as_ref()
117        .map(|f| f.feature.name.clone())
118        .unwrap_or_default();
119    // One problem is said, with its way out; several are counted.
120    let errors: Vec<&Diagnostic> = shown
121        .diagnostics
122        .iter()
123        .filter(|d| d.severity == Severity::Error)
124        .collect();
125    let (said, fix) = match (problems, errors.as_slice()) {
126        (1, [only]) => (only.message.clone(), only.hint.clone()),
127        _ => (format!("{problems} problems"), None),
128    };
129    let result = CommandResult::new(
130        command,
131        CommandStatus::DiagnosticsError,
132        &format!("{} {name} cannot run: {said}", format.wording().unit),
133    );
134    let result = match fix {
135        Some(f) => result.with_fix(vec![format!("→ {f}")]),
136        None => result,
137    };
138    Err(Box::new(
139        result.with_data(CommandData::Inspect(Box::new(shown))),
140    ))
141}
142
143pub(crate) fn tokio_runtime() -> Result<tokio::runtime::Runtime, String> {
144    tokio::runtime::Builder::new_current_thread()
145        .enable_all()
146        .build()
147        .map_err(|e| format!("cannot start the async runtime: {e}"))
148}
149
150/// Replaces every occurrence of `secret` (never shown, never journaled by
151/// speccoder), and every value copied from the person's secret files
152/// ([`crate::secrets`]).
153pub(crate) fn redact(text: &str, secret: Option<&str>) -> String {
154    let text = crate::secrets::mask(text);
155    match secret {
156        Some(s) if !s.is_empty() => text.replace(s, "[redacted]"),
157        _ => text,
158    }
159}
160
161/// One `speccoder run` from its fresh start to its end: `run-<unix
162/// secs>-<8 hex>`. Never one attempt's agentplane run (a retry is a new one
163/// of those, in the same run).
164#[derive(
165    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
166)]
167#[serde(transparent)]
168pub struct RunId(pub String);
169
170impl RunId {
171    /// A fresh id.
172    #[must_use]
173    pub fn fresh() -> Self {
174        use sha2::{Digest as _, Sha256};
175        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
176        let now = std::time::SystemTime::now()
177            .duration_since(std::time::UNIX_EPOCH)
178            .unwrap_or_default();
179        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
180        let digest = Sha256::digest(format!(
181            "{}/{}/{}/{n}",
182            now.as_nanos(),
183            std::process::id(),
184            std::thread::current().name().unwrap_or_default()
185        ));
186        let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
187        Self(format!("run-{}-{hex}", now.as_secs()))
188    }
189
190    /// Its text.
191    #[must_use]
192    pub fn as_str(&self) -> &str {
193        &self.0
194    }
195}
196
197impl Default for RunId {
198    fn default() -> Self {
199        Self::fresh()
200    }
201}
202
203impl std::fmt::Display for RunId {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.write_str(&self.0)
206    }
207}
208
209impl RunOptions {
210    /// The run's id now.
211    #[must_use]
212    pub fn run(&self) -> RunId {
213        self.run_id
214            .lock()
215            .map(|r| r.clone())
216            .unwrap_or_else(|_| RunId(String::new()))
217    }
218
219    /// The run's prompts so far.
220    #[must_use]
221    pub fn asked_now(&self) -> u32 {
222        self.asked.lock().map(|a| *a).unwrap_or(0)
223    }
224}
225
226/// How a run behaves beyond its configuration.
227#[derive(Debug, Clone, Default)]
228pub struct RunOptions {
229    /// At most this many tasks in a feature run.
230    pub max_tasks: Option<usize>,
231    /// Who to ask inline when a call needs approval; without one,
232    /// the attempt stops and `speccoder task approve` decides.
233    pub approver: Option<Arc<dyn approvals::Approver>>,
234    /// Where live progress goes: the CLI's printer.
235    pub progress: Option<Arc<dyn progress::Progress>>,
236    /// Ready `[P]` tasks run at once, each in its own worktree; 0 or 1: one at a time.
237    pub parallel: usize,
238    /// Run the task in this lane's worktree instead of the feature's.
239    pub lane: Option<String>,
240    /// Set by a person (an editor's cancel): a feature run stops
241    /// before its next task.
242    pub cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
243    /// Run although the feature's checklists have unchecked items; the
244    /// override is recorded on every commit of the run.
245    pub ignore_checklists: bool,
246    /// Set a task's held or interrupted attempt aside and write its
247    /// acceptance tests anew (`run --task T --fresh-acceptance`).
248    pub fresh_acceptance: bool,
249    /// Attempt although a check cannot run on the untouched code
250    /// (`run --allow-failing-baseline`); otherwise the run stops before any
251    /// model call (`CHECK-BROKEN`).
252    pub allow_failing_baseline: bool,
253    /// Objects a person approved "for this run": shared by every task (and
254    /// lane) of one `speccoder run`, gone with it.
255    pub run_grants: Arc<std::sync::Mutex<Vec<crate::tools::policy::Object>>>,
256    /// The run: one per `speccoder run` from its fresh start; an
257    /// invocation that resumes a suspended attempt adopts that attempt's.
258    /// Grants, the asking count and the batch are keyed by it.
259    pub run_id: Arc<std::sync::Mutex<RunId>>,
260    /// Prompts the run has shown a person (`[approvals] budget` caps them).
261    pub asked: Arc<std::sync::Mutex<u32>>,
262    /// Run in the person's checkout on `speccoder/<feature>` instead of a
263    /// worktree (`run --in-place`).
264    pub in_place: bool,
265    /// The in-place session the runtime opened for this run.
266    pub session: Option<Arc<std::sync::Mutex<crate::in_place::Session>>>,
267    /// Run a bug of the format's own bug structure (no task list names it).
268    pub bug_home: Option<Arc<bug::NativeBug>>,
269    /// How the run asks for a person: `Ask` as today, or
270    /// `Autopilot` — nothing asks, a call that would is refused for the run
271    /// unless a grant in `run_grants` (the ones the person ticked) covers it.
272    pub mode: speccoder_core::oracle::RunMode,
273    /// This run's agent (`run --agent <id>`): an ACP preset, or `native`
274    /// for speccoder's own loop; `speccoder.toml` is untouched.
275    pub agent: Option<String>,
276    /// What this run may spend (`run --cap`); over `[budget] cap`.
277    pub cap: Option<crate::substrate::run_config::Cap>,
278    /// What is left of the run's cap for this task, set by the feature loop.
279    pub left: Option<crate::substrate::run_config::Cap>,
280}
281
282/// Where a task runs, as `worktree` reports it: speccoder's worktree, or in
283/// place the person's checkout on `speccoder/<feature>`.
284pub(crate) fn workspace(
285    path: &Path,
286    feature: Option<&str>,
287    state: &Path,
288    options: &RunOptions,
289) -> CommandResult {
290    let Some(session) = options
291        .session
292        .as_ref()
293        .and_then(|s| s.lock().ok().map(|s| s.clone()))
294    else {
295        if let Some(e) = crate::in_place::session_elsewhere(path, feature, state) {
296            return e.result("run");
297        }
298        return worktree_with(path, feature, state);
299    };
300    CommandResult::new("worktree", CommandStatus::Ok, "in place").with_data(CommandData::Worktree(
301        speccoder_core::WorktreeReport {
302            feature: session.feature.clone(),
303            path: Some(session.checkout.display().to_string()),
304            project_dir: Some(session.project.display().to_string()),
305            branch: Some(session.branch.clone()),
306            base: Some(session.tip.clone()),
307            created: false,
308            refused_paths: Vec::new(),
309        },
310    ))
311}
312
313/// Runs `f` inside an in-place session when one is asked for and none is
314/// open yet: checks and switches the checkout first, switches it back after
315/// — on every way out.
316pub(crate) fn in_session(
317    command: &str,
318    path: &Path,
319    feature: Option<&str>,
320    state: &Path,
321    options: &RunOptions,
322    f: impl FnOnce(&RunOptions) -> CommandResult,
323) -> CommandResult {
324    if !options.in_place || options.session.is_some() {
325        return f(options);
326    }
327    if options.parallel > 1 {
328        return crate::in_place::InPlaceError {
329            code: "IN-PLACE-PARALLEL",
330            message: "an in-place run has one checkout; it cannot run tasks in parallel".to_owned(),
331            paths: Vec::new(),
332            fix: vec!["speccoder run --in-place   (without --parallel)".to_owned()],
333            commands: Vec::new(),
334        }
335        .result(command);
336    }
337    let opened = match crate::format::open(path, feature) {
338        Ok(o) => o,
339        Err(r) => return CommandResult::new(command, r.status, &r.message),
340    };
341    let Some(f_name) = opened
342        .snapshot
343        .feature
344        .as_ref()
345        .map(|f| f.feature.name.clone())
346    else {
347        return CommandResult::new(
348            command,
349            CommandStatus::SelectionNeeded,
350            "no feature selected; pass --feature",
351        );
352    };
353    let root = std::path::PathBuf::from(&opened.snapshot.root);
354    let plane_dir = worktree::project_state_dir(state, &root);
355    let policy = crate::tools::policy::Policy::new(&[], &[]).rules(opened.adapter.rules());
356    let mut session = match crate::in_place::Session::begin(&root, &plane_dir, &f_name, &policy) {
357        Ok(s) => s,
358        Err(e) => return e.result(command),
359    };
360    // Ticks a killed run committed but did not write, read on the branch.
361    let format = crate::format::at(&root);
362    if let Some(tasks) = format
363        .inspect(&root, Some(&f_name))
364        .and_then(|snap| snap.feature.and_then(|f| f.tasks))
365    {
366        let file = session.project.join(&tasks.path);
367        session.reconcile(&plane_dir, &file, &tasks, |file, task, hash| {
368            format.tick(file, task, hash)
369        });
370    }
371    let original = session.original.describe();
372    if let Some(p) = &options.progress {
373        p.event(&progress::Event::Notice {
374            text: format!(
375                "in place: on {} in {}; returns to {original} at the end — do not use this checkout until then",
376                session.branch,
377                session.checkout.display()
378            ),
379        });
380        for note in &session.notes {
381            p.event(&progress::Event::Notice { text: note.clone() });
382        }
383    }
384    let shared = Arc::new(std::sync::Mutex::new(session));
385    let inner = RunOptions {
386        session: Some(Arc::clone(&shared)),
387        ..options.clone()
388    };
389    let res = f(&inner);
390    let session = shared.lock().map(|s| s.clone());
391    // An attempt waiting for a person keeps the checkout as it left it: the
392    // session stays, and the next `run --in-place` takes it over and resumes.
393    if let Some(task) = waiting_task(&res) {
394        let notes: String = session
395            .as_ref()
396            .map(|s| s.notes.iter().map(|n| format!("\n{n}")).collect())
397            .unwrap_or_default();
398        let message = format!(
399            "{}{notes}\nthe checkout stays on {} while {task} waits: decide it (`speccoder task approve {task}` or `reject`), then `speccoder run --in-place` continues it — or `speccoder run --in-place --return` gives the checkout back",
400            res.message(),
401            session
402                .as_ref()
403                .map(|s| s.branch.as_str())
404                .unwrap_or("the speccoder branch"),
405        );
406        let status = res.status();
407        return res.reworded(status, &message);
408    }
409    let (back, notes) = match session {
410        Ok(s) => (s.finish(&plane_dir), s.notes),
411        Err(_) => (Ok(()), Vec::new()),
412    };
413    let told: String = notes.iter().map(|n| format!("\n{n}")).collect();
414    match back {
415        Ok(()) => {
416            let message = format!("{} — back on {original}{told}", res.message());
417            let status = res.status();
418            res.reworded(status, &message)
419        }
420        Err(e) => {
421            let message = format!("{}{told}\n{}", res.message(), e.line());
422            res.reworded(CommandStatus::NeedsHuman, &message)
423                .with_code(e.code)
424                .with_fix(e.fix.clone())
425        }
426    }
427}
428
429/// The task whose attempt the result left waiting for a person, if any.
430fn waiting_task(res: &CommandResult) -> Option<String> {
431    let waits = |r: &speccoder_core::RunReport| {
432        r.pending
433            .as_ref()
434            .is_some_and(|p| p["waiting"] == true)
435            .then(|| r.task.clone())
436    };
437    match res.data() {
438        Some(CommandData::Run(r)) => waits(r),
439        Some(CommandData::Feature(f)) => f.attempts.last().and_then(waits),
440        _ => None,
441    }
442}
443
444/// The format's checklist gate: an unchecked item in the feature's
445/// checklists stops a run before any model call, unless a person
446/// overrides it (`run --ignore-checklists`). `Ok(true)`: overridden.
447pub(crate) fn checklist_gate(
448    project: &Path,
449    feature: Option<&str>,
450    ignore: bool,
451) -> Result<bool, Box<CommandResult>> {
452    let format = crate::format::at(project);
453    let Some(snap) = format.inspect(project, feature) else {
454        return Ok(false);
455    };
456    let Some(f) = snap.feature.as_ref() else {
457        return Ok(false);
458    };
459    let lists = format.checklists(Path::new(&snap.root), &f.feature.dir);
460    let open: Vec<String> = lists
461        .iter()
462        .filter(|c| c.open > 0)
463        .map(|c| format!("{} ({} of {} open)", c.file, c.open, c.total))
464        .collect();
465    if open.is_empty() {
466        return Ok(false);
467    }
468    if ignore {
469        return Ok(true);
470    }
471    Err(Box::new(
472        CommandResult::new(
473            "run",
474            CommandStatus::NeedsHuman,
475            &(format.messages().checklists)(&open.join(", ")),
476        )
477        .with_data(CommandData::Checklists(lists)),
478    ))
479}
480
481/// One stage event of `task`; `since` (when the stage began) gives an ended
482/// stage its duration.
483pub(crate) fn stage(
484    live: &dyn crate::progress::Progress,
485    task: &str,
486    stage: speccoder_core::stage::Stage,
487    state: speccoder_core::stage::StageState,
488    detail: Option<String>,
489    since: Option<std::time::Instant>,
490) {
491    stage_with_tail(live, task, stage, state, detail, since, Vec::new());
492}
493
494/// [`stage`] with the last lines of what failed.
495pub(crate) fn stage_with_tail(
496    live: &dyn crate::progress::Progress,
497    task: &str,
498    stage: speccoder_core::stage::Stage,
499    state: speccoder_core::stage::StageState,
500    detail: Option<String>,
501    since: Option<std::time::Instant>,
502    tail: Vec<String>,
503) {
504    live.event(&crate::progress::Event::Stage {
505        task: task.to_owned(),
506        stage,
507        state,
508        detail,
509        elapsed_ms: since.map(|t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX)),
510        tail,
511    });
512}
513
514/// The tail of the first failing check of `report`, for a failed stage.
515pub(crate) fn failing_tail(report: &speccoder_core::verify::VerifyReport) -> Vec<String> {
516    report
517        .checks
518        .iter()
519        .find(|c| c.status.is_failure())
520        .map(|c| crate::progress::tail(&format!("{}\n{}", c.stdout_tail, c.stderr_tail)))
521        .unwrap_or_default()
522}