1use 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#[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
41pub 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#[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#[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#[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;
113mod open;
117mod ready;
118mod tests;
119mod tick;
120
121macro_rules! ok {
123 ($phase:expr) => {
124 match $phase {
125 Ok(x) => x,
126 Err(end) => return *end,
127 }
128 };
129}
130
131pub(super) struct Started {
133 pub(super) marker_guard: crate::resume::Guard,
135 pub(super) head_now: String,
137 pub(super) set_aside: Option<String>,
139}
140
141pub(super) struct Carried {
144 pub(super) head_now: String,
147 pub(super) marker_guard: crate::resume::Guard,
149 pub(super) set_aside: Option<String>,
151 pub(super) baseline: speccoder_core::verify::VerifyReport,
154 pub(super) notices: Vec<String>,
156}
157
158pub(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 let delivering = crate::hooks::deliver::Deliverer::start(path, state);
193 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 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 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
239pub const CHANGED_CODE: &str = "planning_changed_code";
242
243fn 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 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
300fn 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
321enum Collected {
327 Unknown(String),
329 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 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
421fn 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#[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 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
539fn 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
586fn 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
598fn 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
608fn 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
623fn 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}