Skip to main content

speccoder_runtime/pipeline/task/
mod.rs

1//! `speccoder run --task`: one task from baseline to tick, one file per
2//! phase: `open`, `ready`, `tests`, `attempt`, `judge`, `tick`.
3
4use std::path::Path;
5use std::sync::Arc;
6
7use speccoder_core::{CommandData, CommandResult, CommandStatus};
8
9use crate::pipeline::*;
10use crate::{
11    approvals, attempts, external, integrity, mcp, mutation, progress, resume, sandbox, substrate,
12    tools, worktree,
13};
14
15/// `speccoder run`: one attempt at a ready task in its worktree, judged by
16/// the verifier. The provider comes from `[model]` and its key variable.
17#[must_use]
18pub fn run(path: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
19    let Some(state) = worktree::state_dir() else {
20        return CommandResult::new(
21            "run",
22            CommandStatus::ConfigError,
23            "no state directory (set SPECCODER_STATE_DIR or HOME)",
24        )
25        .with_code("NO-STATE-DIR");
26    };
27    let model = match setup(path, feature, &state, "run") {
28        Ok(s) => s.model,
29        Err(r) => return *r,
30    };
31    let (driver, key) = match substrate::model_config::open_driver(&model) {
32        Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
33        Err(e) => return substrate::model_config::refused("run", &e),
34    };
35    match driver {
36        Ok(provider) => run_task(path, feature, task, &state, provider, Some(&key), options),
37        Err(e) => CommandResult::new("run", CommandStatus::ConfigError, &redact(&e, Some(&key))),
38    }
39}
40
41/// [`run`] with an explicit state directory and model driver.
42pub fn run_with(
43    path: &Path,
44    feature: Option<&str>,
45    task: &str,
46    state: &Path,
47    provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
48    secret: Option<&str>,
49) -> CommandResult {
50    run_task(
51        path,
52        feature,
53        task,
54        state,
55        provider,
56        secret,
57        &RunOptions::default(),
58    )
59}
60
61/// [`run_with`] with explicit [`RunOptions`].
62#[must_use]
63pub fn run_with_options(
64    path: &Path,
65    feature: Option<&str>,
66    task: &str,
67    state: &Path,
68    provider: Arc<dyn agentplane::model::ModelProvider>,
69    secret: Option<&str>,
70    options: &RunOptions,
71) -> CommandResult {
72    run_task(path, feature, task, state, provider, secret, options)
73}
74
75/// [`run_with`] asking `approver` inline when a call needs approval.
76#[must_use]
77pub fn run_with_approver(
78    path: &Path,
79    feature: Option<&str>,
80    task: &str,
81    state: &Path,
82    provider: Arc<dyn agentplane::model::ModelProvider>,
83    secret: Option<&str>,
84    approver: Arc<dyn approvals::Approver>,
85) -> CommandResult {
86    let options = RunOptions {
87        approver: Some(approver),
88        ..RunOptions::default()
89    };
90    run_task(path, feature, task, state, provider, secret, &options)
91}
92
93/// Runs a bug of the format's own bug structure: a unit no task list
94/// names, its task `bug:<slug>`, ticked nowhere.
95#[must_use]
96pub fn run_native_bug(
97    root: &Path,
98    bug: &speccoder_core::bug::BugReport,
99    state: &Path,
100    provider: Arc<dyn agentplane::model::ModelProvider>,
101    secret: Option<&str>,
102    options: &RunOptions,
103) -> CommandResult {
104    let options = RunOptions {
105        bug_home: Some(Arc::new(super::bug::NativeBug::of(bug))),
106        ..options.clone()
107    };
108    run_task(root, None, &bug.task, state, provider, secret, &options)
109}
110
111mod attempt;
112mod judge;
113/// One task: its attempt, and — when a planning attempt changed code —
114/// the task again as code (the planning attempt saved and undone, its
115/// saved outcome the reason it is code from now on).
116mod open;
117mod ready;
118mod tests;
119mod tick;
120
121/// A phase's result, or the run's end with what a person sees.
122macro_rules! ok {
123    ($phase:expr) => {
124        match $phase {
125            Ok(x) => x,
126            Err(end) => return *end,
127        }
128    };
129}
130
131/// What opening the task started that later phases carry on.
132pub(super) struct Started {
133    /// Removes the resume marker when the run ends, unless kept.
134    pub(super) marker_guard: crate::resume::Guard,
135    /// The worktree's head an interrupted attempt is resumed at.
136    pub(super) head_now: String,
137    /// Leftovers set aside before the run, named in its report.
138    pub(super) set_aside: Option<String>,
139}
140
141/// What one phase sets and a later phase changes: handed on explicitly,
142/// changed only by a phase that takes it mutably.
143pub(super) struct Carried {
144    /// The worktree's head an interrupted attempt is resumed at (moved by
145    /// the story's oracle commit).
146    pub(super) head_now: String,
147    /// Removes the resume marker when the run ends, unless kept.
148    pub(super) marker_guard: crate::resume::Guard,
149    /// Leftovers set aside before the run, named in its report.
150    pub(super) set_aside: Option<String>,
151    /// The checks on the tree the attempt starts from (re-run after the
152    /// story's oracle is committed).
153    pub(super) baseline: speccoder_core::verify::VerifyReport,
154    /// What the run tells a person, gathered by every phase.
155    pub(super) notices: Vec<String>,
156}
157
158/// An early end recorded as the task run's end (`TaskFinished`), redacted.
159pub(super) fn ended(
160    live: &dyn progress::Progress,
161    task: &str,
162    secret: Option<&str>,
163    status: CommandStatus,
164    m: String,
165) -> CommandResult {
166    live.event(&progress::Event::TaskFinished {
167        task: task.to_owned(),
168        outcome: "error".to_owned(),
169        message: redact(&m, secret),
170        run: None,
171        input_tokens: 0,
172        output_tokens: 0,
173        cache_read_tokens: 0,
174        cache_write_tokens: 0,
175        usd: None,
176        regression: None,
177    });
178    CommandResult::new("run", status, &redact(&m, secret))
179}
180
181pub(crate) fn run_task(
182    path: &Path,
183    feature: Option<&str>,
184    task: &str,
185    state: &Path,
186    provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
187    secret: Option<&str>,
188    options: &RunOptions,
189) -> CommandResult {
190    // Observers hear the task's tick or stop beside it (spec 095); inside a
191    // feature run, the feature's deliverer does.
192    let delivering = crate::hooks::deliver::Deliverer::start(path, state);
193    // An observer nobody trusted hears nothing: said once per run.
194    if delivering.is_some()
195        && let Some(p) = &options.progress
196        && let Some(text) = crate::hooks::report::untrusted_notice(path, state)
197    {
198        p.event(&progress::Event::Notice { text });
199    }
200    // An extension nobody trusted runs nothing: said once per run.
201    if let Some(p) = &options.progress
202        && let Some(text) = crate::extensions::report::run_notice(path, state)
203    {
204        p.event(&progress::Event::Notice { text });
205    }
206    // A unit that is a bug runs as one bug, never task by task.
207    if options.bug_home.is_none()
208        && let Some(slug) = bug_spec(path, feature)
209    {
210        if let Some(p) = &options.progress {
211            p.event(&progress::Event::Notice {
212                text: format!("{slug} is a bug: it runs as one, not task by task"),
213            });
214        }
215        return crate::bug::run_with(path, Some(&slug), None, state, provider, secret, options);
216    }
217    let first = run_task_once(
218        path,
219        feature,
220        task,
221        state,
222        Arc::clone(&provider),
223        secret,
224        options,
225    );
226    match first.data() {
227        Some(CommandData::Run(r)) if r.attempt_status == CHANGED_CODE => {
228            if let Some(p) = &options.progress {
229                p.event(&progress::Event::Notice {
230                    text: format!("{task} changed code, so it runs as code"),
231                });
232            }
233            run_task_once(path, feature, task, state, provider, secret, options)
234        }
235        _ => first,
236    }
237}
238
239/// The outcome of a planning attempt that changed something outside its
240/// unit: saved and undone, and the task is code from then on.
241pub const CHANGED_CODE: &str = "planning_changed_code";
242
243/// One task's run, phase by phase — each a file of `task/`: the task
244/// opened (`open`), the worktree made ready (`ready`), the tests written
245/// first (`tests`), the attempt (`attempt`), judged beyond its verdict
246/// (`judge`), ticked or saved (`tick`). Results are read-only to later
247/// phases; what one phase changes for another travels in [`Carried`].
248fn run_task_once(
249    path: &Path,
250    feature: Option<&str>,
251    task: &str,
252    state: &Path,
253    provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
254    secret: Option<&str>,
255    options: &RunOptions,
256) -> CommandResult {
257    // In place: the person's checkout, switched and switched back around the task.
258    if options.in_place && options.session.is_none() {
259        return in_session("run", path, feature, state, options, |o| {
260            run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
261        });
262    }
263    let (opened, started) = ok!(open::task(path, feature, task, state, secret, options));
264    let (ready, mut carried) = ok!(ready::make(
265        &opened,
266        started,
267        task,
268        state,
269        secret,
270        options,
271        Arc::clone(&provider),
272    ));
273    let mut tests = ok!(tests::first(
274        &opened,
275        &ready,
276        &mut carried,
277        task,
278        state,
279        secret,
280        options,
281    ));
282    let attempted = ok!(attempt::run(
283        path,
284        &opened,
285        &ready,
286        &mut tests,
287        &mut carried,
288        task,
289        state,
290        secret,
291        options,
292    ));
293    let (attempted, judged) = judge::after(&opened, &ready, &tests, attempted, task, secret);
294    tick::finish(
295        path, feature, &opened, &ready, tests, attempted, carried, judged, task, state, secret,
296        options,
297    )
298}
299
300/// The code of a run the collection check decided: a file that never ran
301/// (`TESTS-NOT-COLLECTED`), or one speccoder cannot tell about.
302fn collection_code(
303    report: &speccoder_core::RunReport,
304    attempt_status: &str,
305) -> Option<&'static str> {
306    if attempt_status == "collection_unknown" {
307        return Some("TESTS-COLLECTION-UNKNOWN");
308    }
309    match &report.verdict {
310        Some(speccoder_core::verify::Verdict::Failed { reasons })
311            if reasons
312                .first()
313                .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
314        {
315            Some("TESTS-NOT-COLLECTED")
316        }
317        _ => None,
318    }
319}
320
321/// Judges the attempt's changed test files against what the run collected:
322/// a file no test ran from fails the verdict (its reason
323/// first); for an otherwise passing attempt, a file speccoder cannot tell
324/// about returns the stop's message. Listings run only then.
325/// What the collection check came to beyond the verdict's reasons.
326enum Collected {
327    /// A file speccoder cannot tell about: the stop's message.
328    Unknown(String),
329    /// A listing changed the worktree: the integrity finding.
330    Changed(String),
331}
332
333fn judge_collection(
334    dirs: (&Path, &Path, &Path),
335    runner: &sandbox::Runner,
336    otherwise_passing: bool,
337    report: &mut speccoder_core::RunReport,
338    live: &dyn progress::Progress,
339    task: &str,
340) -> Option<Collected> {
341    let (project, wt_root, tmp) = dirs;
342    // Listings run the project's code: the tree they leave must be the one judged.
343    let before = worktree::checkpoint(wt_root, tmp).ok();
344    let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
345        .ok()
346        .and_then(|t| crate::verify::config::parse(&t).ok())
347        .unwrap_or_default()
348        .into_iter()
349        .filter(|c| c.tier == crate::verify::config::Tier::Fast)
350        .collect::<Vec<_>>();
351    let results = report
352        .verification
353        .as_ref()
354        .map(|v| v.checks.clone())
355        .unwrap_or_default();
356    let c = crate::collection::judge(&crate::collection::Input {
357        root: project,
358        changes: &report.changes,
359        checks: &checks,
360        results: &results,
361        runner,
362        listings: otherwise_passing,
363    })?;
364    if !c.listing.is_empty()
365        && let Some(before) = &before
366    {
367        let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
368            .and_then(|after| worktree::diff(wt_root, before, &after))
369            .map(|d| {
370                d.into_iter()
371                    .map(|c| c.path)
372                    .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
373                    .collect()
374            })
375            .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
376        if !changed.is_empty() {
377            report.collection = Some(c);
378            return Some(Collected::Changed(format!(
379                "integrity: a test listing changed the worktree: {}",
380                changed.join(", ")
381            )));
382        }
383    }
384    live.event(&progress::Event::CollectionChecked {
385        task: task.to_owned(),
386        files: c
387            .files
388            .iter()
389            .map(|f| progress::CollectionFile {
390                path: f.path.clone(),
391                state: f.state,
392                decided_by: f.decided_by,
393            })
394            .collect(),
395    });
396    let never = c.reasons();
397    let unknown: Vec<String> = c
398        .unknown()
399        .map(|f| {
400            format!(
401                "speccoder cannot tell whether the runner ran `{}` ({})",
402                f.path,
403                f.why.as_deref().unwrap_or("no report or listing names it")
404            )
405        })
406        .collect();
407    report.collection = Some(c);
408    if !never.is_empty() {
409        let mut reasons = never;
410        if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
411            report.verdict.take()
412        {
413            reasons.extend(more);
414        }
415        report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
416        return None;
417    }
418    (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
419}
420
421/// `note`, with what the check itself said last (its own reason) and, for
422/// the causes that are known, the fix.
423fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
424    let said = c
425        .stderr_tail
426        .lines()
427        .chain(c.stdout_tail.lines())
428        .map(str::trim)
429        .find(|l| l.starts_with("error"))
430        .map(|l| l.chars().take(200).collect::<String>());
431    let fix = match said.as_deref() {
432        Some(l) if l.contains("profile `speccoder` not found") => Some(
433            "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
434        ),
435        Some(l) if l.contains("no tests to run") => Some(
436            "the project has no tests yet; if the first task writes them, run with --allow-failing-baseline, or add `--no-tests=pass` to the check's argv",
437        ),
438        Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
439            "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
440        ),
441        _ => None,
442    };
443    match (said, fix) {
444        (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
445        (Some(s), None) => format!("{note} ({s})"),
446        _ => note.to_owned(),
447    }
448}
449
450/// Why a failing baseline check is broken rather than failing tests: it
451/// could not start, or it failed without any test result to show for it.
452/// `None`: it passed, or its tests ran and some failed.
453/// What the sandbox denied it comes last: often the cause itself.
454#[must_use]
455pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
456    let why = broken_why(c)?;
457    Some(match c.sandbox.denied.first() {
458        Some(first) => {
459            let others = c.sandbox.denied.len() - 1 + c.sandbox.more;
460            let more = if others > 0 {
461                format!(" (and {others} more)")
462            } else {
463                String::new()
464            };
465            format!("{why} — the sandbox denied {first}{more}")
466        }
467        None => why,
468    })
469}
470
471fn broken_why(c: &speccoder_core::verify::CheckResult) -> Option<String> {
472    use speccoder_core::verify::CheckStatus;
473    if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
474        return None;
475    }
476    if let Some(note) = &c.note {
477        return Some(with_cause(note, c));
478    }
479    if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
480        return None;
481    }
482    let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
483    // Only what is unambiguous: a command that cannot start, a build that
484    // fails, or a test runner that ran no test. A script or `grep` that
485    // fails until the task is done is a failing check, not a broken one.
486    let runner = c
487        .argv
488        .first()
489        .map(|a| a.rsplit('/').next().unwrap_or(a))
490        .is_some_and(|a| {
491            matches!(
492                a,
493                "cargo"
494                    | "pytest"
495                    | "python"
496                    | "python3"
497                    | "uv"
498                    | "npm"
499                    | "npx"
500                    | "pnpm"
501                    | "yarn"
502                    | "bun"
503                    | "go"
504                    | "jest"
505                    | "vitest"
506                    | "mvn"
507                    | "gradle"
508                    | "dotnet"
509                    | "mix"
510                    | "swift"
511            )
512        });
513    let cannot_start = matches!(c.exit_code, Some(126 | 127));
514    let is_broken = match &summary {
515        _ if cannot_start => true,
516        Some(s) if s.build_failed => true,
517        Some(s) => s.passed + s.failed + s.errors == 0,
518        None => runner,
519    };
520    match is_broken {
521        false => None,
522        true => {
523            let first = c
524                .stderr_tail
525                .lines()
526                .chain(c.stdout_tail.lines())
527                .map(str::trim)
528                .find(|l| l.to_lowercase().contains("error"))
529                .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
530                .unwrap_or("it failed without running a test")
531                .chars()
532                .take(160)
533                .collect::<String>();
534            Some(first)
535        }
536    }
537}
538
539/// The task stops for the agent's sign-in: its own ways, a command one
540/// names as a step of the person's own (a terminal of theirs: it reads the
541/// keyboard). Nothing was attempted.
542fn login(
543    fail: impl FnOnce(CommandStatus, String) -> CommandResult,
544    shown: &str,
545    agent: &crate::acp::agent::Agent,
546    ways: &[crate::acp::session::SignIn],
547) -> CommandResult {
548    let words: Vec<String> = ways
549        .iter()
550        .filter(|w| w.command.is_none())
551        .map(|w| {
552            format!(
553                "→ {}",
554                w.description.clone().unwrap_or_else(|| w.name.clone())
555            )
556        })
557        .collect();
558    let mut commands: Vec<Vec<String>> = ways
559        .iter()
560        .filter_map(|w| w.command.as_deref())
561        .map(|c| c.split_whitespace().map(str::to_owned).collect())
562        .collect();
563    if words.is_empty()
564        && commands.is_empty()
565        && let Some(p) = agent.preset()
566    {
567        commands.push(p.login.split_whitespace().map(str::to_owned).collect());
568    }
569    commands.dedup();
570    let argvs: Vec<speccoder_core::step::Argvs> = commands.into_iter().map(|c| vec![c]).collect();
571    let mut fix: Vec<String> = argvs
572        .iter()
573        .map(|a| speccoder_core::step::render(a))
574        .collect();
575    fix.extend(words);
576    fix.push("speccoder doctor".to_owned());
577    fail(
578        CommandStatus::NeedsHuman,
579        format!("{shown} needs signing in before it can make the attempt; nothing was attempted"),
580    )
581    .with_code("EXECUTOR-LOGIN")
582    .with_fix(fix)
583    .with_commands(argvs)
584}
585
586/// The red class of a story task's turned scenarios, from the evidence
587/// the oracle gave it.
588fn story_red(report: &speccoder_core::RunReport) -> Option<speccoder_core::verify::RedClass> {
589    let a = report
590        .acceptance
591        .as_ref()
592        .filter(|a| a["oracle"].is_string())?;
593    serde_json::from_value::<speccoder_core::verify::RedClass>(a["red_class"].clone())
594        .ok()
595        .filter(|c| c.is_red())
596}
597
598/// The bundle the implementer is given: with the story's oracle, when the
599/// task is judged by one.
600fn with_oracle(bundle: &serde_json::Value, oracle: &serde_json::Value) -> serde_json::Value {
601    let mut b = bundle.clone();
602    if !oracle.is_null() {
603        b["oracle"] = oracle.clone();
604    }
605    b
606}
607
608/// The tick's record of a person's rulings on the task's tests:
609/// `Ruling: tests-wrong by <who> — <why>`.
610fn ruling_trailers(ruled: &[crate::acceptance_at::Ruling]) -> String {
611    ruled
612        .iter()
613        .map(|r| {
614            format!(
615                "Ruling: tests-wrong by {} — {}\n",
616                r.by,
617                r.reason.replace('\n', " ")
618            )
619        })
620        .collect()
621}
622
623/// The frozen test a `wrong_test` stop names (speccoder's own words:
624/// "frozen test `tests/x.py`").
625fn frozen_test_named(reason: &str) -> Option<String> {
626    Some(
627        reason
628            .split("frozen test `")
629            .nth(1)?
630            .split('`')
631            .next()?
632            .trim_start_matches("./")
633            .to_owned(),
634    )
635}