1use std::path::Path;
4use std::sync::Arc;
5
6use speccoder_core::stage::{Stage, StageState};
7use speccoder_core::{CommandData, CommandResult, CommandStatus};
8
9use crate::pipeline::*;
10use crate::{
11 approvals, attempts, external, git, integrity, mcp, mutation, progress, resume, sandbox,
12 substrate, tools, verify, 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
111#[allow(clippy::too_many_lines)] pub(crate) fn run_task(
113 path: &Path,
114 feature: Option<&str>,
115 task: &str,
116 state: &Path,
117 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
118 secret: Option<&str>,
119 options: &RunOptions,
120) -> CommandResult {
121 use speccoder_core::RunReport;
122 if options.in_place && options.session.is_none() {
124 return in_session("run", path, feature, state, options, |o| {
125 run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
126 });
127 }
128 let approver = options.approver.clone();
129 use speccoder_core::snapshot::TaskState;
130
131 let refuse =
132 |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
133
134 let s = match setup(path, feature, state, "run") {
136 Ok(s) => s,
137 Err(r) => return *r,
138 };
139 let native = options.bug_home.clone();
141 let feature_name = match (&native, s.snapshot.feature.as_ref()) {
142 (Some(n), _) => n.unit.clone(),
143 (None, Some(f)) => f.feature.name.clone(),
144 (None, None) => {
145 return refuse(
146 CommandStatus::SelectionNeeded,
147 "no feature selected; pass --feature".to_owned(),
148 );
149 }
150 };
151 let plane_dir = match &options.lane {
154 Some(lane) => s.plane_dir.join("lanes").join(lane),
155 None => s.plane_dir.clone(),
156 };
157 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
159 &plane_dir,
160 s.model.prices,
161 options.progress.clone(),
162 ));
163
164 let wt = match &native {
166 Some(n) => crate::commands::worktree_of(&s.snapshot, &n.unit, &n.dir, state),
167 None => workspace(path, Some(&feature_name), state, options),
168 };
169 let Some(CommandData::Worktree(w)) = wt.data() else {
170 return CommandResult::carried("run", &wt);
171 };
172 if wt.status() != CommandStatus::Ok {
173 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
174 }
175 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
176 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
177 };
178 let (wt_path, project_dir) = match &options.lane {
179 None => (wt_path, project_dir),
180 Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
181 {
182 Ok(paths) => paths,
183 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
184 },
185 };
186 let project = std::path::PathBuf::from(&project_dir);
187 let wt_root = std::path::PathBuf::from(&wt_path);
188 super::bisect::prune(&wt_root, &s.plane_dir.join("tmp"));
190 let tmp = plane_dir.join("tmp");
191
192 let elsewhere = if options.lane.is_none() {
198 match crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task) {
199 Ok(e) => e,
200 Err(e) => {
201 return refuse(
202 CommandStatus::Error,
203 format!(
204 "the calls waiting for a person could not be read, so whether another task's attempt holds the worktree is unknown: {e}"
205 ),
206 );
207 }
208 }
209 } else {
210 crate::approvals::Elsewhere::default()
211 };
212 if let Some((other, covers)) = elsewhere.waiting {
213 return refuse(
214 CommandStatus::NeedsHuman,
215 format!(
216 "{other} waits for a person ({covers}) and its attempt holds the worktree: decide it (`speccoder task approve {other}` or `reject`), then `speccoder run --task {other}`"
217 ),
218 );
219 }
220 if options.lane.is_none()
222 && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
223 {
224 return refuse(
225 CommandStatus::NeedsHuman,
226 format!(
227 "{other}'s acceptance tests reject an implementation the spec appears to allow, and its attempt holds the worktree: `speccoder task rule {other} --tests-right --reason …` or `speccoder run --task {other} --fresh-acceptance`"
228 ),
229 )
230 .with_code("TESTS-OVER-STRICT");
231 }
232 let commits_per_task = verify::find_config(&project)
236 .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
237 .and_then(|t| substrate::run_config::parse(&t).ok())
238 .is_none_or(|c| c.commit_per_task);
239 let marker_path = resume::path(&plane_dir, &feature_name, task);
242 if options.fresh_acceptance && options.lane.is_none() {
245 let _ = std::fs::remove_file(&marker_path);
246 crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
247 }
248 let head_now = git::Git::at(&wt_root)
249 .line(&["rev-parse", "HEAD"])
250 .unwrap_or_default();
251 let autopilot = options.mode == speccoder_core::oracle::RunMode::Autopilot;
256 let interrupted = resume::load(&marker_path).filter(|m| {
257 m.head == head_now && options.lane.is_none() && !head_now.is_empty() && !autopilot
258 });
259 if let Some(m) = &interrupted {
262 if let (Some(id), Ok(mut run)) = (&m.run_id, options.run_id.lock()) {
263 *run = crate::pipeline::RunId(id.clone());
264 }
265 if let Ok(mut asked) = options.asked.lock() {
266 *asked = (*asked).max(m.asked);
267 }
268 if let Ok(mut g) = options.run_grants.lock() {
269 for o in &m.run_grants {
270 if !g.contains(o) {
271 g.push(o.clone());
272 }
273 }
274 }
275 }
276 let marker_guard = resume::Guard::new(marker_path.clone());
277 let resumed_from = interrupted.as_ref().map(|_| {
280 std::fs::read_to_string(plane_dir.join(progress::FILE))
281 .ok()
282 .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
283 .filter(|l| l.task == task)
284 .map_or((None, None), |l| (l.stage, l.last_call))
285 });
286 let mut set_aside = None;
287 if interrupted.is_none()
289 && commits_per_task
290 && options.session.is_none()
291 && worktree::require_clean(&wt_root).is_err()
292 {
293 let left = worktree::leftovers(&wt_root);
297 if !left.is_empty() {
298 let mut what = Vec::new();
299 if !left.commit.is_empty() {
300 what.push(format!("{} is not committed", left.commit.join(", ")));
301 }
302 if !left.ignore.is_empty() {
303 what.push(format!("{} is not git-ignored", left.ignore.join(", ")));
304 }
305 let checkout = std::path::Path::new(&s.snapshot.root);
306 let remedy = crate::code::setup_remedy(checkout, Some(&feature_name));
307 let command = remedy
308 .as_ref()
309 .map_or_else(|| left.fix(checkout, &wt_root, &[]), |r| r.command.clone());
310 return refuse(
311 CommandStatus::GitPrecondition,
312 format!(
313 "{} — the checks write it on every run, so each run would find it in the worktree and set it aside",
314 what.join(" and "),
315 ),
316 )
317 .with_code("SETUP-INCOMPLETE")
318 .with_fix(vec!["speccoder fix".to_owned(), command])
319 .with_remedy(remedy);
320 }
321 match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
322 Ok(reference) => set_aside = Some(reference),
323 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
324 }
325 }
326
327 let format = crate::format::at(&project);
329 let Some(snap) = format.inspect(
330 &project,
331 if native.is_some() {
332 None
333 } else {
334 Some(&feature_name)
335 },
336 ) else {
337 return refuse(
338 CommandStatus::NotAProject,
339 format.messages().no_project_in_worktree.to_owned(),
340 );
341 };
342 if native.is_some()
343 && crate::bug::state::load(&s.plane_dir, &feature_name, task)
344 .is_some_and(|b| b.outcome == speccoder_core::bug::Outcome::Fixed)
345 {
346 return refuse(
347 CommandStatus::TaskNotReady,
348 format!("{task} is already fixed"),
349 )
350 .with_code("TASK-DONE");
351 }
352 if native.is_none() {
355 let Some(st) = snap
356 .feature
357 .as_ref()
358 .and_then(|f| f.schedule.as_ref())
359 .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
360 else {
361 return refuse(
362 CommandStatus::NotFound,
363 format!("no task {task} in {feature_name}"),
364 );
365 };
366 match st.state {
367 TaskState::Done => {
368 return refuse(
369 CommandStatus::TaskNotReady,
370 format!("{task} is already done"),
371 )
372 .with_code("TASK-DONE");
373 }
374 TaskState::Blocked => {
375 return refuse(
376 CommandStatus::TaskNotReady,
377 format!("{task} is blocked by {}", st.blocked_by.join(", ")),
378 )
379 .with_code("TASK-BLOCKED");
380 }
381 TaskState::Ready => {}
382 }
383 }
384 let checklists_overridden = if native.is_some() {
387 false
388 } else {
389 match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
390 Ok(overridden) => overridden,
391 Err(stop) => return *stop,
392 }
393 };
394 let parsed_task = snap
395 .feature
396 .as_ref()
397 .and_then(|f| f.tasks.as_ref())
398 .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
399 let tasks_file = snap
400 .feature
401 .as_ref()
402 .and_then(|f| f.tasks.as_ref())
403 .map(|t| project.join(&t.path));
404
405 let bundle = match native
406 .as_ref()
407 .map(|n| n.bundle(&project))
408 .map_or_else(|| format.bundle(&snap, task), Ok)
409 {
410 Ok(b) => b,
411 Err(_) => {
412 return refuse(
413 CommandStatus::NotFound,
414 format!("no task {task} in the worktree's {feature_name}"),
415 );
416 }
417 };
418 let acceptance_story = native
421 .is_none()
422 .then(|| crate::acceptance_at::story_at(&s.plane_dir, &feature_name, task))
423 .flatten();
424 let story_task: Option<String> = acceptance_story.as_ref().and_then(|st| {
425 snap.feature
426 .as_ref()
427 .and_then(|f| f.tasks.as_ref())
428 .and_then(|t| {
429 t.tasks
430 .iter()
431 .find(|t| t.story.as_deref() == Some(st.as_str()))
432 })
433 .map(|t| t.id.clone())
434 });
435 let bundle = match (&acceptance_story, &story_task) {
436 (Some(st), Some(other)) => {
437 let mut b = bundle;
438 if let Ok(of) = format.bundle(&snap, other) {
439 b.story = of.story;
440 }
441 b.task.story.clone_from(st);
442 b
443 }
444 _ => bundle,
445 };
446 let Some(config_root) = verify::find_config(&project) else {
447 return refuse(
448 CommandStatus::ConfigError,
449 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
450 )
451 .with_code("CONFIG-MISSING");
452 };
453 let config_text =
454 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
455 let run_config = match substrate::run_config::parse(&config_text) {
456 Ok(c) => c,
457 Err(e) => {
458 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
459 .with_code("CONFIG-INVALID");
460 }
461 };
462 if run_config.review_required
465 && let Some(f) = snap.feature.as_ref()
466 && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
467 && !t.complete()
468 {
469 return refuse(
470 CommandStatus::NeedsHuman,
471 format!(
472 "{feature_name}'s spec is not approved yet ({} approved; {} to review, {} with changes requested, {} changed since, {} open question(s); committed review only) and [review] required is on",
473 t.approved, t.open, t.changes_requested, t.lapsed, t.questions
474 ),
475 )
476 .with_code("REVIEW-PENDING");
477 }
478 if run_config.review_required {
481 let ambiguous = match crate::oracle::open_ambiguities(
482 &s.plane_dir,
483 &feature_name,
484 &format.oracle_requirements(&snap),
485 ) {
486 Ok(a) => a,
487 Err(t) => {
488 return refuse(
489 CommandStatus::NeedsHuman,
490 format!(
491 "{}: whether it is read two ways is unknown and [review] required is on",
492 t.0
493 ),
494 )
495 .with_code("ORACLE-TAMPERED");
496 }
497 };
498 if let Some((req, scenario)) = ambiguous.first() {
499 return refuse(
500 CommandStatus::NeedsHuman,
501 format!(
502 "{req} is read two ways ({scenario}) and [review] required is on: choose a reading or reword it first"
503 ),
504 )
505 .with_code("REVIEW-AMBIGUOUS")
506 .with_fix(vec![
507 format!("speccoder review tests {req} --reading A {scenario}"),
508 format!("speccoder review tests {req} --reading B {scenario}"),
509 ]);
510 }
511 }
512 let executor = match external::parse(&config_text) {
513 Ok(e) => e,
514 Err(e) => {
515 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
516 .with_code("CONFIG-INVALID");
517 }
518 };
519 let sandbox_config = match sandbox::parse(&config_text) {
521 Ok(c) => c,
522 Err(e) => {
523 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
524 .with_code("CONFIG-INVALID");
525 }
526 };
527 let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
528 Ok(r) => r,
529 Err(e) => return refuse(CommandStatus::ConfigError, e),
530 };
531 if options.mode == speccoder_core::oracle::RunMode::Autopilot && !runner.is_sandboxed() {
533 return refuse(
534 CommandStatus::ConfigError,
535 "autopilot needs the OS sandbox, and commands here would run unsandboxed".to_owned(),
536 )
537 .with_code("SANDBOX-UNAVAILABLE");
538 }
539 let mutation_config = match mutation::parse(&config_text) {
540 Ok(c) => c,
541 Err(e) => {
542 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
543 .with_code("CONFIG-INVALID");
544 }
545 };
546 let worktree_config = match crate::prepare::parse(&config_text) {
547 Ok(c) => c,
548 Err(e) => {
549 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
550 .with_code("CONFIG-INVALID");
551 }
552 };
553 let check_argvs = verify::check_argvs(&config_root);
554 let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow)
555 .with_prefixes(run_config.allow_prefix.clone())
556 .rules(format.rules());
557 let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
560 {
561 Some(s) => s.confine(policy, runner),
562 None => (policy, runner, None),
563 };
564 let sandbox_notice = sandbox_notice.or(carve_notice);
565 if let Some(e) = options.session.as_ref().and_then(|s| {
568 s.lock().ok().and_then(|s| {
569 if interrupted.is_some() {
571 s.check_before_tick(&policy).err()
572 } else {
573 s.check(&policy).err()
574 }
575 })
576 }) {
577 return e.result("run");
578 }
579
580 let wt_root_canonical = std::fs::canonicalize(&wt_root)
581 .unwrap_or_else(|_| wt_root.clone())
582 .display()
583 .to_string();
584
585 let this_run = options.run();
588 let (decisions, task_grants) = match approvals::Decisions::load(&s.plane_dir) {
589 Ok(mut d) => {
590 if !autopilot
593 && d.claim(&feature_name, task, this_run.as_str())
594 && let Err(e) = d.save(&s.plane_dir)
595 {
596 return refuse(CommandStatus::Error, e);
597 }
598 (
599 d.for_task(&feature_name, task),
600 if autopilot {
601 Vec::new()
602 } else {
603 d.for_run(&feature_name, task, this_run.as_str())
604 },
605 )
606 }
607 Err(e) => return refuse(CommandStatus::ConfigError, e),
608 };
609
610 if let Some((stage, last_call)) = &resumed_from {
611 live.event(&progress::Event::Resumed {
612 task: task.to_owned(),
613 stage: *stage,
614 last_call: last_call.clone(),
615 });
616 }
617 live.event(&progress::Event::TaskStarted {
618 feature: feature_name.clone(),
619 task: task.to_owned(),
620 title: parsed_task
621 .as_ref()
622 .map(|t| t.title.clone())
623 .unwrap_or_default(),
624 });
625 let fail = |status: CommandStatus, m: String| {
627 live.event(&progress::Event::TaskFinished {
628 task: task.to_owned(),
629 outcome: "error".to_owned(),
630 message: redact(&m, secret),
631 run: None,
632 input_tokens: 0,
633 output_tokens: 0,
634 cache_read_tokens: 0,
635 cache_write_tokens: 0,
636 usd: None,
637 regression: None,
638 });
639 refuse(status, m)
640 };
641 let mut prepare_notes: Vec<String> = Vec::new();
645 if options.session.is_none() {
646 match crate::include::copy(
647 &wt_root,
648 &project,
649 Path::new(&s.snapshot.root),
650 &worktree_config.include,
651 ) {
652 Ok(c) => {
653 if !c.now.is_empty() {
654 prepare_notes.push(format!(
655 "copied from your checkout ([worktree] include): {}",
656 c.now.join(", ")
657 ));
658 }
659 prepare_notes.extend(
660 c.skipped
661 .iter()
662 .map(|n| format!("not copied ([worktree] include): {n}")),
663 );
664 for n in &prepare_notes {
665 live.event(&progress::Event::Notice { text: n.clone() });
666 }
667 }
668 Err(e) => {
669 return fail(
670 CommandStatus::ConfigError,
671 format!("the files [worktree] include names could not be copied: {e}"),
672 )
673 .with_code("PREPARE-FAILED");
674 }
675 }
676 }
677 let prepares =
678 options.session.is_none() && worktree_config.prepare && crate::prepare::needed(&project);
679 if prepares {
680 let began = std::time::Instant::now();
681 stage(
682 &*live,
683 task,
684 Stage::Prepare,
685 StageState::Started,
686 None,
687 None,
688 );
689 let prepared = crate::prepare::prepare(&crate::prepare::Prepare {
690 worktree: &wt_root,
691 project: &project,
692 config: &worktree_config,
693 runner: &runner,
694 state: Some(state),
695 });
696 match prepared {
697 Ok(None) => {}
698 Ok(Some(p)) => {
699 stage(
700 &*live,
701 task,
702 Stage::Prepare,
703 if p.done.is_empty() {
704 StageState::Skipped
705 } else {
706 StageState::Passed
707 },
708 Some(if p.done.is_empty() {
709 "reused: the lockfiles are unchanged".to_owned()
710 } else {
711 p.done.join(", ")
712 }),
713 Some(began),
714 );
715 }
716 Err(f) => {
717 stage(
718 &*live,
719 task,
720 Stage::Prepare,
721 StageState::Failed,
722 Some(f.message.clone()),
723 Some(began),
724 );
725 return fail(CommandStatus::ConfigError, f.message)
726 .with_code("PREPARE-FAILED")
727 .with_fix(f.fix);
728 }
729 }
730 } else if options.session.is_none() {
731 crate::prepare::forget(&wt_root);
733 }
734
735 let began = std::time::Instant::now();
737 stage(
738 &*live,
739 task,
740 Stage::Baseline,
741 StageState::Started,
742 None,
743 None,
744 );
745 let baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
746 Ok(b) => b,
747 Err(e) => {
748 stage(
749 &*live,
750 task,
751 Stage::Baseline,
752 StageState::Failed,
753 None,
754 Some(began),
755 );
756 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
757 }
758 };
759 if !options.allow_failing_baseline
764 && let Some((c, why)) = baseline
765 .checks
766 .iter()
767 .find_map(|c| broken(c).map(|w| (c, w)))
768 {
769 stage(
770 &*live,
771 task,
772 Stage::Baseline,
773 StageState::Failed,
774 Some(format!("`{}` cannot run", c.name)),
775 Some(began),
776 );
777 let checkout = std::path::Path::new(&s.snapshot.root);
780 if let Some(remedy) = crate::code::setup_remedy(checkout, Some(&feature_name)) {
781 return fail(
782 CommandStatus::GitPrecondition,
783 format!(
784 "check `{}` cannot run in the worktree: your checkout has not committed its setup — {why}",
785 c.name
786 ),
787 )
788 .with_code("SETUP-INCOMPLETE")
789 .with_fix(vec!["speccoder fix".to_owned(), remedy.command.clone()])
790 .with_remedy(Some(remedy));
791 }
792 return fail(
793 CommandStatus::ConfigError,
794 if why.contains("git add") || why.contains("--allow-failing-baseline") {
797 format!(
798 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why}",
799 c.name
800 )
801 } else {
802 format!(
803 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why} — fix the check (a file it needs may be uncommitted: `git status`), or run with --allow-failing-baseline",
804 c.name
805 )
806 },
807 )
808 .with_code("CHECK-BROKEN");
809 }
810 let failing = baseline
812 .checks
813 .iter()
814 .filter(|c| c.status.is_failure())
815 .count();
816 stage(
817 &*live,
818 task,
819 Stage::Baseline,
820 StageState::Passed,
821 (failing > 0).then(|| format!("{failing} check(s) already failing")),
822 Some(began),
823 );
824
825 let rt = match tokio_runtime() {
826 Ok(rt) => rt,
827 Err(e) => return fail(CommandStatus::RunFailed, e),
828 };
829 let bundle_value = match serde_json::to_value(&bundle) {
830 Ok(v) => v,
831 Err(e) => return fail(CommandStatus::Error, e.to_string()),
832 };
833 let instructions = crate::instructions::collect(
837 &wt_root,
838 &project,
839 &speccoder_core::scope::named_paths(&bundle.task.title),
840 &run_config.instructions,
841 );
842 let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
843 &instructions
844 } else {
845 &[]
846 };
847
848 let servers = match mcp::parse(&config_text) {
850 Ok(s) => s,
851 Err(e) => {
852 return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
853 .with_code("CONFIG-INVALID");
854 }
855 };
856 let mut connected = if servers.is_empty() {
857 mcp::Connected::default()
858 } else {
859 live.event(&progress::Event::Notice {
860 text: format!("starting {} MCP server(s)", servers.len()),
861 });
862 rt.block_on(mcp::connect(&servers, &project, &runner))
863 };
864 let approved_tools: Vec<String> = task_grants
866 .iter()
867 .filter_map(|g| g.tool_name().map(str::to_owned))
868 .collect();
869 let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
870 connected.notices.extend(pinned);
871
872 let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
875 Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
876 Err(e) => return fail(CommandStatus::Error, e.to_string()),
877 };
878 let swept =
881 match crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc()) {
882 Ok(p) => p,
883 Err(e) => {
884 return fail(
885 CommandStatus::RunFailed,
886 format!("the calls past their deadline could not be denied: {e}"),
887 );
888 }
889 };
890 let present = crate::approvals::PresentContext {
892 root: None,
893 sandbox: runner.is_sandboxed().then(|| {
894 if cfg!(target_os = "macos") {
895 "Seatbelt".to_owned()
896 } else {
897 "Landlock+seccomp".to_owned()
898 }
899 }),
900 network: match sandbox_config.network {
901 sandbox::Network::Off => "none",
902 sandbox::Network::Loopback => "loopback",
903 sandbox::Network::On => "unrestricted",
904 }
905 .to_owned(),
906 file_text: None,
907 };
908 let plane = match rt.block_on(async {
909 substrate::Plane::open_with(
910 &plane_dir,
911 &s.model,
912 provider,
913 Some(tools_server),
914 substrate::Hooks {
915 progress: Some(Arc::clone(&live)),
916 mcp: connected.clients.clone(),
917 mcp_tools: connected.tools.clone(),
918 format: Some(format),
919 present: present.clone(),
920 },
921 )
922 }) {
923 Ok(p) => p,
924 Err(e) => return fail(CommandStatus::RunFailed, e.0),
925 };
926 let mut notices: Vec<String> = elsewhere
927 .replaced_journal
928 .iter()
929 .chain(swept.iter())
930 .chain(plane.replaced_journal.iter())
931 .map(|p| {
932 format!(
933 "the journal could not be read by this build and was moved to {}; past runs stay in git",
934 p.display()
935 )
936 })
937 .chain(connected.notices.iter().cloned())
938 .chain(sandbox_notice.clone())
939 .chain(prepare_notes.iter().cloned())
940 .chain(resumed_from.as_ref().map(|(stage, last_call)| {
941 format!(
942 "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
943 stage.map_or("a run", speccoder_core::stage::Stage::name),
944 last_call
945 .as_ref()
946 .map(|c| format!(" ({c})"))
947 .unwrap_or_default()
948 )
949 }))
950 .collect();
951
952 let demand_red = run_config.acceptance && format.is_convergence(&bundle.task.phase);
955 let start = match &interrupted {
961 Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
962 Ok(tree) => worktree::Checkpoint(tree),
963 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
964 },
965 None => match worktree::checkpoint(&wt_root, &tmp) {
966 Ok(c) => c,
967 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
968 },
969 };
970
971 let began = std::time::Instant::now();
972 let bug_ctx = super::bug::Ctx {
977 rt: &rt,
978 plane: &plane,
979 project: &project,
980 wt_root: &wt_root,
981 wt_path: &wt_path,
982 tmp: &tmp,
983 policy: &policy,
984 runner: &runner,
985 state: &s.plane_dir,
986 feature: &feature_name,
987 task,
988 bundle: &bundle_value,
989 instructions: &instructions,
990 live: &*live,
991 approver: approver.as_deref(),
992 held_out: run_config.held_out,
993 max_tries: run_config.reproduce_tries,
994 bug_test: run_config.bug_test.as_deref(),
995 start: &start,
996 };
997 let mut bug_fix = match super::bug::phase(&s.plane_dir, &feature_name, task, &project, format) {
998 Err(e) => return fail(CommandStatus::NeedsHuman, e),
999 Ok(None) => None,
1000 Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
1001 Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
1002 match super::bug::reproduce(&bug_ctx, *b) {
1003 Ok(f) => Some(f),
1004 Err(stop) => return *stop,
1005 }
1006 }
1007 Ok(Some(super::bug::Phase::Reproduce(_))) => None,
1008 };
1009 let bug_candidate = bug_fix.as_ref().and_then(|f| {
1010 f.bug.candidate.clone().and_then(|c| {
1011 crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
1012 .candidate_patch
1013 .map(|d| (d, c))
1014 })
1015 });
1016 let test_layout = crate::layout::detect(&project, &check_argvs);
1019 let test_layout_text = test_layout.text();
1020 let story_files = super::commit::story_files(&wt_root, &feature_name);
1021 let scenarios = match &bug_fix {
1022 Some(f) => super::bug::scenarios(&f.bug),
1023 None => format.scenarios(&snap, story_task.as_deref().unwrap_or(task)),
1024 };
1025 let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
1028 let mut review_reuse = None;
1029 if bug_fix.is_none() && run_config.acceptance && !options.fresh_acceptance {
1030 match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
1031 Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
1032 Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
1033 "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
1034 )),
1035 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
1036 notices.push(format!("{req}: frozen oracle not present here — authoring"))
1037 }
1038 Some(crate::oracle::TaskReuse::Lapsed {
1039 req,
1040 frozen,
1041 current,
1042 }) => {
1043 if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
1044 live.event(&progress::Event::Oracle {
1045 step: progress::OracleEvent::Lapsed {
1046 unit: feature_name.clone(),
1047 requirement: req.clone(),
1048 frozen_wording_sha256: frozen,
1049 current_wording_sha256: current,
1050 },
1051 });
1052 }
1053 notices.push(format!(
1054 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
1055 ));
1056 }
1057 Some(crate::oracle::TaskReuse::Tampered(why)) => {
1058 return fail(
1059 CommandStatus::NeedsHuman,
1060 format!("{why}: the task stops for a person, nothing is ticked"),
1061 )
1062 .with_code("ORACLE-TAMPERED");
1063 }
1064 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
1065 return fail(
1066 CommandStatus::NeedsHuman,
1067 format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
1068 )
1069 .with_code("REVIEW-UNREADABLE");
1070 }
1071 None => {}
1072 }
1073 }
1074 let mut acceptance = if let Some(prepared) = interrupted
1075 .as_ref()
1076 .and_then(|m| m.acceptance.clone())
1077 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
1078 {
1079 let mut prepared = prepared;
1083 if let Some(sealed) = bug_fix
1084 .as_ref()
1085 .and_then(|f| f.record.held_out_sealed.as_ref())
1086 {
1087 prepared.held_out = Some(sealed.manifest_sha256.clone());
1088 prepared.report["held_out"] = serde_json::json!({
1089 "state": "on",
1090 "manifest": format!("sha256:{}", sealed.manifest_sha256),
1091 "kept": sealed.count,
1092 });
1093 }
1094 Some(prepared)
1095 } else if let Some(f) = &bug_fix {
1096 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1097 match super::bug::reuse(&bug_ctx, f) {
1098 Ok(a) => Some(a),
1099 Err(e) => {
1100 let _ = worktree::restore(&wt_root, &tmp, &start);
1101 stage(
1102 &*live,
1103 task,
1104 Stage::Red,
1105 StageState::Failed,
1106 Some(e.clone()),
1107 Some(began),
1108 );
1109 return fail(CommandStatus::NeedsHuman, e);
1110 }
1111 }
1112 } else if let Some(reuse) = review_reuse.take() {
1113 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1116 let crate::oracle::TaskReuse::Ready {
1117 record,
1118 files,
1119 tests,
1120 manifest,
1121 } = reuse
1122 else {
1123 return fail(CommandStatus::Error, "no frozen oracle".to_owned());
1124 };
1125 match reuse_pre_frozen(
1126 &project,
1127 &record,
1128 &files,
1129 &tests,
1130 &runner,
1131 RedRule::RunTime,
1132 Some(&SealedCheck {
1133 state: &s.plane_dir,
1134 unit: &feature_name,
1135 wt_root: &wt_root,
1136 tmp: &tmp,
1137 policy: &policy,
1138 }),
1139 ) {
1140 Ok(mut a) => {
1141 a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
1142 notices.push(format!(
1143 "acceptance tests frozen at review by {} ({}) for {}: no author call",
1144 record.decided.by, record.decided.via, record.requirement
1145 ));
1146 review_frozen = Some((*record, manifest));
1147 Some(a)
1148 }
1149 Err(e) => {
1150 let _ = worktree::restore(&wt_root, &tmp, &start);
1151 stage(
1152 &*live,
1153 task,
1154 Stage::Red,
1155 StageState::Failed,
1156 Some(e.clone()),
1157 Some(began),
1158 );
1159 return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
1160 }
1161 }
1162 } else if run_config.acceptance && format.acceptance_testable(&bundle) {
1163 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1164 Some(prepare_acceptance(&AcceptanceSetup {
1165 rt: &rt,
1166 plane: &plane,
1167 project: &project,
1168 worktree: &wt_root,
1169 tmp: &tmp,
1170 policy: &policy,
1171 feature: &feature_name,
1172 task,
1173 bundle: bundle_value.clone(),
1174 scenarios: &scenarios,
1175 instructions: &instructions,
1176 runner: &runner,
1177 layout: Some(&test_layout),
1178 story_files: &story_files,
1179 }))
1180 } else {
1181 None
1182 };
1183 if review_frozen.is_none()
1184 && acceptance
1185 .as_ref()
1186 .is_some_and(|a| a.report["source"] == "review")
1187 && let Some(crate::oracle::TaskReuse::Ready {
1188 record, manifest, ..
1189 }) = review_reuse.take()
1190 {
1191 review_frozen = Some((*record, manifest));
1192 }
1193 let held_out_store = match (&bug_fix, &review_frozen) {
1196 (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1197 (None, Some((r, _))) => {
1198 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1199 }
1200 (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1201 };
1202 if let Some(a) = acceptance
1203 .as_mut()
1204 .filter(|a| a.report.get("held_out").is_none())
1205 {
1206 if !run_config.held_out {
1207 a.report["held_out"] = serde_json::json!({ "state": "off" });
1208 } else if a.argv.is_none() {
1209 a.report["held_out"] =
1210 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1211 } else if !a.red {
1212 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1213 } else if scenarios.is_empty() {
1214 a.report["held_out"] =
1215 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1216 } else {
1217 let (held, sha) = prepare_held_out(
1218 &AcceptanceSetup {
1219 rt: &rt,
1220 plane: &plane,
1221 project: &project,
1222 worktree: &wt_root,
1223 tmp: &tmp,
1224 policy: &policy,
1225 feature: &feature_name,
1226 task,
1227 bundle: bundle_value.clone(),
1228 scenarios: &scenarios,
1229 instructions: &instructions,
1230 runner: &runner,
1231 layout: Some(&test_layout),
1232 story_files: &story_files,
1233 },
1234 a,
1235 &scenarios,
1236 &held_out_store,
1237 story_checkpoint(
1238 snap.feature.as_ref().and_then(|f| f.tasks.as_ref()),
1239 task,
1240 &scenarios,
1241 ),
1242 );
1243 a.report["held_out"] = held;
1244 a.held_out = sha;
1245 }
1246 }
1247 if run_config.held_out && !run_config.acceptance {
1248 notices.push(
1249 "[acceptance] held_out has no effect while generate = false: no held-out tests"
1250 .to_owned(),
1251 );
1252 }
1253 match &acceptance {
1254 None => stage(
1255 &*live,
1256 task,
1257 Stage::Red,
1258 StageState::Skipped,
1259 Some("no acceptance examples".to_owned()),
1260 None,
1261 ),
1262 Some(a) if a.red => stage(
1263 &*live,
1264 task,
1265 Stage::Red,
1266 StageState::Passed,
1267 a.red_class.map(class_word),
1268 Some(began),
1269 ),
1270 Some(a) => stage(
1271 &*live,
1272 task,
1273 Stage::Red,
1274 StageState::Failed,
1275 Some(a.red_class.map_or_else(
1276 || "not demonstrated".to_owned(),
1277 |c| format!("not demonstrated ({})", class_word(c)),
1278 )),
1279 Some(began),
1280 ),
1281 }
1282
1283 if let Some(why) = acceptance
1287 .as_ref()
1288 .and_then(|a| a.generation_failed.clone())
1289 {
1290 let reason = format!(
1291 "the acceptance tests could not be written ({why}); nothing was attempted — run again once the model answers, or set [acceptance] generate = false to rely on the checks alone"
1292 );
1293 let _ = worktree::restore(&wt_root, &tmp, &start);
1294 let report = RunReport {
1295 reason: Some(reason.clone()),
1296 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1297 set_aside,
1298 notices: notices.clone(),
1299 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1300 };
1301 live.event(&finished_event(
1302 task,
1303 &report,
1304 "acceptance_failed",
1305 false,
1306 redact(&reason, secret),
1307 ));
1308 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1309 .with_code("ACCEPTANCE-UNWRITTEN")
1310 .with_data(CommandData::Run(Box::new(report)));
1311 }
1312
1313 if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
1314 let why = undemonstrated_why(acceptance.as_ref());
1315 let reason = format!(
1316 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1317 );
1318 let saved = attempts::save(
1320 &wt_root,
1321 &tmp,
1322 &feature_name,
1323 task,
1324 &attempts::Record {
1325 run: None,
1326 outcome: "not_demonstrated",
1327 reason: &reason,
1328 pending: None,
1329 usage: None,
1330 },
1331 )
1332 .ok()
1333 .map(|s| s.reference);
1334 let _ = worktree::restore(&wt_root, &tmp, &start);
1335 let report = RunReport {
1336 reason: Some(reason.clone()),
1337 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1338 saved_attempt: saved,
1339 set_aside,
1340 notices: notices.clone(),
1341 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1342 };
1343 live.event(&finished_event(
1344 task,
1345 &report,
1346 "not_demonstrated",
1347 false,
1348 redact(&reason, secret),
1349 ));
1350 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1351 .with_data(CommandData::Run(Box::new(report)));
1352 }
1353
1354 let before = match &interrupted {
1357 Some(m) => worktree::Checkpoint(m.before.clone()),
1358 None => match worktree::checkpoint(&wt_root, &tmp) {
1359 Ok(c) => c,
1360 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1361 },
1362 };
1363 let acceptance_saved = acceptance
1364 .as_ref()
1365 .and_then(|a| serde_json::to_value(a).ok());
1366 let frozen: Vec<String> = worktree::files(&wt_root, &before)
1368 .unwrap_or_default()
1369 .into_iter()
1370 .filter(|p| tools::paths::is_test_path(p))
1371 .collect();
1372 let mut usage: Option<serde_json::Value> = None;
1373 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1376 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1378 let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1380 let mut refused_account = false;
1382 let (mut report, attempt_status) = loop {
1383 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1384 let mut digest: Option<serde_json::Value> = None;
1385 let mut attempt_no = interrupted
1388 .as_ref()
1389 .filter(|m| m.sample == samples.len())
1390 .map_or(1, |m| m.attempt.max(1));
1391 let (mut report, attempt_status) = loop {
1392 let hidden: Vec<String> = options
1394 .session
1395 .as_ref()
1396 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1397 .unwrap_or_default();
1398 let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1404 Ok(_) if autopilot => (Vec::new(), Vec::new()),
1407 Ok(mut d) => {
1408 let once = d.take_once(&feature_name, task, this_run.as_str());
1409 if !once.is_empty()
1410 && let Err(e) = d.save(&s.plane_dir)
1411 {
1412 return fail(CommandStatus::Error, e);
1413 }
1414 (d.for_run(&feature_name, task, this_run.as_str()), once)
1415 }
1416 Err(e) => return fail(CommandStatus::ConfigError, e),
1417 };
1418 let run_approved: Vec<crate::tools::policy::Object> = options
1420 .run_grants
1421 .lock()
1422 .map(|g| g.clone())
1423 .unwrap_or_default();
1424 let input = substrate::attempt::input(
1426 bundle_value.clone(),
1427 &substrate::attempt::Settings {
1428 checks: &check_argvs,
1429 allow: &run_config.allow,
1430 allow_prefix: &run_config.allow_prefix,
1431 max_turns: run_config.max_turns,
1432 max_tool_calls: run_config.max_tool_calls,
1433 reasoning: s.model.reasoning_summary,
1434 frozen: &frozen,
1435 approved: &task_grants,
1436 rejected: &decisions.rejected,
1437 run_approved: &run_approved,
1438 approved_once: &approved_once,
1439 nudges: &decisions.nudges,
1440 mcp: &connected.tools,
1441 root: &wt_root_canonical,
1442 frozen_additions: run_config.frozen_additions,
1443 asks: if options.lane.is_some() {
1446 "stop"
1447 } else {
1448 "tasks"
1449 },
1450 wait_secs: run_config.approval_wait.as_secs(),
1451 network_allow: &sandbox_config.network_allow,
1452 network_allow_prefix: &sandbox_config.network_allow_prefix,
1453 run_id: this_run.as_str(),
1454 asked: options.asked_now(),
1455 budget: run_config.approval_budget,
1456 prompts: approver.is_some() && options.lane.is_none(),
1457 in_place: options.session.is_some(),
1458 hidden: &hidden,
1459 autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1460 },
1461 digest.as_ref(),
1462 attempt_instructions,
1463 (!test_layout.runners.is_empty()).then_some(test_layout_text.as_str()),
1465 );
1466 let attempt_began = std::time::Instant::now();
1467 let attempt_detail = if run_config.best_of > 1 {
1468 format!(
1469 "{attempt_no}/{} · sample {}/{}",
1470 run_config.max_attempts,
1471 samples.len() + 1,
1472 run_config.best_of
1473 )
1474 } else {
1475 format!("{attempt_no}/{}", run_config.max_attempts)
1476 };
1477 stage(
1478 &*live,
1479 task,
1480 Stage::Attempt,
1481 StageState::Started,
1482 Some(attempt_detail.clone()),
1483 None,
1484 );
1485 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1488 let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1491 let held: Vec<String> = bug_fix
1492 .as_ref()
1493 .and_then(|f| {
1494 f.bug
1495 .held_out_sealed
1496 .clone()
1497 .map(|h| (f.bug.unit.clone(), h))
1498 })
1499 .and_then(|(unit, h)| {
1500 crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1501 })
1502 .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1503 .unwrap_or_default();
1504 match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1505 Ok(x) => x,
1506 Err(stop) => return *stop,
1507 }
1508 } else if let Some(exec) = &executor {
1509 let allow: Vec<Vec<String>> = check_argvs
1510 .iter()
1511 .chain(&run_config.allow)
1512 .cloned()
1513 .collect();
1514 let prompt = external::prompt_in(format.wording(), &bundle_value, digest.as_ref());
1515 let withheld = acceptance
1516 .as_ref()
1517 .and_then(|a| a.held_out.as_ref())
1518 .map(|_| held_out_store.as_path());
1519 if withheld.is_some()
1520 && !notices
1521 .iter()
1522 .any(|n| n.starts_with("held-out set withheld"))
1523 {
1524 notices.push("held-out set withheld from the external agent, not sealed: it runs with your permissions; a copied held-out literal stops the tick".to_owned());
1525 }
1526 let o = external::run(exec, &wt_root, &prompt, &allow, &tmp, withheld);
1527 (o.run_id, true, o.output)
1528 } else {
1529 let key = match &interrupted {
1531 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
1532 m.key.clone()
1533 }
1534 _ => resume::new_key(&feature_name, task),
1535 };
1536 let _ = resume::save(
1537 &marker_path,
1538 &resume::Marker {
1539 key: key.clone(),
1540 before: before.0.clone(),
1541 head: head_now.clone(),
1542 attempt: attempt_no,
1543 sample: samples.len(),
1544 acceptance: acceptance_saved.clone(),
1545 run_id: Some(this_run.to_string()),
1546 asked: options.asked_now(),
1547 run_grants: run_approved.clone(),
1548 },
1549 );
1550 marker_guard.arm();
1551 let outcome = match rt.block_on(plane.attempt_keyed(
1552 &feature_name,
1553 task,
1554 input,
1555 &key,
1556 )) {
1557 Ok((o, resumed)) => {
1558 if resumed {
1559 notices.push(format!(
1560 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
1561 o.run_id
1562 ));
1563 }
1564 o
1565 }
1566 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1567 };
1568 let context = crate::approvals::PresentContext {
1572 root: Some(wt_root.clone()),
1573 ..present.clone()
1574 };
1575 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
1576 outcome,
1577 approver.as_deref(),
1578 &context,
1579 )) {
1580 Ok(x) => {
1581 notices.extend(crate::actions::allow_always_inline(
1584 path,
1585 &feature_name,
1586 &x.always,
1587 state,
1588 ));
1589 (x.outcome, x.waiting)
1590 }
1591 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1592 };
1593 if let Some(task_row) = waiting {
1594 marker_guard.keep();
1597 let action = &task_row.justification.proposed_action;
1598 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
1599 report.run_id = outcome.run_id.to_string();
1600 report.notices = notices;
1601 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
1602 report.tool_calls.push(serde_json::json!({
1604 "tool": action["tool"],
1605 "arguments": action["arguments"],
1606 "decision": "approval",
1607 "reason": action["request"]["reason"],
1608 "object": action["request"]["object"],
1609 "waiting": true,
1610 "call": task_row.id.to_hex(),
1611 }));
1612 report.pending = Some(serde_json::json!({
1613 "tool": action["tool"],
1614 "arguments": action["arguments"],
1615 "reason": action["request"]["reason"],
1616 "object": action["request"]["object"],
1617 "request": action["request"],
1618 "call": task_row.id.to_hex(),
1619 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
1620 "waiting": true,
1621 "batched": action["request"]["batched"],
1622 }));
1623 if let (Some(n), Ok(mut asked)) = (
1626 action["request"]["asked"]
1627 .as_u64()
1628 .and_then(|n| u32::try_from(n).ok()),
1629 options.asked.lock(),
1630 ) {
1631 *asked = (*asked).max(n);
1632 }
1633 report.reason = Some(format!(
1634 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
1635 task_row.justification.summary.peek()
1636 ));
1637 let (status, message) = status_and_message(&report, "needs_approval", false);
1638 live.event(&finished_event(
1639 task,
1640 &report,
1641 "needs_approval",
1642 false,
1643 redact(&message, secret),
1644 ));
1645 return CommandResult::new("run", status, &redact(&message, secret))
1646 .with_data(CommandData::Run(Box::new(report)));
1647 }
1648 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
1649 refused_account = outcome
1650 .failed_effect
1651 .as_ref()
1652 .is_some_and(agentplane::journal::FailedEffect::refuses_the_account);
1653 let mut output = outcome
1654 .output
1655 .as_ref()
1656 .map(|o| o.peek().clone())
1657 .unwrap_or(serde_json::Value::Null);
1658 if !succeeded && output["reason"].as_str().is_none() {
1659 if !output.is_object() {
1660 output = serde_json::json!({});
1661 }
1662 let reason = if rt.block_on(plane.holds_a_pre_054_decision(outcome.run_id)) {
1665 substrate::PRE_054_DECISION.to_owned()
1666 } else {
1667 format!("{:?}", outcome.status)
1668 };
1669 output["reason"] = serde_json::Value::String(redact(&reason, secret));
1670 }
1671 (outcome.run_id.to_string(), succeeded, output)
1672 };
1673 if bug_candidate.is_some()
1674 && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
1675 {
1676 c.applied = true;
1677 }
1678 if let Some(argv) = run_config.format.as_ref().filter(|_| {
1681 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
1682 }) {
1683 use verify::process::CommandRunner as _;
1684 let ran = runner.run(&verify::process::Invocation {
1685 argv: argv.clone(),
1686 cwd: project.clone(),
1687 env: verify::process::scrubbed_env(std::env::vars()),
1688 timeout: std::time::Duration::from_secs(300),
1689 output_cap: verify::process::OUTPUT_CAP,
1690 });
1691 if ran.exit_code != Some(0) {
1692 notices.push(format!(
1693 "the formatter (`{}`) did not succeed",
1694 argv.join(" ")
1695 ));
1696 }
1697 }
1698 if prepares {
1702 let began = std::time::Instant::now();
1703 match crate::prepare::recheck(&crate::prepare::Prepare {
1704 worktree: &wt_root,
1705 project: &project,
1706 config: &worktree_config,
1707 runner: &runner,
1708 state: Some(state),
1709 }) {
1710 Ok(done) if done.is_empty() => {}
1711 Ok(done) => {
1712 stage(
1713 &*live,
1714 task,
1715 Stage::Prepare,
1716 StageState::Passed,
1717 Some(format!(
1718 "{}: the attempt changed its lockfile",
1719 done.join(", ")
1720 )),
1721 Some(began),
1722 );
1723 }
1724 Err(f) => {
1725 stage(
1726 &*live,
1727 task,
1728 Stage::Prepare,
1729 StageState::Failed,
1730 Some(f.message.clone()),
1731 Some(began),
1732 );
1733 let reason = redact(&f.message, secret);
1737 let saved = attempts::save(
1738 &wt_root,
1739 &tmp,
1740 &feature_name,
1741 task,
1742 &attempts::Record {
1743 run: (!run_id.is_empty()).then_some(run_id.as_str()),
1744 outcome: "prepare_failed",
1745 reason: &reason,
1746 pending: None,
1747 usage: output.get("usage"),
1748 },
1749 )
1750 .and_then(|saved| {
1751 worktree::restore(&wt_root, &tmp, &before).map(|_| saved)
1752 });
1753 let message = match saved {
1754 Ok(saved) => format!(
1755 "{} — the attempt is saved as {} and undone",
1756 f.message, saved.reference
1757 ),
1758 Err(e) => format!("{} — saving the attempt failed: {}", f.message, e.0),
1759 };
1760 return fail(CommandStatus::ConfigError, message)
1761 .with_code("PREPARE-FAILED")
1762 .with_fix(f.fix);
1763 }
1764 }
1765 }
1766 let after = match worktree::checkpoint(&wt_root, &tmp) {
1767 Ok(c) => c,
1768 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1769 };
1770 let raw_changes = worktree::diff(&wt_root, &before, &after).unwrap_or_default();
1771 if let (Some(n), Ok(mut asked)) = (
1776 output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
1777 options.asked.lock(),
1778 ) {
1779 *asked = n;
1780 }
1781 let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
1782 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1783 if let Some(name) = crate::include::added_secret(&patch) {
1786 let _ = worktree::restore(&wt_root, &tmp, &before);
1787 return fail(
1788 CommandStatus::NeedsHuman,
1789 format!(
1790 "the attempt's change adds the value of {name}, copied from your checkout ([worktree] include): undone, nothing judged or committed"
1791 ),
1792 )
1793 .with_code("SECRET-IN-DIFF");
1794 }
1795 let findings = attempt_findings(&Audited {
1796 wt_root: &wt_root,
1797 policy: &policy,
1798 judged_before: &judged_before,
1799 raw_changes: &raw_changes,
1800 patch: &patch,
1801 frozen: &frozen,
1802 frozen_additions: run_config.frozen_additions,
1803 approved_writes: &approved_writes,
1804 command_drift: &output["integrity"],
1805 });
1806 let changes = changes_of(raw_changes);
1807
1808 let quarantined = !succeeded
1813 && output["reason"]
1814 .as_str()
1815 .is_some_and(|r| r.starts_with("Quarantined("));
1816 if quarantined {
1817 output["reason"] = serde_json::Value::String(
1818 "the attempt was interrupted while a tool call was running; whether it finished cannot be known, so it was not run again. The attempt is saved and undone; run again to start a fresh attempt".to_owned(),
1819 );
1820 }
1821 let mut attempt_status = if succeeded {
1822 output["status"].as_str().unwrap_or("failed").to_owned()
1823 } else if quarantined {
1824 "interrupted".to_owned()
1825 } else {
1826 "failed".to_owned()
1827 };
1828 let mut report = RunReport {
1829 run_id: run_id.clone(),
1830 model_report: output.get("report").cloned(),
1831 reason: output["reason"].as_str().map(str::to_owned),
1832 pending: output.get("pending").cloned(),
1833 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
1834 changes,
1835 usage: output.get("usage").cloned(),
1836 integrity: findings.clone(),
1837 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1838 set_aside: set_aside.clone(),
1839 notices: notices.clone(),
1840 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
1841 };
1842 if let Err(e) = record_inline_approvals(
1843 &s.plane_dir,
1844 &feature_name,
1845 task,
1846 &report.tool_calls,
1847 &options.run_grants,
1848 this_run.as_str(),
1849 ) {
1850 report.notices.push(format!(
1851 "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
1852 ));
1853 }
1854 for c in report
1856 .tool_calls
1857 .iter()
1858 .filter(|c| c["approved_by"] == "person")
1859 {
1860 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
1861 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
1862 }
1863 }
1864
1865 stage(
1866 &*live,
1867 task,
1868 Stage::Attempt,
1869 if attempt_status == "completed" {
1870 StageState::Passed
1871 } else {
1872 StageState::Failed
1873 },
1874 Some(if attempt_status == "completed" {
1875 attempt_detail
1876 } else {
1877 format!("{attempt_detail} {attempt_status}")
1878 }),
1879 Some(attempt_began),
1880 );
1881 if attempt_status == "integrity" {
1884 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1885 reasons: findings.clone(),
1886 });
1887 }
1888 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
1890 if judged {
1891 let began = std::time::Instant::now();
1892 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
1893 if let Err(e) = judge_checks(
1894 &project,
1895 &runner,
1896 &baseline,
1897 acceptance.as_ref(),
1898 &findings,
1899 &mut report,
1900 ) {
1901 stage(
1902 &*live,
1903 task,
1904 Stage::Verify,
1905 StageState::Failed,
1906 None,
1907 Some(began),
1908 );
1909 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
1910 }
1911 let otherwise_passing = attempt_status == "completed"
1915 && findings.is_empty()
1916 && matches!(
1917 report.verdict,
1918 Some(
1919 speccoder_core::verify::Verdict::Verified
1920 | speccoder_core::verify::Verdict::RegressionOnly
1921 )
1922 );
1923 match judge_collection(
1924 (&project, &wt_root, &tmp),
1925 &runner,
1926 otherwise_passing,
1927 &mut report,
1928 &*live,
1929 task,
1930 ) {
1931 Some(Collected::Unknown(why)) => {
1932 attempt_status = "collection_unknown".to_owned();
1933 report.verdict = None;
1934 report.reason = Some(why);
1935 }
1936 Some(Collected::Changed(finding)) => {
1938 report.integrity.push(finding.clone());
1939 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1940 reasons: vec![finding],
1941 });
1942 attempt_status = "integrity".to_owned();
1943 }
1944 None => {}
1945 }
1946 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
1947 stage_with_tail(
1948 &*live,
1949 task,
1950 Stage::Verify,
1951 if checks_held {
1952 StageState::Passed
1953 } else {
1954 StageState::Failed
1955 },
1956 None,
1957 Some(began),
1958 report
1959 .verification
1960 .as_ref()
1961 .map(failing_tail)
1962 .unwrap_or_default(),
1963 );
1964 }
1965 if judged || attempt_status == "integrity" {
1966 stage(
1967 &*live,
1968 task,
1969 Stage::Audit,
1970 if findings.is_empty() {
1971 StageState::Passed
1972 } else {
1973 StageState::Failed
1974 },
1975 findings
1976 .first()
1977 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
1978 None,
1979 );
1980 }
1981
1982 let visibly_passed = judged
1986 && findings.is_empty()
1987 && attempt_status == "completed"
1988 && matches!(
1989 report.verdict,
1990 Some(
1991 speccoder_core::verify::Verdict::Verified
1992 | speccoder_core::verify::Verdict::RegressionOnly
1993 )
1994 );
1995 if let Some(sha) = acceptance
1996 .as_ref()
1997 .and_then(|a| a.held_out.clone())
1998 .filter(|_| visibly_passed)
1999 {
2000 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2001 let mut known = format!(
2002 "{}\n{}\n{}",
2003 bundle_value["story"].as_str().unwrap_or_default(),
2004 bundle_value["plan"].as_str().unwrap_or_default(),
2005 bundle_value["constitution"].as_str().unwrap_or_default()
2006 );
2007 for p in report
2008 .acceptance
2009 .as_ref()
2010 .and_then(|a| a["files"].as_array().cloned())
2011 .unwrap_or_default()
2012 {
2013 if let Some(p) = p.as_str() {
2014 known.push_str(
2015 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2016 );
2017 }
2018 }
2019 match judge_held_out(
2020 &held_out_store,
2021 &sha,
2022 &scenarios,
2023 &known,
2024 &wt_root,
2025 &project,
2026 &tmp,
2027 &before,
2028 &patch,
2029 &runner,
2030 &policy,
2031 ) {
2032 Ok(j) => {
2033 let failed_ids: Vec<String> =
2034 j.failed.iter().map(|s| s.id.clone()).collect();
2035 held_out_log.push(serde_json::json!({
2036 "attempt": held_out_log.len() + 1,
2037 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
2038 "scenarios": failed_ids,
2039 }));
2040 if let Some(v) = report.verdict.take() {
2041 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
2042 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
2043 && !j.failed.is_empty()
2044 && let Some(last) = reasons.last_mut()
2045 {
2046 *last = speccoder_core::retry::held_out_reason(&j.failed);
2047 }
2048 report.verdict = Some(v);
2049 }
2050 }
2051 Err((outcome, why)) => {
2052 held_out_log.push(serde_json::json!({
2053 "attempt": held_out_log.len() + 1,
2054 "outcome": outcome,
2055 }));
2056 report.verdict = None;
2057 report.reason = Some(why);
2058 attempt_status = "held_out".to_owned();
2059 }
2060 }
2061 }
2062 if let Some(fix) = bug_fix.as_mut().filter(|_| {
2065 attempt_status == "completed"
2066 && findings.is_empty()
2067 && matches!(
2068 report.verdict,
2069 Some(
2070 speccoder_core::verify::Verdict::Verified
2071 | speccoder_core::verify::Verdict::RegressionOnly
2072 )
2073 )
2074 }) {
2075 let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
2076 let judging = super::bug::Judging {
2077 before: &before,
2078 baseline: &baseline,
2079 verification: report.verification.as_ref(),
2080 base: report
2081 .verdict
2082 .clone()
2083 .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
2084 keep: &keep,
2085 };
2086 match super::bug::judge(&bug_ctx, fix, &judging) {
2087 Ok(j) => {
2088 report.verdict = Some(j.verdict);
2089 if let Some(weak) = j.weak {
2090 report.reason = Some(format!(
2091 "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
2092 weak.scenario, weak.rationale
2093 ));
2094 bug_weak = Some(weak);
2095 attempt_status = "repro_weak".to_owned();
2096 }
2097 }
2098 Err(e) => {
2099 report.integrity.push(e.clone());
2100 report.reason = Some(e);
2101 report.verdict = None;
2102 attempt_status = "integrity".to_owned();
2103 }
2104 }
2105 }
2106 if let Some(h) = report
2107 .acceptance
2108 .as_mut()
2109 .and_then(|a| a.get_mut("held_out"))
2110 .filter(|h| h["state"] == "on")
2111 {
2112 let failed = held_out_log
2113 .iter()
2114 .filter(|o| o["outcome"] == "failed")
2115 .count();
2116 h["attempts"] = serde_json::json!(held_out_log);
2117 h["gap"] = serde_json::json!(failed > 0);
2118 }
2119
2120 let passing = attempt_status == "completed"
2122 && matches!(
2123 report.verdict,
2124 Some(
2125 speccoder_core::verify::Verdict::Verified
2126 | speccoder_core::verify::Verdict::RegressionOnly
2127 )
2128 );
2129 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
2130 stage(
2131 &*live,
2132 task,
2133 Stage::Mutation,
2134 StageState::Skipped,
2135 Some(if mutation_config.is_none() {
2136 "not configured".to_owned()
2137 } else {
2138 "suite = false: for acceptance strength only".to_owned()
2139 }),
2140 None,
2141 );
2142 }
2143 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
2144 let began = std::time::Instant::now();
2145 stage(
2146 &*live,
2147 task,
2148 Stage::Mutation,
2149 StageState::Started,
2150 None,
2151 None,
2152 );
2153 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2154 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
2155 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
2156 if m.mode == mutation::Mode::Require
2157 && let Some(reason) = mutation::failure(&result)
2158 {
2159 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2160 reasons: vec![reason],
2161 });
2162 }
2163 report.mutation = Some(result);
2164 }
2165 let held = !matches!(
2166 report.verdict,
2167 Some(speccoder_core::verify::Verdict::Failed { .. })
2168 );
2169 stage(
2170 &*live,
2171 task,
2172 Stage::Mutation,
2173 if held {
2174 StageState::Passed
2175 } else {
2176 StageState::Failed
2177 },
2178 report
2179 .mutation
2180 .as_ref()
2181 .and_then(crate::mutation::trailer)
2182 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
2183 Some(began),
2184 );
2185 }
2186
2187 usage = add_usage(usage.as_ref(), report.usage.as_ref());
2188 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
2190 (report.verdict.clone(), report.verification.as_ref())
2191 else {
2192 break (report, attempt_status);
2193 };
2194 if attempt_status != "completed" {
2195 break (report, attempt_status);
2196 }
2197 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
2198 let mut d =
2199 speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
2200 d.uncollected = report
2201 .collection
2202 .as_ref()
2203 .map(speccoder_core::collection::Collection::retry_lines)
2204 .unwrap_or_default();
2205 let fingerprint = speccoder_core::retry::fingerprint(&d);
2206 let stop = if bug_candidate.is_some() {
2207 Some("a candidate is judged, never retried".to_owned())
2208 } else {
2209 retry_stop(
2210 &findings,
2211 verification,
2212 earlier.last().map(|e| e.fingerprint.as_str()),
2213 &fingerprint,
2214 attempt_no,
2215 run_config.max_attempts,
2216 )
2217 };
2218 if let Some(stop) = stop {
2219 report.retry_stop = Some(stop);
2220 break (report, attempt_status);
2221 }
2222 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2223 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
2224 break (report, attempt_status);
2225 }
2226 earlier.push(speccoder_core::EarlierAttempt {
2227 run_id: report.run_id.clone(),
2228 attempt_status: attempt_status.clone(),
2229 verdict: report.verdict.clone(),
2230 fingerprint,
2231 digest: d.clone(),
2232 usage: report.usage.clone(),
2233 });
2234 digest = serde_json::to_value(&d).ok();
2235 attempt_no += 1;
2236 };
2237 report.earlier = earlier;
2238 if run_config.best_of <= 1 {
2239 break (report, attempt_status);
2240 }
2241 let end = match worktree::checkpoint(&wt_root, &tmp) {
2242 Ok(c) => c,
2243 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2244 };
2245 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
2246 let summary = sample_summary(&report, &attempt_status, &patch);
2247 samples.push((report, attempt_status, end, summary));
2248 if samples.len() >= run_config.best_of as usize {
2249 let best = best_sample(&samples);
2250 let summaries: Vec<serde_json::Value> = samples
2251 .iter()
2252 .enumerate()
2253 .map(|(i, s)| {
2254 let mut v = s.3.clone();
2255 v["kept"] = serde_json::json!(i == best);
2256 v
2257 })
2258 .collect();
2259 let (mut chosen, status, end, _) = samples.swap_remove(best);
2260 if best != samples.len() {
2261 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
2263 return fail(CommandStatus::GitPrecondition, e.0);
2264 }
2265 }
2266 chosen.samples = summaries;
2267 break (chosen, status);
2268 }
2269 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2270 return fail(CommandStatus::GitPrecondition, e.0);
2271 }
2272 };
2273 report.usage = usage;
2274 {
2277 use speccoder_core::context::ContextKind as K;
2278 let part = |k: &str| {
2279 bundle_value[k]
2280 .as_str()
2281 .unwrap_or_default()
2282 .as_bytes()
2283 .to_vec()
2284 };
2285 let mut m = crate::context::Manifest::new();
2286 m.push(
2287 K::Constitution,
2288 "worktree",
2289 "/bundle/constitution",
2290 &part("constitution"),
2291 )
2292 .push(K::Plan, "worktree", "/bundle/plan", &part("plan"))
2293 .push(K::Story, "worktree", "/bundle/story", &part("story"))
2294 .push(
2295 K::Task,
2296 "worktree",
2297 "/bundle/task",
2298 serde_json::to_string(&bundle_value["task"])
2299 .unwrap_or_default()
2300 .as_bytes(),
2301 )
2302 .push(
2303 K::TestLayout,
2304 "worktree",
2305 "/test_layout",
2306 test_layout_text.as_bytes(),
2307 );
2308 report.context = Some(m.finish());
2309 }
2310 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
2312
2313 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
2317 let mut attempt_status = attempt_status;
2318 let verified = attempt_status == "completed"
2319 && matches!(
2320 report.verdict,
2321 Some(speccoder_core::verify::Verdict::Verified)
2322 );
2323 let visible_argv = acceptance
2324 .as_ref()
2325 .filter(|a| a.red)
2326 .and_then(|a| a.argv.clone());
2327 if verified && !run_config.strength.enabled {
2328 stage(
2329 &*live,
2330 task,
2331 Stage::Strength,
2332 StageState::Skipped,
2333 Some("strength = false".to_owned()),
2334 None,
2335 );
2336 }
2337 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
2338 use speccoder_core::strength::{Gate, gate, over_strict};
2339 let began = std::time::Instant::now();
2340 stage(
2341 &*live,
2342 task,
2343 Stage::Strength,
2344 StageState::Started,
2345 None,
2346 None,
2347 );
2348 let fin = worktree::checkpoint(&wt_root, &tmp);
2349 let patch = fin
2350 .as_ref()
2351 .ok()
2352 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
2353 .unwrap_or_default();
2354 let mut notes = Vec::new();
2355 let held: Vec<(crate::held_out::HeldOutFile, String)> =
2356 match acceptance.as_ref().and_then(|a| a.held_out.clone()) {
2357 None => Vec::new(),
2358 Some(sha) => match crate::held_out::load_verified(&held_out_store, &sha) {
2359 Ok((manifest, contents)) => manifest
2360 .files
2361 .into_iter()
2362 .zip(contents.into_iter().map(|(_, c)| c))
2363 .collect(),
2364 Err(t) => {
2365 notes.push(format!("held-out set not overlaid: {}", t.0));
2366 Vec::new()
2367 }
2368 },
2369 };
2370 let rulings = crate::strength::rulings(&strength_dir);
2371 let mut commands = vec![argv];
2372 let measure = |commands: &[Vec<String>]| {
2373 crate::strength::measure(
2374 &crate::strength::Setup {
2375 wt_root: &wt_root,
2376 project: &project,
2377 tmp: &tmp,
2378 runner: &runner,
2379 policy: &policy,
2380 acceptance: commands,
2381 held_out: &held,
2382 mutation: mutation_config.as_ref(),
2383 patch: &patch,
2384 config: &run_config.strength,
2385 keep: &strength_dir,
2386 rulings: &rulings,
2387 },
2388 |changed| {
2389 let input = substrate::near_miss::input(
2391 &bundle_value,
2392 changed,
2393 &crate::mutation::source_patch(&patch),
2394 run_config.strength.near_miss,
2395 run_config.strength.permitted,
2396 );
2397 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
2398 Ok(o) => {
2399 let out = o
2400 .output
2401 .as_ref()
2402 .map(|v| v.peek().clone())
2403 .unwrap_or_default();
2404 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
2405 && !out["answer"].is_null();
2406 crate::strength::Authored {
2407 answer: if ok {
2408 Ok(out["answer"].clone())
2409 } else {
2410 Err(format!(
2411 "the variant author did not answer ({})",
2412 o.status.as_str()
2413 ))
2414 },
2415 run_id: Some(o.run_id.to_string()),
2416 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
2417 }
2418 }
2419 Err(e) => crate::strength::Authored {
2420 answer: Err(redact(&e.0, secret)),
2421 run_id: None,
2422 usage: None,
2423 },
2424 }
2425 },
2426 )
2427 };
2428 let mut measured = measure(&commands);
2429 let min = run_config.strength.min;
2432 if let (Ok(a), Ok(fin)) = (&measured, &fin)
2433 && gate(min, &a.combined, false) == Gate::Failed
2434 && over_strict(&a.permitted).is_empty()
2435 {
2436 let visible_tests: Vec<(String, String)> = report
2437 .acceptance
2438 .as_ref()
2439 .and_then(|a| a["files"].as_array().cloned())
2440 .unwrap_or_default()
2441 .iter()
2442 .filter_map(|p| p.as_str())
2443 .map(|p| {
2444 (
2445 p.to_owned(),
2446 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2447 )
2448 })
2449 .collect();
2450 let setup = AcceptanceSetup {
2451 rt: &rt,
2452 plane: &plane,
2453 project: &project,
2454 worktree: &wt_root,
2455 tmp: &tmp,
2456 policy: &policy,
2457 feature: &feature_name,
2458 task,
2459 bundle: bundle_value.clone(),
2460 scenarios: &scenarios,
2461 instructions: &instructions,
2462 runner: &runner,
2463 layout: Some(&test_layout),
2464 story_files: &story_files,
2465 };
2466 let weak = crate::strength::weak(a);
2467 match strengthen(&setup, &visible_tests, weak, &before, fin) {
2468 Ok(added) => {
2469 if let Some(acc) = report.acceptance.as_mut() {
2470 if let Some(files) = acc["files"].as_array_mut() {
2471 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
2472 }
2473 acc["strengthened"] = serde_json::json!({
2474 "files": added.files,
2475 "argv": added.argv,
2476 "run_id": added.report["run_id"],
2477 "usage": added.report["usage"],
2478 });
2479 }
2480 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
2481 commands.push(added.argv);
2482 let mut again = measure(&commands);
2483 if let Ok(b) = again.as_mut() {
2484 b.gate = gate(min, &b.combined, true);
2485 b.notes.insert(
2486 0,
2487 "strengthened once with added acceptance tests".to_owned(),
2488 );
2489 }
2490 measured = again;
2491 }
2492 Err(why) => {
2493 if let Ok(a) = measured.as_mut() {
2494 a.notes
2495 .insert(0, format!("could not strengthen the set: {why}"));
2496 }
2497 }
2498 }
2499 }
2500 match measured {
2501 Ok(mut a) => {
2502 if a.gate == Gate::Off {
2503 a.gate = gate(min, &a.combined, false);
2504 }
2505 a.notes.extend(notes);
2506 let findings = over_strict(&a.permitted);
2507 if !findings.is_empty() {
2508 attempt_status = "over_strict".to_owned();
2509 report.reason = Some(format!(
2510 "an acceptance test rejects an implementation the spec appears to allow: {}",
2511 findings
2512 .iter()
2513 .map(|p| format!("{} ({})", p.scenario, p.rationale))
2514 .collect::<Vec<_>>()
2515 .join("; ")
2516 ));
2517 } else if a.gate == Gate::Failed {
2518 attempt_status = "acceptance_weak".to_owned();
2519 report.reason = Some(format!(
2520 "acceptance set too weak: strength {} is below min_strength {}{}",
2521 speccoder_core::strength::summary(&a.combined, None),
2522 min.unwrap_or_default(),
2523 a.notes
2524 .first()
2525 .map(|n| format!(" ({n})"))
2526 .unwrap_or_default()
2527 ));
2528 } else {
2529 crate::strength::release(&strength_dir);
2530 }
2531 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
2533 report.strength = Some(a);
2534 }
2535 Err(e) => {
2536 report.integrity.push(e.clone());
2537 report.reason = Some(e);
2538 report.verdict = None;
2539 attempt_status = "integrity".to_owned();
2540 }
2541 }
2542 report.attempt_status.clone_from(&attempt_status);
2543 let held_up = attempt_status == "completed";
2544 stage(
2545 &*live,
2546 task,
2547 Stage::Strength,
2548 if held_up {
2549 StageState::Passed
2550 } else {
2551 StageState::Failed
2552 },
2553 report.strength.as_ref().map(|a| {
2554 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
2555 }),
2556 Some(began),
2557 );
2558 }
2559
2560 let verdict_passed = attempt_status == "completed"
2564 && matches!(
2565 report.verdict,
2566 Some(
2567 speccoder_core::verify::Verdict::Verified
2568 | speccoder_core::verify::Verdict::RegressionOnly
2569 )
2570 );
2571 let mut suspected = false;
2572 if !run_config.monitor && verdict_passed {
2573 stage(
2574 &*live,
2575 task,
2576 Stage::Monitor,
2577 StageState::Skipped,
2578 Some("not enabled".to_owned()),
2579 None,
2580 );
2581 }
2582 if run_config.monitor && verdict_passed {
2583 let began = std::time::Instant::now();
2584 stage(
2585 &*live,
2586 task,
2587 Stage::Monitor,
2588 StageState::Started,
2589 None,
2590 None,
2591 );
2592 let patch = worktree::checkpoint(&wt_root, &tmp)
2593 .and_then(|now| worktree::patch(&wt_root, &start, &now))
2594 .unwrap_or_default();
2595 let calls: Vec<serde_json::Value> = report
2596 .tool_calls
2597 .iter()
2598 .map(|c| {
2599 let tool = c["tool"].as_str().unwrap_or_default();
2600 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
2601 })
2602 .collect();
2603 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
2604 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
2605 report.monitor = Some(monitor);
2606 suspected = suspicion;
2607 stage(
2608 &*live,
2609 task,
2610 Stage::Monitor,
2611 if suspected {
2612 StageState::Failed
2613 } else {
2614 StageState::Passed
2615 },
2616 suspected.then(|| "suspects gaming".to_owned()),
2617 Some(began),
2618 );
2619 }
2620
2621 let in_place_stop = options
2624 .session
2625 .as_ref()
2626 .filter(|_| verdict_passed && !suspected)
2627 .and_then(|s| {
2628 s.lock()
2629 .ok()
2630 .and_then(|s| s.check_before_tick(&policy).err())
2631 });
2632 if let Some(e) = &in_place_stop {
2633 report.reason = Some(e.line());
2634 report.verdict = None;
2635 attempt_status = e.code.to_lowercase().replace('-', "_");
2637 report.attempt_status.clone_from(&attempt_status);
2638 }
2639 let passed = verdict_passed && !suspected && in_place_stop.is_none();
2640 if passed
2644 && matches!(
2645 report.verdict,
2646 Some(speccoder_core::verify::Verdict::RegressionOnly)
2647 )
2648 {
2649 use speccoder_core::regression::{Regression, RegressionFacts, RegressionReason, decide};
2650 let facts = RegressionFacts {
2651 acceptance_on: run_config.acceptance,
2652 story: acceptance_story
2654 .clone()
2655 .or_else(|| bug_fix.as_ref().map(|f| f.bug.requirement.clone()))
2656 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone())),
2657 scenarios: scenarios.len(),
2658 skip: acceptance.as_ref().and_then(|a| a.skip_cause.clone()),
2659 red_class: acceptance.as_ref().and_then(|a| {
2661 if a.red
2662 && report
2663 .acceptance
2664 .as_ref()
2665 .is_some_and(|r| r["green_flaky"] == true)
2666 {
2667 Some(speccoder_core::verify::RedClass::Flaky)
2668 } else {
2669 a.red_class
2670 }
2671 }),
2672 };
2673 let mut reason = decide(&facts);
2674 if matches!(reason, RegressionReason::AlreadyPassing { .. })
2677 && bug_fix.is_none()
2678 && let Some(a) = acceptance.as_ref()
2679 && let Some(argv) = a.argv.as_ref()
2680 {
2681 let mut files: Vec<(String, String)> = a.report["files"]
2682 .as_array()
2683 .into_iter()
2684 .flatten()
2685 .filter_map(|f| f.as_str())
2686 .filter_map(|f| {
2687 std::fs::read_to_string(project.join(f))
2688 .ok()
2689 .map(|c| (f.to_owned(), c))
2690 })
2691 .collect();
2692 let prefix = project
2693 .strip_prefix(&wt_root)
2694 .map(|p| p.to_string_lossy().replace('\\', "/"))
2695 .unwrap_or_default();
2696 let mut command = Some(argv.clone());
2699 if a.report["appended"] == true
2700 && let Some(story) = a.report["story_file"].as_str()
2701 {
2702 let at = if prefix.is_empty() {
2703 story.to_owned()
2704 } else {
2705 format!("{prefix}/{story}")
2706 };
2707 let old = crate::git::Git::at(&wt_root)
2708 .run(&["show", &format!("HEAD:{at}")])
2709 .map(|b| String::from_utf8_lossy(&b).into_owned())
2710 .unwrap_or_default();
2711 command = None;
2712 if let Some((_, content)) = files.iter_mut().find(|(f, _)| f == story)
2713 && let Some(added) = content.strip_prefix(old.as_str()).map(str::to_owned)
2714 {
2715 command = super::bisect::section_command(argv, story, &added);
2716 let imports: String = old
2717 .lines()
2718 .filter(|l| {
2719 let t = l.trim_start();
2720 ["use ", "import ", "from ", "package ", "extern crate "]
2721 .iter()
2722 .any(|k| t.starts_with(k))
2723 })
2724 .map(|l| format!("{l}\n"))
2725 .collect();
2726 *content = format!("{imports}{added}");
2727 }
2728 }
2729 let timeout = std::fs::read_to_string(project.join(crate::verify::config::FILE))
2730 .ok()
2731 .and_then(|t| crate::verify::config::parse(&t).ok())
2732 .and_then(|cs| {
2733 cs.into_iter()
2734 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
2735 .map(|c| c.timeout)
2736 .max()
2737 })
2738 .unwrap_or(std::time::Duration::from_secs(600));
2739 let found = match &command {
2740 Some(argv) => super::bisect::search(&super::bisect::Search {
2741 repo: &wt_root,
2742 prefix: &prefix,
2743 feature: &feature_name,
2744 task,
2745 files: &files,
2746 argv,
2747 scratch: &s.plane_dir.join("tmp"),
2748 sandbox: &sandbox_config,
2749 timeout,
2750 worktree: &worktree_config,
2751 checkout: Path::new(&s.snapshot.root),
2752 state: Some(state),
2753 }),
2754 None => super::bisect::Found {
2756 implemented_by: None,
2757 runs: 0,
2758 },
2759 };
2760 live.event(&progress::Event::RegressionBisected {
2761 task: task.to_owned(),
2762 runs: found.runs,
2763 result: found.implemented_by.as_ref().map(|i| match i {
2764 speccoder_core::regression::ImplementedBy::Task { task, .. } => task.clone(),
2765 speccoder_core::regression::ImplementedBy::BeforeFeature { .. } => {
2766 "before_feature".to_owned()
2767 }
2768 }),
2769 });
2770 reason = RegressionReason::AlreadyPassing {
2771 implemented_by: found.implemented_by,
2772 };
2773 }
2774 report.regression = Some(Regression::of(reason));
2775 }
2776 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
2777 let began = std::time::Instant::now();
2778 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
2779 let message = tick_message(&TickFacts {
2780 feature: &feature_name,
2781 task,
2782 title: &t.title,
2783 report: &report,
2784 red: acceptance
2785 .as_ref()
2786 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
2787 checklists_overridden,
2788 extra: bug_fix
2789 .as_ref()
2790 .map(|f| {
2791 super::bug::trailers(
2792 f,
2793 &report
2794 .verdict
2795 .as_ref()
2796 .map(super::outcome::verdict_word)
2797 .unwrap_or_default(),
2798 &crate::strength::rulings(&strength_dir),
2799 )
2800 })
2801 .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
2803 .unwrap_or_default(),
2804 });
2805 match &options.session {
2806 None => {
2808 match format.tick(file, task, &t.line_hash) {
2809 Ok(()) => report.ticked = true,
2810 Err(e) => report.integrity.push(format!("tick refused: {e}")),
2811 }
2812 if report.ticked && run_config.commit_per_task {
2813 match commit_attempt(&wt_root, &message) {
2814 Ok(id) => report.commit = Some(id),
2815 Err(e) => report.integrity.push(format!("commit failed: {e}")),
2816 }
2817 }
2818 }
2819 Some(session) => {
2822 let digest =
2823 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
2824 if let Ok(mut s) = session.lock() {
2825 let done = s.commit_and_tick(
2826 &plane_dir,
2827 file,
2828 &t.line_hash,
2829 &message,
2830 &digest,
2831 || format.tick(file, task, &t.line_hash),
2832 );
2833 report.ticked = done.ticked;
2834 report.commit = done.commit;
2835 report.integrity.extend(done.problems);
2836 }
2837 }
2838 }
2839 report.instructions = attempt_instructions
2840 .iter()
2841 .map(|i| speccoder_core::InstructionFile {
2842 path: i.path.clone(),
2843 sha256: i.sha256.clone(),
2844 bytes: i.bytes,
2845 cut: i.cut,
2846 })
2847 .collect();
2848 if let Some(commit) = report
2850 .commit
2851 .clone()
2852 .filter(|_| report.ticked && run_config.attest.enabled)
2853 {
2854 let verdict = report
2855 .verdict
2856 .as_ref()
2857 .map(super::outcome::verdict_word)
2858 .unwrap_or_default();
2859 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
2860 let feature_dir = snap
2861 .feature
2862 .as_ref()
2863 .map(|f| f.feature.dir.clone())
2864 .unwrap_or_default();
2865 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
2866 let tick = crate::attest::Tick {
2867 repo: &wt_root,
2868 config: &config_text,
2869 attest: &run_config.attest,
2870 format: &format!("{:?}", format.kind()).to_lowercase(),
2871 feature: &feature_name,
2872 feature_dir: &feature_dir,
2873 task,
2874 title: &t.title,
2875 line_hash: &t.line_hash,
2876 section: t.story.as_deref(),
2877 section_text: bundle_value["story"].as_str().unwrap_or_default(),
2878 requirements: &requirements,
2879 commit: &commit,
2880 verdict: &verdict,
2881 sandbox: serde_json::json!({
2882 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
2883 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
2884 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
2885 }),
2886 executor: serde_json::json!({
2887 "kind": if bug_candidate.is_some() { "candidate".to_owned() } else { executor.as_ref().map_or_else(|| "native".to_owned(), |e| match &e.kind {
2888 external::Kind::Claude => "claude".to_owned(),
2889 external::Kind::Codex => "codex".to_owned(),
2890 external::Kind::Command(_) => "command".to_owned(),
2891 }) },
2892 "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
2893 }),
2894 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
2895 bug: bug_fix
2896 .as_ref()
2897 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
2898 };
2899 report.attestation = Some(crate::attest::record_tick(&tick, &report));
2900 }
2901 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
2902 stage(
2903 &*live,
2904 task,
2905 Stage::Commit,
2906 if committed {
2907 StageState::Passed
2908 } else {
2909 StageState::Failed
2910 },
2911 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
2912 Some(began),
2913 );
2914 }
2915 if passed && let Some(n) = &native {
2918 let began = std::time::Instant::now();
2919 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
2920 let verdict = report
2921 .verdict
2922 .as_ref()
2923 .map(super::outcome::verdict_word)
2924 .unwrap_or_default();
2925 let message = tick_message(&TickFacts {
2926 feature: &feature_name,
2927 task,
2928 title: &n.title,
2929 report: &report,
2930 red: acceptance
2931 .as_ref()
2932 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
2933 checklists_overridden: false,
2934 extra: bug_fix
2935 .as_ref()
2936 .map(|f| {
2937 super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
2938 })
2939 .unwrap_or_default(),
2940 });
2941 match commit_attempt(&wt_root, &message) {
2942 Ok(id) => report.commit = Some(id),
2943 Err(e) => report.integrity.push(format!("commit failed: {e}")),
2944 }
2945 if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
2946 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
2947 let tick = crate::attest::Tick {
2948 repo: &wt_root,
2949 config: &config_text,
2950 attest: &run_config.attest,
2951 format: &format!("{:?}", format.kind()).to_lowercase(),
2952 feature: &feature_name,
2953 feature_dir: &n.dir,
2954 task,
2955 title: &n.title,
2956 line_hash: "",
2957 section: None,
2958 section_text: &n.story,
2959 requirements: &[],
2960 commit: &commit,
2961 verdict: &verdict,
2962 sandbox: serde_json::json!({
2963 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
2964 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
2965 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
2966 }),
2967 executor: serde_json::json!({
2968 "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
2969 }),
2970 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
2971 bug: bug_fix
2972 .as_ref()
2973 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
2974 };
2975 report.attestation = Some(crate::attest::record_tick(&tick, &report));
2976 }
2977 stage(
2978 &*live,
2979 task,
2980 Stage::Commit,
2981 if report.commit.is_some() {
2982 StageState::Passed
2983 } else {
2984 StageState::Failed
2985 },
2986 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
2987 Some(began),
2988 );
2989 }
2990
2991 if attempt_status == "repro_weak"
2996 && let Some(weak) = &bug_weak
2997 && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
2998 {
2999 report
3000 .integrity
3001 .push(format!("holding the attempt failed: {e}"));
3002 }
3003 if attempt_status == "over_strict" || attempt_status == "repro_weak" {
3004 let findings = report
3005 .strength
3006 .as_ref()
3007 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
3008 .unwrap_or_default();
3009 if attempt_status == "over_strict"
3010 && let Err(e) = crate::strength::hold(&strength_dir, &findings)
3011 {
3012 report
3013 .integrity
3014 .push(format!("holding the attempt failed: {e}"));
3015 }
3016 marker_guard.keep();
3017 }
3018 if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
3021 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
3022 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
3023 let reason = redact(&reason, secret);
3024 let record = attempts::Record {
3025 run,
3026 outcome: &outcome,
3027 reason: &reason,
3028 pending: report.pending.as_ref(),
3029 usage: report.usage.as_ref(),
3030 };
3031 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
3032 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
3033 {
3034 Ok(saved) => report.saved_attempt = Some(saved.reference),
3035 Err(e) => report
3036 .integrity
3037 .push(format!("saving the attempt failed: {}", e.0)),
3038 }
3039 }
3040
3041 let (status, mut message) = status_and_message(&report, &attempt_status, suspected);
3042 if status == CommandStatus::RunFailed
3045 && let Some(region) = s.model.region.as_deref()
3046 && report
3047 .reason
3048 .as_deref()
3049 .and_then(substrate::provider_refusal)
3050 .is_some()
3051 {
3052 message.push_str(&format!(
3053 " — Amazon Bedrock, region {region}, model {}",
3054 s.model.model
3055 ));
3056 }
3057 let refusal = |result: CommandResult| {
3060 if !result.has_explicit_code()
3061 && refused_account
3062 && result.status() == CommandStatus::RunFailed
3063 {
3064 result.with_code("PROVIDER-REFUSED").with_fix(
3065 substrate::model_config::provider_refused_fix(
3066 &s.model,
3067 crate::aws::detect_here().as_ref(),
3068 feature,
3069 ),
3070 )
3071 } else {
3072 result
3073 }
3074 };
3075 if let Some(fix) = bug_fix.as_mut() {
3076 return refusal(super::bug::finished(
3077 &bug_ctx,
3078 fix,
3079 report,
3080 &attempt_status,
3081 passed,
3082 status,
3083 redact(&message, secret),
3084 ));
3085 }
3086 live.event(&finished_event(
3087 task,
3088 &report,
3089 &attempt_status,
3090 suspected,
3091 redact(&message, secret),
3092 ));
3093 let code = collection_code(&report, &attempt_status);
3094 let result = CommandResult::new("run", status, &redact(&message, secret));
3095 match code {
3096 Some(c) => result.with_code(c),
3097 None => refusal(result),
3098 }
3099 .with_data(CommandData::Run(Box::new(report)))
3100}
3101
3102fn collection_code(
3105 report: &speccoder_core::RunReport,
3106 attempt_status: &str,
3107) -> Option<&'static str> {
3108 if attempt_status == "collection_unknown" {
3109 return Some("TESTS-COLLECTION-UNKNOWN");
3110 }
3111 match &report.verdict {
3112 Some(speccoder_core::verify::Verdict::Failed { reasons })
3113 if reasons
3114 .first()
3115 .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
3116 {
3117 Some("TESTS-NOT-COLLECTED")
3118 }
3119 _ => None,
3120 }
3121}
3122
3123enum Collected {
3129 Unknown(String),
3131 Changed(String),
3133}
3134
3135fn judge_collection(
3136 dirs: (&Path, &Path, &Path),
3137 runner: &sandbox::Runner,
3138 otherwise_passing: bool,
3139 report: &mut speccoder_core::RunReport,
3140 live: &dyn progress::Progress,
3141 task: &str,
3142) -> Option<Collected> {
3143 let (project, wt_root, tmp) = dirs;
3144 let before = worktree::checkpoint(wt_root, tmp).ok();
3146 let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
3147 .ok()
3148 .and_then(|t| crate::verify::config::parse(&t).ok())
3149 .unwrap_or_default()
3150 .into_iter()
3151 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
3152 .collect::<Vec<_>>();
3153 let results = report
3154 .verification
3155 .as_ref()
3156 .map(|v| v.checks.clone())
3157 .unwrap_or_default();
3158 let c = crate::collection::judge(&crate::collection::Input {
3159 root: project,
3160 changes: &report.changes,
3161 checks: &checks,
3162 results: &results,
3163 runner,
3164 listings: otherwise_passing,
3165 })?;
3166 if !c.listing.is_empty()
3167 && let Some(before) = &before
3168 {
3169 let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
3170 .and_then(|after| worktree::diff(wt_root, before, &after))
3171 .map(|d| {
3172 d.into_iter()
3173 .map(|c| c.path)
3174 .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
3175 .collect()
3176 })
3177 .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
3178 if !changed.is_empty() {
3179 report.collection = Some(c);
3180 return Some(Collected::Changed(format!(
3181 "integrity: a test listing changed the worktree: {}",
3182 changed.join(", ")
3183 )));
3184 }
3185 }
3186 live.event(&progress::Event::CollectionChecked {
3187 task: task.to_owned(),
3188 files: c
3189 .files
3190 .iter()
3191 .map(|f| progress::CollectionFile {
3192 path: f.path.clone(),
3193 state: f.state,
3194 decided_by: f.decided_by,
3195 })
3196 .collect(),
3197 });
3198 let never = c.reasons();
3199 let unknown: Vec<String> = c
3200 .unknown()
3201 .map(|f| {
3202 format!(
3203 "speccoder cannot tell whether the runner ran `{}` ({})",
3204 f.path,
3205 f.why.as_deref().unwrap_or("no report or listing names it")
3206 )
3207 })
3208 .collect();
3209 report.collection = Some(c);
3210 if !never.is_empty() {
3211 let mut reasons = never;
3212 if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
3213 report.verdict.take()
3214 {
3215 reasons.extend(more);
3216 }
3217 report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
3218 return None;
3219 }
3220 (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
3221}
3222
3223fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
3226 let said = c
3227 .stderr_tail
3228 .lines()
3229 .chain(c.stdout_tail.lines())
3230 .map(str::trim)
3231 .find(|l| l.starts_with("error"))
3232 .map(|l| l.chars().take(200).collect::<String>());
3233 let fix = match said.as_deref() {
3234 Some(l) if l.contains("profile `speccoder` not found") => Some(
3235 "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
3236 ),
3237 Some(l) if l.contains("no tests to run") => Some(
3238 "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",
3239 ),
3240 Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
3241 "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
3242 ),
3243 _ => None,
3244 };
3245 match (said, fix) {
3246 (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
3247 (Some(s), None) => format!("{note} ({s})"),
3248 _ => note.to_owned(),
3249 }
3250}
3251
3252#[must_use]
3256pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
3257 use speccoder_core::verify::CheckStatus;
3258 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
3259 return None;
3260 }
3261 if let Some(note) = &c.note {
3262 return Some(with_cause(note, c));
3263 }
3264 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
3265 return None;
3266 }
3267 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
3268 let runner = c
3272 .argv
3273 .first()
3274 .map(|a| a.rsplit('/').next().unwrap_or(a))
3275 .is_some_and(|a| {
3276 matches!(
3277 a,
3278 "cargo"
3279 | "pytest"
3280 | "python"
3281 | "python3"
3282 | "uv"
3283 | "npm"
3284 | "npx"
3285 | "pnpm"
3286 | "yarn"
3287 | "bun"
3288 | "go"
3289 | "jest"
3290 | "vitest"
3291 | "mvn"
3292 | "gradle"
3293 | "dotnet"
3294 | "mix"
3295 | "swift"
3296 )
3297 });
3298 let cannot_start = matches!(c.exit_code, Some(126 | 127));
3299 let is_broken = match &summary {
3300 _ if cannot_start => true,
3301 Some(s) if s.build_failed => true,
3302 Some(s) => s.passed + s.failed + s.errors == 0,
3303 None => runner,
3304 };
3305 match is_broken {
3306 false => None,
3307 true => {
3308 let first = c
3309 .stderr_tail
3310 .lines()
3311 .chain(c.stdout_tail.lines())
3312 .map(str::trim)
3313 .find(|l| l.to_lowercase().contains("error"))
3314 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
3315 .unwrap_or("it failed without running a test")
3316 .chars()
3317 .take(160)
3318 .collect::<String>();
3319 Some(first)
3320 }
3321 }
3322}