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
111pub(crate) fn run_task(
115 path: &Path,
116 feature: Option<&str>,
117 task: &str,
118 state: &Path,
119 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
120 secret: Option<&str>,
121 options: &RunOptions,
122) -> CommandResult {
123 let first = run_task_once(
124 path,
125 feature,
126 task,
127 state,
128 Arc::clone(&provider),
129 secret,
130 options,
131 );
132 match first.data() {
133 Some(CommandData::Run(r)) if r.attempt_status == CHANGED_CODE => {
134 if let Some(p) = &options.progress {
135 p.event(&progress::Event::Notice {
136 text: format!("{task} changed code, so it runs as code"),
137 });
138 }
139 run_task_once(path, feature, task, state, provider, secret, options)
140 }
141 _ => first,
142 }
143}
144
145pub const CHANGED_CODE: &str = "planning_changed_code";
148
149#[allow(clippy::too_many_lines)] fn run_task_once(
151 path: &Path,
152 feature: Option<&str>,
153 task: &str,
154 state: &Path,
155 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
156 secret: Option<&str>,
157 options: &RunOptions,
158) -> CommandResult {
159 use speccoder_core::RunReport;
160 if options.in_place && options.session.is_none() {
162 return in_session("run", path, feature, state, options, |o| {
163 run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
164 });
165 }
166 let approver = options.approver.clone();
167 use speccoder_core::snapshot::TaskState;
168
169 let refuse =
170 |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
171
172 let s = match setup(path, feature, state, "run") {
174 Ok(s) => s,
175 Err(r) => return *r,
176 };
177 let native = options.bug_home.clone();
179 let feature_name = match (&native, s.snapshot.feature.as_ref()) {
180 (Some(n), _) => n.unit.clone(),
181 (None, Some(f)) => f.feature.name.clone(),
182 (None, None) => {
183 return refuse(
184 CommandStatus::SelectionNeeded,
185 "no feature selected; pass --feature".to_owned(),
186 );
187 }
188 };
189 let plane_dir = match &options.lane {
192 Some(lane) => s.plane_dir.join("lanes").join(lane),
193 None => s.plane_dir.clone(),
194 };
195 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
197 &plane_dir,
198 s.model.prices,
199 options.progress.clone(),
200 ));
201
202 let wt = match &native {
204 Some(n) => crate::commands::worktree_of(&s.snapshot, &n.unit, &n.dir, state),
205 None => workspace(path, Some(&feature_name), state, options),
206 };
207 let Some(CommandData::Worktree(w)) = wt.data() else {
208 return CommandResult::carried("run", &wt);
209 };
210 if wt.status() != CommandStatus::Ok {
211 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
212 }
213 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
214 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
215 };
216 let (wt_path, project_dir) = match &options.lane {
217 None => (wt_path, project_dir),
218 Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
219 {
220 Ok(paths) => paths,
221 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
222 },
223 };
224 let project = std::path::PathBuf::from(&project_dir);
225 let wt_root = std::path::PathBuf::from(&wt_path);
226 let tmp = plane_dir.join("tmp");
227
228 let elsewhere = if options.lane.is_none() {
234 match crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task) {
235 Ok(e) => e,
236 Err(e) => {
237 return refuse(
238 CommandStatus::Error,
239 format!(
240 "the calls waiting for a person could not be read, so whether another task's attempt holds the worktree is unknown: {e}"
241 ),
242 );
243 }
244 }
245 } else {
246 crate::approvals::Elsewhere::default()
247 };
248 if let Some((other, covers)) = elsewhere.waiting {
249 return refuse(
250 CommandStatus::NeedsHuman,
251 format!(
252 "{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}`"
253 ),
254 );
255 }
256 if options.lane.is_none()
258 && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
259 {
260 return refuse(
261 CommandStatus::NeedsHuman,
262 format!(
263 "{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`"
264 ),
265 )
266 .with_code("TESTS-OVER-STRICT");
267 }
268 let commits_per_task = verify::find_config(&project)
272 .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
273 .and_then(|t| substrate::run_config::parse(&t).ok())
274 .is_none_or(|c| c.commit_per_task);
275 let marker_path = resume::path(&plane_dir, &feature_name, task);
278 if options.fresh_acceptance && options.lane.is_none() {
281 let _ = std::fs::remove_file(&marker_path);
282 crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
283 }
284 let mut head_now = git::Git::at(&wt_root)
285 .line(&["rev-parse", "HEAD"])
286 .unwrap_or_default();
287 let autopilot = options.mode == speccoder_core::oracle::RunMode::Autopilot;
292 let interrupted = resume::load(&marker_path).filter(|m| {
293 m.head == head_now && options.lane.is_none() && !head_now.is_empty() && !autopilot
294 });
295 if let Some(m) = &interrupted {
298 if let (Some(id), Ok(mut run)) = (&m.run_id, options.run_id.lock()) {
299 *run = crate::pipeline::RunId(id.clone());
300 }
301 if let Ok(mut asked) = options.asked.lock() {
302 *asked = (*asked).max(m.asked);
303 }
304 if let Ok(mut g) = options.run_grants.lock() {
305 for o in &m.run_grants {
306 if !g.contains(o) {
307 g.push(o.clone());
308 }
309 }
310 }
311 }
312 let marker_guard = resume::Guard::new(marker_path.clone());
313 let resumed_from = interrupted.as_ref().map(|_| {
316 std::fs::read_to_string(plane_dir.join(progress::FILE))
317 .ok()
318 .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
319 .filter(|l| l.task == task)
320 .map_or((None, None), |l| (l.stage, l.last_call))
321 });
322 let mut set_aside = None;
323 if interrupted.is_none()
325 && commits_per_task
326 && options.session.is_none()
327 && worktree::require_clean(&wt_root).is_err()
328 {
329 let left = worktree::leftovers(&wt_root);
333 if !left.is_empty() {
334 let mut what = Vec::new();
335 if !left.commit.is_empty() {
336 what.push(format!("{} is not committed", left.commit.join(", ")));
337 }
338 if !left.ignore.is_empty() {
339 what.push(format!("{} is not git-ignored", left.ignore.join(", ")));
340 }
341 let checkout = std::path::Path::new(&s.snapshot.root);
342 let remedy = crate::code::setup_remedy(checkout, Some(&feature_name));
343 let command = remedy
344 .as_ref()
345 .map_or_else(|| left.fix(checkout, &wt_root, &[]), |r| r.command.clone());
346 return refuse(
347 CommandStatus::GitPrecondition,
348 format!(
349 "{} — the checks write it on every run, so each run would find it in the worktree and set it aside",
350 what.join(" and "),
351 ),
352 )
353 .with_code("SETUP-INCOMPLETE")
354 .with_fix(vec!["speccoder fix".to_owned(), command])
355 .with_remedy(remedy);
356 }
357 match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
358 Ok(reference) => set_aside = Some(reference),
359 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
360 }
361 }
362
363 let format = crate::format::at(&project);
365 let Some(snap) = format.inspect(
366 &project,
367 if native.is_some() {
368 None
369 } else {
370 Some(&feature_name)
371 },
372 ) else {
373 return refuse(
374 CommandStatus::NotAProject,
375 format.messages().no_project_in_worktree.to_owned(),
376 );
377 };
378 if native.is_some()
379 && crate::bug::state::load(&s.plane_dir, &feature_name, task)
380 .is_some_and(|b| b.outcome == speccoder_core::bug::Outcome::Fixed)
381 {
382 return refuse(
383 CommandStatus::TaskNotReady,
384 format!("{task} is already fixed"),
385 )
386 .with_code("TASK-DONE");
387 }
388 if native.is_none() {
391 let Some(st) = snap
392 .feature
393 .as_ref()
394 .and_then(|f| f.schedule.as_ref())
395 .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
396 else {
397 return refuse(
398 CommandStatus::NotFound,
399 format!("no task {task} in {feature_name}"),
400 );
401 };
402 match st.state {
403 TaskState::Done => {
404 return refuse(
405 CommandStatus::TaskNotReady,
406 format!("{task} is already done"),
407 )
408 .with_code("TASK-DONE");
409 }
410 TaskState::Blocked => {
411 return refuse(
412 CommandStatus::TaskNotReady,
413 format!("{task} is blocked by {}", st.blocked_by.join(", ")),
414 )
415 .with_code("TASK-BLOCKED");
416 }
417 TaskState::Ready => {}
418 }
419 }
420 let checklists_overridden = if native.is_some() {
423 false
424 } else {
425 match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
426 Ok(overridden) => overridden,
427 Err(stop) => return *stop,
428 }
429 };
430 let parsed_task = snap
431 .feature
432 .as_ref()
433 .and_then(|f| f.tasks.as_ref())
434 .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
435 let tasks_file = snap
436 .feature
437 .as_ref()
438 .and_then(|f| f.tasks.as_ref())
439 .map(|t| project.join(&t.path));
440
441 let bundle = match native
442 .as_ref()
443 .map(|n| n.bundle(&project))
444 .map_or_else(|| format.bundle(&snap, task), Ok)
445 {
446 Ok(b) => b,
447 Err(_) => {
448 return refuse(
449 CommandStatus::NotFound,
450 format!("no task {task} in the worktree's {feature_name}"),
451 );
452 }
453 };
454 let acceptance_story = native
457 .is_none()
458 .then(|| crate::acceptance_at::story_at(&s.plane_dir, &feature_name, task))
459 .flatten();
460 let story_task: Option<String> = acceptance_story.as_ref().and_then(|st| {
461 snap.feature
462 .as_ref()
463 .and_then(|f| f.tasks.as_ref())
464 .and_then(|t| {
465 t.tasks
466 .iter()
467 .find(|t| t.story.as_deref() == Some(st.as_str()))
468 })
469 .map(|t| t.id.clone())
470 });
471 let bundle = match (&acceptance_story, &story_task) {
472 (Some(st), Some(other)) => {
473 let mut b = bundle;
474 if let Ok(of) = format.bundle(&snap, other) {
475 b.story = of.story;
476 }
477 b.task.story.clone_from(st);
478 b
479 }
480 _ => bundle,
481 };
482 let Some(config_root) = verify::find_config(&project) else {
483 return refuse(
484 CommandStatus::ConfigError,
485 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
486 )
487 .with_code("CONFIG-MISSING");
488 };
489 let config_text =
490 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
491 let run_config = match substrate::run_config::parse(&config_text) {
492 Ok(c) => c,
493 Err(e) => {
494 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
495 .with_code("CONFIG-INVALID");
496 }
497 };
498 if run_config.review_required
501 && let Some(f) = snap.feature.as_ref()
502 && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
503 && !t.complete()
504 {
505 return refuse(
506 CommandStatus::NeedsHuman,
507 format!(
508 "{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",
509 t.approved, t.open, t.changes_requested, t.lapsed, t.questions
510 ),
511 )
512 .with_code("REVIEW-PENDING");
513 }
514 if run_config.review_required {
517 let ambiguous = match crate::oracle::open_ambiguities(
518 &s.plane_dir,
519 &feature_name,
520 &format.oracle_requirements(&snap),
521 ) {
522 Ok(a) => a,
523 Err(t) => {
524 return refuse(
525 CommandStatus::NeedsHuman,
526 format!(
527 "{}: whether it is read two ways is unknown and [review] required is on",
528 t.0
529 ),
530 )
531 .with_code("ORACLE-TAMPERED");
532 }
533 };
534 if let Some((req, scenario)) = ambiguous.first() {
535 return refuse(
536 CommandStatus::NeedsHuman,
537 format!(
538 "{req} is read two ways ({scenario}) and [review] required is on: choose a reading or reword it first"
539 ),
540 )
541 .with_code("REVIEW-AMBIGUOUS")
542 .with_fix(vec![
543 format!("speccoder review tests {req} --reading A {scenario}"),
544 format!("speccoder review tests {req} --reading B {scenario}"),
545 ]);
546 }
547 }
548 let executor = match external::parse(&config_text) {
549 Ok(e) => e,
550 Err(e) => {
551 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
552 .with_code("CONFIG-INVALID");
553 }
554 };
555 let executor = match options.agent.as_deref() {
557 None => executor,
558 Some("native") => None,
559 Some(id) => match external::for_agent(id, executor.as_ref()) {
560 Ok(e) => Some(e),
561 Err(e) => return refuse(CommandStatus::UsageError, e),
562 },
563 };
564 let sandbox_config = match sandbox::parse(&config_text) {
566 Ok(c) => c,
567 Err(e) => {
568 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
569 .with_code("CONFIG-INVALID");
570 }
571 };
572 let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
573 Ok(r) => r,
574 Err(e) => return refuse(CommandStatus::ConfigError, e),
575 };
576 if options.mode == speccoder_core::oracle::RunMode::Autopilot && !runner.is_sandboxed() {
578 return refuse(
579 CommandStatus::ConfigError,
580 "autopilot needs the OS sandbox, and commands here would run unsandboxed".to_owned(),
581 )
582 .with_code("SANDBOX-UNAVAILABLE");
583 }
584 let mutation_config = match mutation::parse(&config_text) {
585 Ok(c) => c,
586 Err(e) => {
587 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
588 .with_code("CONFIG-INVALID");
589 }
590 };
591 let worktree_config = match crate::prepare::parse(&config_text) {
592 Ok(c) => c,
593 Err(e) => {
594 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
595 .with_code("CONFIG-INVALID");
596 }
597 };
598 let check_argvs = verify::check_argvs(&config_root);
599 let unit_dir = snap
603 .feature
604 .as_ref()
605 .map(|f| f.feature.dir.clone())
606 .unwrap_or_default();
607 let changed_code = crate::attempts::list(&wt_root, &feature_name)
608 .iter()
609 .any(|a| a.task == task && a.outcome == CHANGED_CODE);
610 let planning: Option<String> = parsed_task
611 .as_ref()
612 .filter(|t| {
613 native.is_none()
614 && !changed_code
615 && speccoder_core::lane::by_paths(&t.title, &unit_dir, &format.rules().judged)
616 == speccoder_core::lane::Lane::Planning
617 })
618 .map(|_| {
619 let prefix = project
620 .strip_prefix(&wt_root)
621 .map(|p| p.to_string_lossy().replace('\\', "/"))
622 .unwrap_or_default();
623 if prefix.is_empty() {
624 unit_dir.trim_end_matches('/').to_owned()
625 } else {
626 format!("{prefix}/{}", unit_dir.trim_end_matches('/'))
627 }
628 });
629 let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow)
630 .with_prefixes(run_config.allow_prefix.clone())
631 .rules(format.rules());
632 let policy = match &planning {
633 Some(dir) => policy.planning(dir),
634 None => policy,
635 };
636 let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
639 {
640 Some(s) => s.confine(policy, runner),
641 None => (policy, runner, None),
642 };
643 let sandbox_notice = sandbox_notice.or(carve_notice);
644 if let Some(e) = options.session.as_ref().and_then(|s| {
647 s.lock().ok().and_then(|s| {
648 if interrupted.is_some() {
650 s.check_before_tick(&policy).err()
651 } else {
652 s.check(&policy).err()
653 }
654 })
655 }) {
656 return e.result("run");
657 }
658
659 let wt_root_canonical = std::fs::canonicalize(&wt_root)
660 .unwrap_or_else(|_| wt_root.clone())
661 .display()
662 .to_string();
663
664 let this_run = options.run();
667 let (decisions, task_grants) = match approvals::Decisions::load(&s.plane_dir) {
668 Ok(mut d) => {
669 if !autopilot
672 && d.claim(&feature_name, task, this_run.as_str())
673 && let Err(e) = d.save(&s.plane_dir)
674 {
675 return refuse(CommandStatus::Error, e);
676 }
677 (
678 d.for_task(&feature_name, task),
679 if autopilot {
680 Vec::new()
681 } else {
682 d.for_run(&feature_name, task, this_run.as_str())
683 },
684 )
685 }
686 Err(e) => return refuse(CommandStatus::ConfigError, e),
687 };
688
689 if let Some((stage, last_call)) = &resumed_from {
690 live.event(&progress::Event::Resumed {
691 task: task.to_owned(),
692 stage: *stage,
693 last_call: last_call.clone(),
694 });
695 }
696 live.event(&progress::Event::TaskStarted {
697 feature: feature_name.clone(),
698 task: task.to_owned(),
699 title: parsed_task
700 .as_ref()
701 .map(|t| t.title.clone())
702 .unwrap_or_default(),
703 });
704 if planning.is_some()
705 && let Some(t) = &parsed_task
706 {
707 live.event(&progress::Event::Notice {
708 text: format!(
709 "📐 planning · writes {}",
710 speccoder_core::lane::task_paths(&t.title, &unit_dir).join(", ")
711 ),
712 });
713 }
714 let fail = |status: CommandStatus, m: String| {
716 live.event(&progress::Event::TaskFinished {
717 task: task.to_owned(),
718 outcome: "error".to_owned(),
719 message: redact(&m, secret),
720 run: None,
721 input_tokens: 0,
722 output_tokens: 0,
723 cache_read_tokens: 0,
724 cache_write_tokens: 0,
725 usd: None,
726 regression: None,
727 });
728 refuse(status, m)
729 };
730 let mut prepare_notes: Vec<String> = Vec::new();
734 let mut copied_files: Vec<String> = Vec::new();
737 if options.session.is_none() {
738 match crate::include::copy(
739 &wt_root,
740 &project,
741 Path::new(&s.snapshot.root),
742 &worktree_config.include,
743 ) {
744 Ok(c) => {
745 copied_files.clone_from(&c.files);
746 if !c.now.is_empty() {
747 prepare_notes.push(format!(
748 "copied from your checkout ([worktree] include): {}",
749 c.now.join(", ")
750 ));
751 }
752 prepare_notes.extend(
753 c.skipped
754 .iter()
755 .map(|n| format!("not copied ([worktree] include): {n}")),
756 );
757 for n in &prepare_notes {
758 live.event(&progress::Event::Notice { text: n.clone() });
759 }
760 }
761 Err(e) => {
762 return fail(
763 CommandStatus::ConfigError,
764 format!("the files [worktree] include names could not be copied: {e}"),
765 )
766 .with_code("PREPARE-FAILED");
767 }
768 }
769 }
770 let prepares =
771 options.session.is_none() && worktree_config.prepare && crate::prepare::needed(&project);
772 if prepares {
773 let began = std::time::Instant::now();
774 stage(
775 &*live,
776 task,
777 Stage::Prepare,
778 StageState::Started,
779 None,
780 None,
781 );
782 let prepared = crate::prepare::prepare(&crate::prepare::Prepare {
783 worktree: &wt_root,
784 project: &project,
785 config: &worktree_config,
786 runner: &runner,
787 state: Some(state),
788 });
789 match prepared {
790 Ok(None) => {}
791 Ok(Some(p)) => {
792 stage(
793 &*live,
794 task,
795 Stage::Prepare,
796 if p.done.is_empty() {
797 StageState::Skipped
798 } else {
799 StageState::Passed
800 },
801 Some(if p.done.is_empty() {
802 "reused: the lockfiles are unchanged".to_owned()
803 } else {
804 p.done.join(", ")
805 }),
806 Some(began),
807 );
808 }
809 Err(f) => {
810 stage(
811 &*live,
812 task,
813 Stage::Prepare,
814 StageState::Failed,
815 Some(f.message.clone()),
816 Some(began),
817 );
818 return fail(CommandStatus::ConfigError, f.message)
819 .with_code("PREPARE-FAILED")
820 .with_fix(f.fix)
821 .with_commands(f.commands);
822 }
823 }
824 } else if options.session.is_none() {
825 crate::prepare::forget(&wt_root);
827 }
828
829 let began = std::time::Instant::now();
831 stage(
832 &*live,
833 task,
834 Stage::Baseline,
835 StageState::Started,
836 None,
837 None,
838 );
839 let mut baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner)
840 {
841 Ok(b) => b,
842 Err(e) => {
843 stage(
844 &*live,
845 task,
846 Stage::Baseline,
847 StageState::Failed,
848 None,
849 Some(began),
850 );
851 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
852 }
853 };
854 if !options.allow_failing_baseline
860 && !super::story::open_oracle(&wt_root, &snap, format)
861 && let Some((c, why)) = baseline
862 .checks
863 .iter()
864 .find_map(|c| broken(c).map(|w| (c, w)))
865 {
866 stage(
867 &*live,
868 task,
869 Stage::Baseline,
870 StageState::Failed,
871 Some(format!("`{}` cannot run", c.name)),
872 Some(began),
873 );
874 let checkout = std::path::Path::new(&s.snapshot.root);
877 if let Some(remedy) = crate::code::setup_remedy(checkout, Some(&feature_name)) {
878 return fail(
879 CommandStatus::GitPrecondition,
880 format!(
881 "check `{}` cannot run in the worktree: your checkout has not committed its setup — {why}",
882 c.name
883 ),
884 )
885 .with_code("SETUP-INCOMPLETE")
886 .with_fix(vec!["speccoder fix".to_owned(), remedy.command.clone()])
887 .with_remedy(Some(remedy));
888 }
889 return fail(
890 CommandStatus::ConfigError,
891 if why.contains("git add") || why.contains("--allow-failing-baseline") {
894 format!(
895 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why}",
896 c.name
897 )
898 } else {
899 format!(
900 "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",
901 c.name
902 )
903 },
904 )
905 .with_code("CHECK-BROKEN");
906 }
907 let failing = baseline
909 .checks
910 .iter()
911 .filter(|c| c.status.is_failure())
912 .count();
913 stage(
914 &*live,
915 task,
916 Stage::Baseline,
917 StageState::Passed,
918 (failing > 0).then(|| format!("{failing} check(s) already failing")),
919 Some(began),
920 );
921
922 let rt = match tokio_runtime() {
923 Ok(rt) => rt,
924 Err(e) => return fail(CommandStatus::RunFailed, e),
925 };
926 let bundle_value = match serde_json::to_value(&bundle) {
927 Ok(v) => v,
928 Err(e) => return fail(CommandStatus::Error, e.to_string()),
929 };
930 let instructions = crate::instructions::collect(
934 &wt_root,
935 &project,
936 &speccoder_core::scope::named_paths(&bundle.task.title),
937 &run_config.instructions,
938 );
939 let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
940 &instructions
941 } else {
942 &[]
943 };
944
945 let servers = match mcp::parse(&config_text) {
947 Ok(s) => s,
948 Err(e) => {
949 return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
950 .with_code("CONFIG-INVALID");
951 }
952 };
953 let mut connected = if servers.is_empty() {
954 mcp::Connected::default()
955 } else {
956 live.event(&progress::Event::Notice {
957 text: format!("starting {} MCP server(s)", servers.len()),
958 });
959 rt.block_on(mcp::connect(&servers, &project, &runner))
960 };
961 let approved_tools: Vec<String> = task_grants
963 .iter()
964 .filter_map(|g| g.tool_name().map(str::to_owned))
965 .collect();
966 let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
967 connected.notices.extend(pinned);
968
969 let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
972 Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
973 Err(e) => return fail(CommandStatus::Error, e.to_string()),
974 };
975 let swept =
978 match crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc()) {
979 Ok(p) => p,
980 Err(e) => {
981 return fail(
982 CommandStatus::RunFailed,
983 format!("the calls past their deadline could not be denied: {e}"),
984 );
985 }
986 };
987 let present = crate::approvals::PresentContext {
989 root: None,
990 sandbox: runner.is_sandboxed().then(|| {
991 if cfg!(target_os = "macos") {
992 "Seatbelt".to_owned()
993 } else {
994 "Landlock+seccomp".to_owned()
995 }
996 }),
997 network: match sandbox_config.network {
998 sandbox::Network::Off => "none",
999 sandbox::Network::Loopback => "loopback",
1000 sandbox::Network::On => "unrestricted",
1001 }
1002 .to_owned(),
1003 file_text: None,
1004 };
1005 let plane = match rt.block_on(async {
1006 substrate::Plane::open_with(
1007 &plane_dir,
1008 &s.model,
1009 provider,
1010 Some(tools_server),
1011 substrate::Hooks {
1012 progress: Some(Arc::clone(&live)),
1013 mcp: connected.clients.clone(),
1014 mcp_tools: connected.tools.clone(),
1015 format: Some(format),
1016 present: present.clone(),
1017 },
1018 )
1019 }) {
1020 Ok(p) => p,
1021 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1022 };
1023 let mut notices: Vec<String> = elsewhere
1024 .replaced_journal
1025 .iter()
1026 .chain(swept.iter())
1027 .chain(plane.replaced_journal.iter())
1028 .map(|p| {
1029 format!(
1030 "the journal could not be read by this build and was moved to {}; past runs stay in git",
1031 p.display()
1032 )
1033 })
1034 .chain(connected.notices.iter().cloned())
1035 .chain(sandbox_notice.clone())
1036 .chain(prepare_notes.iter().cloned())
1037 .chain(resumed_from.as_ref().map(|(stage, last_call)| {
1038 format!(
1039 "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
1040 stage.map_or("a run", speccoder_core::stage::Stage::name),
1041 last_call
1042 .as_ref()
1043 .map(|c| format!(" ({c})"))
1044 .unwrap_or_default()
1045 )
1046 }))
1047 .collect();
1048
1049 let demand_red =
1052 run_config.acceptance && planning.is_none() && format.is_convergence(&bundle.task.phase);
1053 let start = match &interrupted {
1059 Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
1060 Ok(tree) => worktree::Checkpoint(tree),
1061 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1062 },
1063 None => match worktree::checkpoint(&wt_root, &tmp) {
1064 Ok(c) => c,
1065 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1066 },
1067 };
1068
1069 let began = std::time::Instant::now();
1070 let bug_ctx = super::bug::Ctx {
1075 rt: &rt,
1076 plane: &plane,
1077 project: &project,
1078 wt_root: &wt_root,
1079 wt_path: &wt_path,
1080 tmp: &tmp,
1081 policy: &policy,
1082 runner: &runner,
1083 state: &s.plane_dir,
1084 feature: &feature_name,
1085 task,
1086 bundle: &bundle_value,
1087 instructions: &instructions,
1088 live: &*live,
1089 approver: approver.as_deref(),
1090 held_out: run_config.held_out,
1091 max_tries: run_config.reproduce_tries,
1092 bug_test: run_config.bug_test.as_deref(),
1093 start: &start,
1094 };
1095 let mut bug_fix = match super::bug::phase(&s.plane_dir, &feature_name, task, &project, format) {
1096 Err(e) => return fail(CommandStatus::NeedsHuman, e),
1097 Ok(None) => None,
1098 Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
1099 Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
1100 match super::bug::reproduce(&bug_ctx, *b) {
1101 Ok(f) => Some(f),
1102 Err(stop) => return *stop,
1103 }
1104 }
1105 Ok(Some(super::bug::Phase::Reproduce(_))) => None,
1106 };
1107 let bug_candidate = bug_fix.as_ref().and_then(|f| {
1108 f.bug.candidate.clone().and_then(|c| {
1109 crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
1110 .candidate_patch
1111 .map(|d| (d, c))
1112 })
1113 });
1114 let test_layout = crate::layout::detect(&project, &check_argvs);
1117 let test_layout_text = test_layout.text();
1118 let story_scenarios = match &bug_fix {
1119 Some(f) => super::bug::scenarios(&f.bug),
1120 None => format.scenarios(&snap, story_task.as_deref().unwrap_or(task)),
1121 };
1122 let rules = format.rules().judged;
1128 let whole_unit = format.kind() == speccoder_core::spec::FormatKind::Openspec;
1129 let linked_story: Option<(crate::format::OracleRequirement, Vec<String>)> = if bug_fix.is_some()
1133 || acceptance_story.is_some()
1134 || whole_unit
1135 || parsed_task.as_ref().is_none_or(|t| t.story.is_some())
1136 {
1137 None
1138 } else {
1139 parsed_task.as_ref().and_then(|t| {
1140 super::acceptance::linked_story(
1141 t,
1142 &story_scenarios,
1143 &unit_dir,
1144 &rules,
1145 &format.oracle_requirements(&snap),
1146 )
1147 })
1148 };
1149 let story_scenarios = linked_story
1150 .as_ref()
1151 .map_or(story_scenarios, |(r, _)| r.scenarios.clone());
1152 let story_key: Option<String> = if bug_fix.is_some() || story_scenarios.is_empty() {
1155 None
1156 } else {
1157 acceptance_story
1158 .clone()
1159 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone()))
1160 .or_else(|| whole_unit.then(|| feature_name.clone()))
1161 .or_else(|| linked_story.as_ref().map(|(r, _)| r.key.clone()))
1162 };
1163 let parsed_tasks = snap.feature.as_ref().and_then(|f| f.tasks.as_ref());
1164 let story_members: Vec<&speccoder_core::snapshot::Task> = match (&story_key, parsed_tasks) {
1165 (Some(key), Some(pt)) => pt
1166 .tasks
1167 .iter()
1168 .filter(|t| whole_unit || t.story.as_deref() == Some(key.as_str()))
1169 .collect(),
1170 _ => Vec::new(),
1171 };
1172 let judged_by: Option<speccoder_core::story::Judged> = if bug_fix.is_some() {
1173 None
1174 } else if let Some((_, links)) = &linked_story {
1175 Some(speccoder_core::story::Judged::Linked {
1176 scenarios: links.clone(),
1177 })
1178 } else if story_key.is_some() {
1179 let ids: Vec<String> = story_scenarios.iter().map(|s| s.id.clone()).collect();
1180 let st = super::story::story_tasks(&story_members, &unit_dir, &rules);
1181 speccoder_core::story::judged(&st, task, &ids).or_else(|| {
1182 acceptance_story
1183 .is_some()
1184 .then_some(speccoder_core::story::Judged::Progress)
1185 })
1186 } else {
1187 parsed_task
1188 .as_ref()
1189 .and_then(|t| super::acceptance::outside_story(t, &story_scenarios, &unit_dir, &rules))
1190 };
1191 let in_story =
1194 story_key.is_some() && judged_by.is_some() && run_config.acceptance && planning.is_none();
1195 let scenarios: Vec<speccoder_core::spec::ScenarioRef> = match &judged_by {
1196 Some(speccoder_core::story::Judged::Linked { scenarios: l }) if story_key.is_none() => {
1197 story_scenarios
1198 .iter()
1199 .filter(|s| l.contains(&s.id))
1200 .cloned()
1201 .collect()
1202 }
1203 _ => story_scenarios.clone(),
1204 };
1205 let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
1208 let mut review_reuse = None;
1209 let mut story_oracle: Option<super::story::StoryOracle> = None;
1214 let mut oracle_note = serde_json::Value::Null;
1216 let mut oracle_empty = false;
1218 let mut before_run = super::story::Run::default();
1219 let mut oracle_spent: Option<serde_json::Value> = None;
1221 if in_story && let Some(key) = story_key.as_deref() {
1222 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1223 match super::story::ensure(&super::story::Ensure {
1224 key,
1225 scenarios: &story_scenarios,
1226 feature: &feature_name,
1227 project: &project,
1228 wt_root: &wt_root,
1229 state,
1230 plane_dir: &s.plane_dir,
1233 runner: &runner,
1234 rt: &rt,
1235 plane: &plane,
1236 options,
1237 format,
1238 snapshot: &snap,
1239 fresh: options.fresh_acceptance,
1240 resuming: interrupted.is_some(),
1241 }) {
1242 Ok((oracle, wrote, said)) => {
1243 notices.extend(said);
1244 oracle_spent.clone_from(&oracle.usage);
1245 if wrote {
1246 notices.push(format!(
1247 "{key}'s oracle: {} test(s) for {} scenario(s), committed {}",
1248 oracle.tests.len(),
1249 oracle.scenarios.len(),
1250 oracle.commit.get(..7).unwrap_or(&oracle.commit)
1251 ));
1252 head_now = git::Git::at(&wt_root)
1255 .line(&["rev-parse", "HEAD"])
1256 .unwrap_or_default();
1257 match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
1260 Ok(b) => baseline = b,
1261 Err(e) => {
1262 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
1263 }
1264 }
1265 }
1266 before_run = interrupted
1269 .as_ref()
1270 .and_then(|m| m.story_before.clone())
1271 .and_then(|v| serde_json::from_value(v).ok())
1272 .unwrap_or_else(|| super::story::run(&project, &oracle, &runner));
1273 if !oracle.tests.is_empty() {
1277 oracle_note = serde_json::json!({
1278 "red": oracle
1279 .scenarios
1280 .iter()
1281 .filter(|s| {
1282 before_run.states.get(&s.id)
1283 != Some(&speccoder_core::story::ScenarioState::Green)
1284 && !oracle.untestable.contains_key(&s.id)
1285 })
1286 .map(|s| serde_json::json!({
1287 "id": s.id,
1288 "contract": oracle.contracts.get(&s.id),
1289 "tests": oracle
1290 .tests
1291 .iter()
1292 .filter(|t| t.scenarios.contains(&s.id))
1293 .map(|t| t.path.clone())
1294 .collect::<Vec<_>>(),
1295 }))
1296 .collect::<Vec<_>>(),
1297 "links": judged_by.as_ref().map(speccoder_core::story::Judged::links).unwrap_or_default(),
1298 "checkpoint": matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint)),
1299 });
1300 }
1301 if let Some((record, manifest)) = oracle.reviewed.clone() {
1303 review_frozen = Some((record, manifest));
1304 }
1305 if let Some((_, links)) = &linked_story
1308 && !oracle.tests.is_empty()
1309 && links.iter().all(|l| {
1310 before_run.states.get(l)
1311 == Some(&speccoder_core::story::ScenarioState::Green)
1312 })
1313 {
1314 stage(
1315 &*live,
1316 task,
1317 Stage::Red,
1318 StageState::Skipped,
1319 Some("already holds".to_owned()),
1320 None,
1321 );
1322 let reason = format!(
1323 "{} already hold{} — {key}'s oracle passes {}: nothing for {task} to do",
1324 links.join(", "),
1325 if links.len() == 1 { "s" } else { "" },
1326 if links.len() == 1 {
1327 "its test"
1328 } else {
1329 "their tests"
1330 },
1331 );
1332 let report = speccoder_core::RunReport {
1333 reason: Some(reason.clone()),
1334 notices: notices.clone(),
1335 usage: oracle.usage.clone(),
1336 ..bare_report(&feature_name, task, &wt_path, "holds")
1337 };
1338 return CommandResult::new("run", CommandStatus::NeedsHuman, &reason)
1339 .with_code("TASK-HOLDS")
1340 .with_data(CommandData::Run(Box::new(report)));
1341 }
1342 if matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
1345 && !oracle.untestable.is_empty()
1346 {
1347 let accepted = super::story::accepted_on_branch(&wt_root, "HEAD");
1348 let open: Vec<(&String, &String)> = oracle
1349 .untestable
1350 .iter()
1351 .filter(|(id, _)| !accepted.contains(id))
1352 .collect();
1353 if let Some((first, _)) = open.first() {
1354 stage(
1355 &*live,
1356 task,
1357 Stage::Red,
1358 StageState::Skipped,
1359 Some("untestable".to_owned()),
1360 None,
1361 );
1362 let said: Vec<String> = open
1363 .iter()
1364 .map(|(id, why)| format!("{id} (\"{why}\")"))
1365 .collect();
1366 let reason = format!(
1367 "{key} closes with {task}, but its test author called {} untestable — a person accepts each, or has its test written again",
1368 said.join(", ")
1369 );
1370 let report = speccoder_core::RunReport {
1371 reason: Some(reason.clone()),
1372 notices: notices.clone(),
1373 usage: oracle.usage.clone(),
1374 ..bare_report(&feature_name, task, &wt_path, "untestable")
1375 };
1376 return CommandResult::new("run", CommandStatus::NeedsHuman, &reason)
1377 .with_code("STORY-UNTESTABLE")
1378 .with_fix(vec![
1379 format!("speccoder story accept {first} --reason \"…\""),
1380 format!("speccoder task rule {task} --tests-wrong --scenario {first} --reason \"…\""),
1381 ])
1382 .with_data(CommandData::Run(Box::new(report)));
1383 }
1384 }
1385 if oracle.tests.is_empty() {
1386 oracle_empty = true;
1387 } else {
1388 story_oracle = Some(oracle);
1389 }
1390 }
1391 Err(r) => {
1392 stage(
1393 &*live,
1394 task,
1395 Stage::Red,
1396 StageState::Failed,
1397 Some(r.message().to_owned()),
1398 None,
1399 );
1400 let reason = format!(
1401 "the acceptance tests could not be written ({}); nothing was attempted",
1402 r.message()
1403 );
1404 let report = speccoder_core::RunReport {
1405 reason: Some(redact(&reason, secret)),
1406 notices: notices.clone(),
1407 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1408 };
1409 let code = r.code().map_or("ORACLE-UNWRITTEN", |c| c.name);
1410 return CommandResult::new(
1411 "run",
1412 CommandStatus::NeedsHuman,
1413 &redact(&reason, secret),
1414 )
1415 .with_code(code)
1416 .with_fix(if r.given_fix().is_empty() {
1418 Vec::new()
1419 } else {
1420 r.given_fix()
1421 .iter()
1422 .cloned()
1423 .chain([format!("speccoder run --task {task}")])
1424 .collect()
1425 })
1426 .with_data(CommandData::Run(Box::new(report)));
1427 }
1428 }
1429 }
1430 let ruled: Vec<crate::acceptance_at::Ruling> = if bug_fix.is_none() && !in_story {
1436 match crate::acceptance_at::rulings_for_task(&s.plane_dir, &feature_name, task) {
1437 Ok(r) => r,
1438 Err(e) => {
1439 return fail(
1440 CommandStatus::Error,
1441 format!("the person's rulings cannot be read: {e}"),
1442 );
1443 }
1444 }
1445 } else {
1446 Vec::new()
1447 };
1448 let told: Vec<String> = ruled
1449 .iter()
1450 .map(|r| format!("{} ruled the tests wrong: {}", r.by, r.reason))
1451 .collect();
1452 let related: Vec<String> = match (parsed_task.as_ref(), parsed_tasks) {
1455 (Some(t), Some(pt)) => super::acceptance::related(t, &pt.tasks),
1456 _ => Vec::new(),
1457 };
1458 if bug_fix.is_none()
1459 && run_config.acceptance
1460 && !options.fresh_acceptance
1461 && planning.is_none()
1462 && !in_story
1463 && ruled.is_empty()
1464 {
1465 match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
1466 Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
1467 Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
1468 "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
1469 )),
1470 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
1471 notices.push(format!("{req}: frozen oracle not present here — authoring"))
1472 }
1473 Some(crate::oracle::TaskReuse::Lapsed {
1474 req,
1475 frozen,
1476 current,
1477 }) => {
1478 if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
1479 live.event(&progress::Event::Oracle {
1480 step: progress::OracleEvent::Lapsed {
1481 unit: feature_name.clone(),
1482 requirement: req.clone(),
1483 frozen_wording_sha256: frozen,
1484 current_wording_sha256: current,
1485 },
1486 });
1487 }
1488 notices.push(format!(
1489 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
1490 ));
1491 }
1492 Some(crate::oracle::TaskReuse::Tampered(why)) => {
1493 return fail(
1494 CommandStatus::NeedsHuman,
1495 format!("{why}: the task stops for a person, nothing is ticked"),
1496 )
1497 .with_code("ORACLE-TAMPERED");
1498 }
1499 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
1500 return fail(
1501 CommandStatus::NeedsHuman,
1502 format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
1503 )
1504 .with_code("REVIEW-UNREADABLE");
1505 }
1506 None => {}
1507 }
1508 }
1509 let mut acceptance = if let Some(prepared) = interrupted
1510 .as_ref()
1511 .and_then(|m| m.acceptance.clone())
1512 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
1513 {
1514 let mut prepared = prepared;
1518 if let Some(sealed) = bug_fix
1519 .as_ref()
1520 .and_then(|f| f.record.held_out_sealed.as_ref())
1521 {
1522 prepared.held_out = Some(sealed.manifest_sha256.clone());
1523 prepared.report["held_out"] = serde_json::json!({
1524 "state": "on",
1525 "manifest": format!("sha256:{}", sealed.manifest_sha256),
1526 "kept": sealed.count,
1527 });
1528 }
1529 Some(prepared)
1530 } else if let Some(f) = &bug_fix {
1531 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1532 match super::bug::reuse(&bug_ctx, f) {
1533 Ok(a) => Some(a),
1534 Err(e) => {
1535 let _ = worktree::restore(&wt_root, &tmp, &start);
1536 stage(
1537 &*live,
1538 task,
1539 Stage::Red,
1540 StageState::Failed,
1541 Some(e.clone()),
1542 Some(began),
1543 );
1544 return fail(CommandStatus::NeedsHuman, e);
1545 }
1546 }
1547 } else if let Some(reuse) = review_reuse.take() {
1548 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1551 let crate::oracle::TaskReuse::Ready {
1552 record,
1553 files,
1554 tests,
1555 manifest,
1556 } = reuse
1557 else {
1558 return fail(CommandStatus::Error, "no frozen oracle".to_owned());
1559 };
1560 match reuse_pre_frozen(
1561 &project,
1562 &record,
1563 &files,
1564 &tests,
1565 &runner,
1566 RedRule::RunTime,
1567 Some(&SealedCheck {
1568 state: &s.plane_dir,
1569 unit: &feature_name,
1570 wt_root: &wt_root,
1571 tmp: &tmp,
1572 policy: &policy,
1573 }),
1574 ) {
1575 Ok(mut a) => {
1576 a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
1577 notices.push(format!(
1578 "acceptance tests frozen at review by {} ({}) for {}: no author call",
1579 record.decided.by, record.decided.via, record.requirement
1580 ));
1581 review_frozen = Some((*record, manifest));
1582 Some(a)
1583 }
1584 Err(e) => {
1585 let _ = worktree::restore(&wt_root, &tmp, &start);
1586 stage(
1587 &*live,
1588 task,
1589 Stage::Red,
1590 StageState::Failed,
1591 Some(e.clone()),
1592 Some(began),
1593 );
1594 return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
1595 }
1596 }
1597 } else if run_config.acceptance
1598 && planning.is_none()
1599 && !in_story
1600 && format.acceptance_testable(&bundle)
1601 {
1602 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1603 Some(prepare_acceptance(&AcceptanceSetup {
1604 rt: &rt,
1605 plane: &plane,
1606 project: &project,
1607 worktree: &wt_root,
1608 tmp: &tmp,
1609 policy: &policy,
1610 feature: &feature_name,
1611 task,
1612 bundle: bundle_value.clone(),
1613 scenarios: &scenarios,
1614 instructions: &instructions,
1615 runner: &runner,
1616 layout: Some(&test_layout),
1617 told: &told,
1618 related: &related,
1619 oracle: false,
1620 }))
1621 } else {
1622 None
1623 };
1624 if review_frozen.is_none()
1625 && acceptance
1626 .as_ref()
1627 .is_some_and(|a| a.report["source"] == "review")
1628 && let Some(crate::oracle::TaskReuse::Ready {
1629 record, manifest, ..
1630 }) = review_reuse.take()
1631 {
1632 review_frozen = Some((*record, manifest));
1633 }
1634 let held_out_store = match (&bug_fix, &review_frozen) {
1637 (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1638 (None, Some((r, _))) => {
1639 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1640 }
1641 (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1642 };
1643 if let Some(a) = acceptance
1644 .as_mut()
1645 .filter(|a| a.report.get("held_out").is_none())
1646 {
1647 if !run_config.held_out {
1648 a.report["held_out"] = serde_json::json!({ "state": "off" });
1649 } else if a.argv.is_none() {
1650 a.report["held_out"] =
1651 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1652 } else if !a.red {
1653 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1654 } else if scenarios.is_empty() {
1655 a.report["held_out"] =
1656 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1657 } else {
1658 let (held, sha) = prepare_held_out(
1659 &AcceptanceSetup {
1660 rt: &rt,
1661 plane: &plane,
1662 project: &project,
1663 worktree: &wt_root,
1664 tmp: &tmp,
1665 policy: &policy,
1666 feature: &feature_name,
1667 task,
1668 bundle: bundle_value.clone(),
1669 scenarios: &scenarios,
1670 instructions: &instructions,
1671 runner: &runner,
1672 layout: Some(&test_layout),
1673 told: &told,
1674 related: &related,
1675 oracle: false,
1676 },
1677 a,
1678 &scenarios,
1679 &held_out_store,
1680 false,
1681 );
1682 a.report["held_out"] = held;
1683 a.held_out = sha;
1684 }
1685 }
1686 if run_config.held_out && !run_config.acceptance {
1687 notices.push(
1688 "[acceptance] held_out has no effect while generate = false: no held-out tests"
1689 .to_owned(),
1690 );
1691 }
1692 match &acceptance {
1693 None => stage(
1694 &*live,
1695 task,
1696 Stage::Red,
1697 StageState::Skipped,
1698 Some(if planning.is_some() {
1699 "planning work: design documents".to_owned()
1700 } else if in_story {
1701 "judged by its story's oracle".to_owned()
1702 } else {
1703 "no acceptance examples".to_owned()
1704 }),
1705 None,
1706 ),
1707 Some(a) if a.red => stage(
1708 &*live,
1709 task,
1710 Stage::Red,
1711 StageState::Passed,
1712 a.red_class.map(class_word),
1713 Some(began),
1714 ),
1715 Some(a) => stage(
1716 &*live,
1717 task,
1718 Stage::Red,
1719 StageState::Failed,
1720 Some(a.red_class.map_or_else(
1721 || "not demonstrated".to_owned(),
1722 |c| format!("not demonstrated ({})", class_word(c)),
1723 )),
1724 Some(began),
1725 ),
1726 }
1727
1728 if let Some(why) = acceptance
1732 .as_ref()
1733 .and_then(|a| a.generation_failed.clone())
1734 {
1735 let reason = format!(
1736 "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"
1737 );
1738 let _ = worktree::restore(&wt_root, &tmp, &start);
1739 let report = RunReport {
1740 reason: Some(reason.clone()),
1741 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1742 set_aside,
1743 notices: notices.clone(),
1744 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1745 };
1746 live.event(&finished_event(
1747 task,
1748 &report,
1749 "acceptance_failed",
1750 false,
1751 redact(&reason, secret),
1752 ));
1753 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1754 .with_code("ACCEPTANCE-UNWRITTEN")
1755 .with_data(CommandData::Run(Box::new(report)));
1756 }
1757
1758 if demand_red && story_oracle.is_none() && !acceptance.as_ref().is_some_and(|a| a.red) {
1760 let why = undemonstrated_why(acceptance.as_ref());
1761 let reason = format!(
1762 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1763 );
1764 let saved = attempts::save(
1766 &wt_root,
1767 &tmp,
1768 &feature_name,
1769 task,
1770 &attempts::Record {
1771 run: None,
1772 outcome: "not_demonstrated",
1773 reason: &reason,
1774 pending: None,
1775 usage: None,
1776 story_open: &[],
1777 own_test: None,
1778 },
1779 )
1780 .ok()
1781 .map(|s| s.reference);
1782 let _ = worktree::restore(&wt_root, &tmp, &start);
1783 let report = RunReport {
1784 reason: Some(reason.clone()),
1785 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1786 saved_attempt: saved,
1787 set_aside,
1788 notices: notices.clone(),
1789 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1790 };
1791 live.event(&finished_event(
1792 task,
1793 &report,
1794 "not_demonstrated",
1795 false,
1796 redact(&reason, secret),
1797 ));
1798 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1799 .with_data(CommandData::Run(Box::new(report)));
1800 }
1801
1802 let before = match &interrupted {
1805 Some(m) => worktree::Checkpoint(m.before.clone()),
1806 None => match worktree::checkpoint(&wt_root, &tmp) {
1807 Ok(c) => c,
1808 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1809 },
1810 };
1811 let acceptance_saved = acceptance
1812 .as_ref()
1813 .and_then(|a| serde_json::to_value(a).ok());
1814 let frozen: Vec<String> = match worktree::files(&wt_root, &before) {
1816 Ok(f) => f,
1817 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1818 }
1819 .into_iter()
1820 .filter(|p| tools::paths::is_test_path(p))
1821 .collect();
1822 let mut usage: Option<serde_json::Value> = None;
1823 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1826 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1828 let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1830 let mut refused_account = false;
1832 let (mut report, attempt_status) = loop {
1833 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1834 let mut digest: Option<serde_json::Value> = None;
1835 let mut attempt_no = interrupted
1838 .as_ref()
1839 .filter(|m| m.sample == samples.len())
1840 .map_or(1, |m| m.attempt.max(1));
1841 let (mut report, attempt_status) = loop {
1842 let hidden: Vec<String> = options
1844 .session
1845 .as_ref()
1846 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1847 .unwrap_or_default();
1848 let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1854 Ok(_) if autopilot => (Vec::new(), Vec::new()),
1857 Ok(mut d) => {
1858 let once = d.take_once(&feature_name, task, this_run.as_str());
1859 if !once.is_empty()
1860 && let Err(e) = d.save(&s.plane_dir)
1861 {
1862 return fail(CommandStatus::Error, e);
1863 }
1864 (d.for_run(&feature_name, task, this_run.as_str()), once)
1865 }
1866 Err(e) => return fail(CommandStatus::ConfigError, e),
1867 };
1868 let run_approved: Vec<crate::tools::policy::Object> = options
1870 .run_grants
1871 .lock()
1872 .map(|g| g.clone())
1873 .unwrap_or_default();
1874 let input = substrate::attempt::input(
1876 with_oracle(&bundle_value, &oracle_note),
1877 &substrate::attempt::Settings {
1878 checks: &check_argvs,
1879 allow: &run_config.allow,
1880 allow_prefix: &run_config.allow_prefix,
1881 max_turns: run_config.max_turns,
1882 max_tool_calls: run_config.max_tool_calls,
1883 reasoning: s.model.reasoning_summary,
1884 frozen: &frozen,
1885 approved: &task_grants,
1886 rejected: &decisions.rejected,
1887 run_approved: &run_approved,
1888 approved_once: &approved_once,
1889 nudges: &decisions.nudges,
1890 mcp: &connected.tools,
1891 root: &wt_root_canonical,
1892 frozen_additions: run_config.frozen_additions,
1893 asks: if options.lane.is_some() {
1896 "stop"
1897 } else {
1898 "tasks"
1899 },
1900 wait_secs: run_config.approval_wait.as_secs(),
1901 network_allow: &sandbox_config.network_allow,
1902 network_allow_prefix: &sandbox_config.network_allow_prefix,
1903 run_id: this_run.as_str(),
1904 asked: options.asked_now(),
1905 budget: run_config.approval_budget,
1906 prompts: approver.is_some() && options.lane.is_none(),
1907 in_place: options.session.is_some(),
1908 hidden: &hidden,
1909 autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1910 planning: planning.as_deref(),
1911 },
1912 digest.as_ref(),
1913 attempt_instructions,
1914 (!test_layout.runners.is_empty()).then_some(test_layout_text.as_str()),
1916 );
1917 let attempt_began = std::time::Instant::now();
1918 let attempt_detail = if run_config.best_of > 1 {
1919 format!(
1920 "{attempt_no}/{} · sample {}/{}",
1921 run_config.max_attempts,
1922 samples.len() + 1,
1923 run_config.best_of
1924 )
1925 } else {
1926 format!("{attempt_no}/{}", run_config.max_attempts)
1927 };
1928 stage(
1929 &*live,
1930 task,
1931 Stage::Attempt,
1932 StageState::Started,
1933 Some(attempt_detail.clone()),
1934 None,
1935 );
1936 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1939 let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1942 let held: Vec<String> = bug_fix
1943 .as_ref()
1944 .and_then(|f| {
1945 f.bug
1946 .held_out_sealed
1947 .clone()
1948 .map(|h| (f.bug.unit.clone(), h))
1949 })
1950 .and_then(|(unit, h)| {
1951 crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1952 })
1953 .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1954 .unwrap_or_default();
1955 match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1956 Ok(x) => x,
1957 Err(stop) => return *stop,
1958 }
1959 } else if let Some(exec) = &executor {
1960 let allow: Vec<Vec<String>> = check_argvs
1961 .iter()
1962 .chain(&run_config.allow)
1963 .cloned()
1964 .collect();
1965 let prompt = external::prompt_in(
1966 format.wording(),
1967 &with_oracle(&bundle_value, &oracle_note),
1968 digest.as_ref(),
1969 );
1970 let withheld = acceptance
1971 .as_ref()
1972 .and_then(|a| a.held_out.as_ref())
1973 .map(|_| held_out_store.as_path());
1974 if withheld.is_some()
1975 && !notices
1976 .iter()
1977 .any(|n| n.starts_with("held-out set withheld"))
1978 {
1979 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());
1980 }
1981 let _ = allow;
1982 let o = match &exec.kind {
1983 external::Kind::Command(argv) => {
1984 external::run(argv, exec.timeout, &wt_root, &prompt, &tmp)
1985 }
1986 external::Kind::Acp(agent) => {
1987 let policy = match substrate::attempt::policy_of(
1990 &input.peek()["settings"],
1991 format.rules(),
1992 ) {
1993 Ok(p) => p,
1994 Err(e) => return fail(CommandStatus::RunFailed, e.to_string()),
1995 };
1996 let tools = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
1997 Ok(t) => t.with_runner(runner.clone()),
1998 Err(e) => {
1999 return fail(
2000 CommandStatus::Error,
2001 format!("{}: {e}", wt_root.display()),
2002 );
2003 }
2004 };
2005 let governor = Arc::new(crate::acp::govern::Governor::new(
2006 crate::acp::govern::Setup {
2007 root: std::fs::canonicalize(&wt_root)
2008 .unwrap_or_else(|_| wt_root.clone()),
2009 tools,
2010 policy,
2011 approver: approver.clone().filter(|_| options.lane.is_none()),
2013 present: present.clone(),
2014 task: format!("{feature_name}/{task}"),
2015 task_title: parsed_task
2016 .as_ref()
2017 .map(|t| t.title.clone())
2018 .unwrap_or_default(),
2019 run: Some(this_run.to_string()),
2020 frozen_additions: run_config.frozen_additions,
2021 run_approved: run_approved.clone(),
2022 approved_once: approved_once.clone(),
2023 progress: Some((task.to_owned(), Arc::clone(&live))),
2024 copied: copied_files.clone(),
2025 agent: agent.id.clone(),
2026 },
2027 ));
2028 let a = external::run_acp(
2029 exec,
2030 agent,
2031 &wt_root,
2032 &prompt,
2033 &runner,
2034 governor,
2035 Some((task.to_owned(), Arc::clone(&live))),
2036 );
2037 if let Some(ways) = &a.sign_in {
2038 return login(fail, &a.agent, agent, ways);
2039 }
2040 for n in a.outcome.output["acp"]["notices"]
2041 .as_array()
2042 .into_iter()
2043 .flatten()
2044 .filter_map(serde_json::Value::as_str)
2045 {
2046 notices.push(format!("{}: {n}", a.agent));
2047 }
2048 a.outcome
2049 }
2050 };
2051 (o.run_id, true, o.output)
2052 } else {
2053 let key = match &interrupted {
2055 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
2056 m.key.clone()
2057 }
2058 _ => resume::new_key(&feature_name, task),
2059 };
2060 let _ = resume::save(
2061 &marker_path,
2062 &resume::Marker {
2063 key: key.clone(),
2064 before: before.0.clone(),
2065 head: head_now.clone(),
2066 attempt: attempt_no,
2067 sample: samples.len(),
2068 acceptance: acceptance_saved.clone(),
2069 story_before: story_oracle
2070 .as_ref()
2071 .and_then(|_| serde_json::to_value(&before_run).ok()),
2072 run_id: Some(this_run.to_string()),
2073 asked: options.asked_now(),
2074 run_grants: run_approved.clone(),
2075 },
2076 );
2077 marker_guard.arm();
2078 let outcome = match rt.block_on(plane.attempt_keyed(
2079 &feature_name,
2080 task,
2081 input,
2082 &key,
2083 )) {
2084 Ok((o, resumed)) => {
2085 if resumed {
2086 notices.push(format!(
2087 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
2088 o.run_id
2089 ));
2090 }
2091 o
2092 }
2093 Err(e) => return fail(CommandStatus::RunFailed, e.0),
2094 };
2095 let context = crate::approvals::PresentContext {
2099 root: Some(wt_root.clone()),
2100 ..present.clone()
2101 };
2102 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
2103 outcome,
2104 approver.as_deref(),
2105 &context,
2106 )) {
2107 Ok(x) => {
2108 notices.extend(crate::actions::allow_always_inline(
2111 path,
2112 &feature_name,
2113 &x.always,
2114 state,
2115 ));
2116 (x.outcome, x.waiting)
2117 }
2118 Err(e) => return fail(CommandStatus::RunFailed, e.0),
2119 };
2120 if let Some(task_row) = waiting {
2121 marker_guard.keep();
2124 let action = &task_row.justification.proposed_action;
2125 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
2126 report.judged.clone_from(&judged_by);
2127 report.run_id = outcome.run_id.to_string();
2128 report.notices = notices;
2129 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
2130 report.tool_calls.push(serde_json::json!({
2132 "tool": action["tool"],
2133 "arguments": action["arguments"],
2134 "decision": "approval",
2135 "reason": action["request"]["reason"],
2136 "object": action["request"]["object"],
2137 "waiting": true,
2138 "call": task_row.id.to_hex(),
2139 }));
2140 report.pending = Some(serde_json::json!({
2141 "tool": action["tool"],
2142 "arguments": action["arguments"],
2143 "reason": action["request"]["reason"],
2144 "object": action["request"]["object"],
2145 "request": action["request"],
2146 "call": task_row.id.to_hex(),
2147 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
2148 "waiting": true,
2149 "batched": action["request"]["batched"],
2150 }));
2151 if let (Some(n), Ok(mut asked)) = (
2154 action["request"]["asked"]
2155 .as_u64()
2156 .and_then(|n| u32::try_from(n).ok()),
2157 options.asked.lock(),
2158 ) {
2159 *asked = (*asked).max(n);
2160 }
2161 report.reason = Some(format!(
2162 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
2163 task_row.justification.summary.peek()
2164 ));
2165 let (status, message) = status_and_message(&report, "needs_approval", false);
2166 live.event(&finished_event(
2167 task,
2168 &report,
2169 "needs_approval",
2170 false,
2171 redact(&message, secret),
2172 ));
2173 return CommandResult::new("run", status, &redact(&message, secret))
2174 .with_data(CommandData::Run(Box::new(report)));
2175 }
2176 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
2177 refused_account = outcome
2178 .failed_effect
2179 .as_ref()
2180 .is_some_and(agentplane::journal::FailedEffect::refuses_the_account);
2181 let mut output = outcome
2182 .output
2183 .as_ref()
2184 .map(|o| o.peek().clone())
2185 .unwrap_or(serde_json::Value::Null);
2186 if !succeeded && output["reason"].as_str().is_none() {
2187 if !output.is_object() {
2188 output = serde_json::json!({});
2189 }
2190 let reason = if rt.block_on(plane.holds_a_pre_054_decision(outcome.run_id)) {
2193 substrate::PRE_054_DECISION.to_owned()
2194 } else if let agentplane::prelude::RunStatus::Exhausted(limit) = &outcome.status
2195 {
2196 output["status"] = serde_json::json!("budget_exhausted");
2198 substrate::exhausted(limit)
2199 } else if let agentplane::prelude::RunStatus::Quarantined(why) = &outcome.status
2200 {
2201 output["status"] = serde_json::json!("quarantined");
2202 why.clone()
2203 } else {
2204 substrate::why_not(&outcome.status)
2205 };
2206 output["reason"] = serde_json::Value::String(redact(&reason, secret));
2207 }
2208 if output.get("usage").is_none_or(serde_json::Value::is_null)
2211 && let Ok(Some(used)) =
2212 rt.block_on(substrate::spent(&s.plane_dir, &outcome.run_id.to_string()))
2213 {
2214 output["usage"] = used;
2215 }
2216 (outcome.run_id.to_string(), succeeded, output)
2217 };
2218 if bug_candidate.is_some()
2219 && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
2220 {
2221 c.applied = true;
2222 }
2223 if let Some(argv) = run_config.format.as_ref().filter(|_| {
2226 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
2227 }) {
2228 use verify::process::CommandRunner as _;
2229 let ran = runner.run(&verify::process::Invocation {
2230 argv: argv.clone(),
2231 cwd: project.clone(),
2232 env: verify::process::scrubbed_env(std::env::vars()),
2233 timeout: std::time::Duration::from_secs(300),
2234 output_cap: verify::process::OUTPUT_CAP,
2235 });
2236 if ran.exit_code != Some(0) {
2237 notices.push(format!(
2238 "the formatter (`{}`) did not succeed",
2239 argv.join(" ")
2240 ));
2241 }
2242 }
2243 if prepares {
2247 let began = std::time::Instant::now();
2248 match crate::prepare::recheck(&crate::prepare::Prepare {
2249 worktree: &wt_root,
2250 project: &project,
2251 config: &worktree_config,
2252 runner: &runner,
2253 state: Some(state),
2254 }) {
2255 Ok(done) if done.is_empty() => {}
2256 Ok(done) => {
2257 stage(
2258 &*live,
2259 task,
2260 Stage::Prepare,
2261 StageState::Passed,
2262 Some(format!(
2263 "{}: the attempt changed its lockfile",
2264 done.join(", ")
2265 )),
2266 Some(began),
2267 );
2268 }
2269 Err(f) => {
2270 stage(
2271 &*live,
2272 task,
2273 Stage::Prepare,
2274 StageState::Failed,
2275 Some(f.message.clone()),
2276 Some(began),
2277 );
2278 let reason = redact(&f.message, secret);
2282 let saved = attempts::save(
2283 &wt_root,
2284 &tmp,
2285 &feature_name,
2286 task,
2287 &attempts::Record {
2288 run: (!run_id.is_empty()).then_some(run_id.as_str()),
2289 outcome: "prepare_failed",
2290 reason: &reason,
2291 pending: None,
2292 usage: output.get("usage"),
2293 story_open: &[],
2294 own_test: None,
2295 },
2296 )
2297 .and_then(|saved| {
2298 worktree::restore(&wt_root, &tmp, &before).map(|_| saved)
2299 });
2300 let message = match saved {
2301 Ok(saved) => format!(
2302 "{} — the attempt is saved as {} and undone",
2303 f.message, saved.reference
2304 ),
2305 Err(e) => format!("{} — saving the attempt failed: {}", f.message, e.0),
2306 };
2307 return fail(CommandStatus::ConfigError, message)
2308 .with_code("PREPARE-FAILED")
2309 .with_fix(f.fix)
2310 .with_commands(f.commands);
2311 }
2312 }
2313 }
2314 let after = match worktree::checkpoint(&wt_root, &tmp) {
2315 Ok(c) => c,
2316 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2317 };
2318 let raw_changes = match worktree::diff(&wt_root, &before, &after) {
2320 Ok(c) => c,
2321 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2322 };
2323 if let (Some(n), Ok(mut asked)) = (
2328 output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
2329 options.asked.lock(),
2330 ) {
2331 *asked = n;
2332 }
2333 let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
2334 let patch = match worktree::patch(&wt_root, &before, &after) {
2335 Ok(p) => p,
2336 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2337 };
2338 if let Some(name) = crate::include::added_secret(&patch) {
2341 let _ = worktree::restore(&wt_root, &tmp, &before);
2342 return fail(
2343 CommandStatus::NeedsHuman,
2344 format!(
2345 "the attempt's change adds the value of {name}, copied from your checkout ([worktree] include): undone, nothing judged or committed"
2346 ),
2347 )
2348 .with_code("SECRET-IN-DIFF");
2349 }
2350 let findings = attempt_findings(&Audited {
2351 wt_root: &wt_root,
2352 policy: &policy,
2353 judged_before: &judged_before,
2354 raw_changes: &raw_changes,
2355 patch: &patch,
2356 frozen: &frozen,
2357 frozen_additions: run_config.frozen_additions,
2358 approved_writes: &approved_writes,
2359 command_drift: &output["integrity"],
2360 });
2361 let changes = changes_of(raw_changes);
2362
2363 let quarantined = !succeeded && output["status"] == "quarantined";
2368 if quarantined {
2369 output["reason"] = serde_json::Value::String(
2370 "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(),
2371 );
2372 }
2373 let mut attempt_status = if succeeded {
2374 output["status"].as_str().unwrap_or("failed").to_owned()
2375 } else if quarantined {
2376 "interrupted".to_owned()
2377 } else if output["status"] == "budget_exhausted" {
2378 "budget_exhausted".to_owned()
2379 } else {
2380 "failed".to_owned()
2381 };
2382 if attempt_status == "completed" && changes.is_empty() && output.get("acp").is_some() {
2386 attempt_status = "blocked".to_owned();
2387 output["reason"] = serde_json::json!(format!(
2388 "{} ended its turn without changing anything",
2389 output["acp"]["agent"].as_str().unwrap_or("the agent")
2390 ));
2391 }
2392 let mut report = RunReport {
2393 run_id: run_id.clone(),
2394 model_report: output.get("report").cloned(),
2395 reason: output["reason"].as_str().map(str::to_owned),
2396 pending: output.get("pending").cloned(),
2397 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
2398 changes,
2399 usage: output.get("usage").cloned(),
2400 integrity: findings.clone(),
2401 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
2402 set_aside: set_aside.clone(),
2403 notices: notices.clone(),
2404 agent: external::agent_attempt(&output),
2405 judged: judged_by.clone(),
2406 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
2407 };
2408 if let Err(e) = record_inline_approvals(
2409 &s.plane_dir,
2410 &feature_name,
2411 task,
2412 &report.tool_calls,
2413 &options.run_grants,
2414 this_run.as_str(),
2415 ) {
2416 report.notices.push(format!(
2417 "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
2418 ));
2419 }
2420 for c in report
2422 .tool_calls
2423 .iter()
2424 .filter(|c| c["approved_by"] == "person")
2425 {
2426 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
2427 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
2428 }
2429 }
2430
2431 stage(
2432 &*live,
2433 task,
2434 Stage::Attempt,
2435 if attempt_status == "completed" {
2436 StageState::Passed
2437 } else {
2438 StageState::Failed
2439 },
2440 Some(if attempt_status == "completed" {
2441 attempt_detail
2442 } else {
2443 format!("{attempt_detail} {attempt_status}")
2444 }),
2445 Some(attempt_began),
2446 );
2447 if attempt_status == "integrity" {
2450 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2451 reasons: findings.clone(),
2452 });
2453 }
2454 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
2456 if judged {
2457 let began = std::time::Instant::now();
2458 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
2459 let oracle_files: Vec<String> = story_oracle
2460 .iter()
2461 .flat_map(|o| o.tests.iter().map(|t| t.path.clone()))
2462 .collect();
2463 if let Err(e) = judge_checks(
2464 &project,
2465 &runner,
2466 (&baseline, &oracle_files),
2467 acceptance.as_ref(),
2468 &findings,
2469 &mut report,
2470 ) {
2471 stage(
2472 &*live,
2473 task,
2474 Stage::Verify,
2475 StageState::Failed,
2476 None,
2477 Some(began),
2478 );
2479 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
2480 }
2481 if let Some(o) = &story_oracle {
2484 let touched = super::story::changed(&project, o);
2487 if !touched.is_empty() {
2488 let why = format!(
2489 "the story's oracle tests were changed: {}",
2490 touched.join(", ")
2491 );
2492 report.verdict = Some(match report.verdict.take() {
2493 Some(speccoder_core::verify::Verdict::Failed { mut reasons }) => {
2494 reasons.push(why);
2495 speccoder_core::verify::Verdict::Failed { reasons }
2496 }
2497 _ => speccoder_core::verify::Verdict::Failed { reasons: vec![why] },
2498 });
2499 }
2500 let after = super::story::states(&project, o, &runner);
2501 super::story::judge(
2502 o,
2503 &before_run.states,
2504 &after,
2505 judged_by
2506 .as_ref()
2507 .map(speccoder_core::story::Judged::links)
2508 .unwrap_or_default(),
2509 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint)),
2510 &mut report,
2511 );
2512 if let Some(speccoder_core::story::Progress::Verified { turned }) =
2514 &report.progress
2515 {
2516 report.acceptance = Some(super::story::evidence(o, turned, &before_run));
2517 }
2518 }
2519 if let Some(dir) = planning.as_ref().filter(|_| {
2523 matches!(
2524 report.verdict,
2525 Some(
2526 speccoder_core::verify::Verdict::Verified
2527 | speccoder_core::verify::Verdict::RegressionOnly
2528 )
2529 )
2530 }) {
2531 let inside = format!("{dir}/");
2532 let outside: Vec<&str> = report
2533 .changes
2534 .iter()
2535 .filter(|(p, _)| !p.starts_with(&inside))
2536 .map(|(p, _)| p.as_str())
2537 .collect();
2538 let reason = if !outside.is_empty() {
2539 Some(format!(
2540 "a planning task changed code: {}",
2541 outside.join(", ")
2542 ))
2543 } else if !report.changes.iter().any(|(_, kind)| kind != "deleted") {
2544 Some(format!("wrote no planning file under {inside}"))
2545 } else {
2546 None
2547 };
2548 if !outside.is_empty() {
2549 attempt_status = CHANGED_CODE.to_owned();
2551 report.attempt_status = CHANGED_CODE.to_owned();
2552 }
2553 if let Some(r) = reason {
2554 report.verdict =
2555 Some(speccoder_core::verify::Verdict::Failed { reasons: vec![r] });
2556 }
2557 }
2558 let otherwise_passing = attempt_status == "completed"
2562 && findings.is_empty()
2563 && matches!(
2564 report.verdict,
2565 Some(
2566 speccoder_core::verify::Verdict::Verified
2567 | speccoder_core::verify::Verdict::RegressionOnly
2568 )
2569 );
2570 match judge_collection(
2571 (&project, &wt_root, &tmp),
2572 &runner,
2573 otherwise_passing,
2574 &mut report,
2575 &*live,
2576 task,
2577 ) {
2578 Some(Collected::Unknown(why)) => {
2579 attempt_status = "collection_unknown".to_owned();
2580 report.verdict = None;
2581 report.reason = Some(why);
2582 }
2583 Some(Collected::Changed(finding)) => {
2585 report.integrity.push(finding.clone());
2586 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2587 reasons: vec![finding],
2588 });
2589 attempt_status = "integrity".to_owned();
2590 }
2591 None => {}
2592 }
2593 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
2594 stage_with_tail(
2595 &*live,
2596 task,
2597 Stage::Verify,
2598 if checks_held {
2599 StageState::Passed
2600 } else {
2601 StageState::Failed
2602 },
2603 None,
2604 Some(began),
2605 report
2606 .verification
2607 .as_ref()
2608 .map(failing_tail)
2609 .unwrap_or_default(),
2610 );
2611 }
2612 if judged || attempt_status == "integrity" {
2613 stage(
2614 &*live,
2615 task,
2616 Stage::Audit,
2617 if findings.is_empty() {
2618 StageState::Passed
2619 } else {
2620 StageState::Failed
2621 },
2622 findings
2623 .first()
2624 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
2625 None,
2626 );
2627 }
2628
2629 let visibly_passed = judged
2633 && findings.is_empty()
2634 && attempt_status == "completed"
2635 && matches!(
2636 report.verdict,
2637 Some(
2638 speccoder_core::verify::Verdict::Verified
2639 | speccoder_core::verify::Verdict::RegressionOnly
2640 )
2641 );
2642 let held = acceptance
2645 .as_ref()
2646 .and_then(|a| a.held_out.clone())
2647 .map(|sha| (held_out_store.clone(), sha))
2648 .or_else(|| {
2649 story_oracle
2650 .as_ref()
2651 .filter(|_| {
2652 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
2653 })
2654 .and_then(|o| o.held_out.as_ref())
2655 .map(|h| {
2656 (
2657 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &h.key),
2658 h.manifest_sha256.clone(),
2659 )
2660 })
2661 });
2662 let at_checkpoint =
2665 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint));
2666 if at_checkpoint
2667 && visibly_passed
2668 && held.is_none()
2669 && let Some(summary) = story_oracle
2670 .as_ref()
2671 .and_then(|o| o.held_out_summary.as_ref())
2672 {
2673 let entry = report
2674 .acceptance
2675 .get_or_insert_with(|| serde_json::json!({ "source": "authored" }));
2676 entry["held_out"] = match summary.state.as_str() {
2677 "off" => serde_json::json!({ "state": "off" }),
2678 _ => serde_json::json!({
2679 "state": "absent",
2680 "reason": summary.reason,
2681 "discarded": summary
2682 .discarded
2683 .iter()
2684 .map(|r| serde_json::json!({ "reason": r }))
2685 .collect::<Vec<_>>(),
2686 }),
2687 };
2688 }
2689 if acceptance
2690 .as_ref()
2691 .and_then(|a| a.held_out.as_ref())
2692 .is_none()
2693 && let (Some((_, sha)), Some(o)) = (&held, &story_oracle)
2694 && visibly_passed
2695 {
2696 let entry = report.acceptance.get_or_insert_with(
2697 || serde_json::json!({ "source": "authored", "oracle": o.commit }),
2698 );
2699 entry["held_out"] = serde_json::json!({
2700 "state": "on",
2701 "manifest": format!("sha256:{sha}"),
2702 "kept": o.held_out.as_ref().map_or(0, |h| h.count),
2703 });
2704 }
2705 if let Some((store, sha)) = held.filter(|_| visibly_passed) {
2706 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2707 let mut known = format!(
2708 "{}\n{}\n{}",
2709 bundle_value["story"].as_str().unwrap_or_default(),
2710 bundle_value["plan"].as_str().unwrap_or_default(),
2711 bundle_value["constitution"].as_str().unwrap_or_default()
2712 );
2713 for p in report
2714 .acceptance
2715 .as_ref()
2716 .and_then(|a| a["files"].as_array().cloned())
2717 .unwrap_or_default()
2718 {
2719 if let Some(p) = p.as_str() {
2720 known.push_str(
2721 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2722 );
2723 }
2724 }
2725 match judge_held_out(
2726 &store, &sha, &scenarios, &known, &wt_root, &project, &tmp, &before, &patch,
2727 &runner, &policy,
2728 ) {
2729 Ok(j) => {
2730 let failed_ids: Vec<String> =
2731 j.failed.iter().map(|s| s.id.clone()).collect();
2732 held_out_log.push(serde_json::json!({
2733 "attempt": held_out_log.len() + 1,
2734 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
2735 "scenarios": failed_ids,
2736 }));
2737 if let Some(v) = report.verdict.take() {
2738 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
2739 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
2740 && !j.failed.is_empty()
2741 && let Some(last) = reasons.last_mut()
2742 {
2743 *last = speccoder_core::retry::held_out_reason(&j.failed);
2744 }
2745 report.verdict = Some(v);
2746 }
2747 }
2748 Err((outcome, why)) => {
2749 held_out_log.push(serde_json::json!({
2750 "attempt": held_out_log.len() + 1,
2751 "outcome": outcome,
2752 }));
2753 report.verdict = None;
2754 report.reason = Some(why);
2755 attempt_status = "held_out".to_owned();
2756 }
2757 }
2758 }
2759 if let Some(fix) = bug_fix.as_mut().filter(|_| {
2762 attempt_status == "completed"
2763 && findings.is_empty()
2764 && matches!(
2765 report.verdict,
2766 Some(
2767 speccoder_core::verify::Verdict::Verified
2768 | speccoder_core::verify::Verdict::RegressionOnly
2769 )
2770 )
2771 }) {
2772 let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
2773 let judging = super::bug::Judging {
2774 before: &before,
2775 baseline: &baseline,
2776 verification: report.verification.as_ref(),
2777 base: report
2778 .verdict
2779 .clone()
2780 .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
2781 keep: &keep,
2782 };
2783 match super::bug::judge(&bug_ctx, fix, &judging) {
2784 Ok(j) => {
2785 report.verdict = Some(j.verdict);
2786 if let Some(weak) = j.weak {
2787 report.reason = Some(format!(
2788 "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
2789 weak.scenario, weak.rationale
2790 ));
2791 bug_weak = Some(weak);
2792 attempt_status = "repro_weak".to_owned();
2793 }
2794 }
2795 Err(e) => {
2796 report.integrity.push(e.clone());
2797 report.reason = Some(e);
2798 report.verdict = None;
2799 attempt_status = "integrity".to_owned();
2800 }
2801 }
2802 }
2803 if let Some(h) = report
2804 .acceptance
2805 .as_mut()
2806 .and_then(|a| a.get_mut("held_out"))
2807 .filter(|h| h["state"] == "on")
2808 {
2809 let failed = held_out_log
2810 .iter()
2811 .filter(|o| o["outcome"] == "failed")
2812 .count();
2813 h["attempts"] = serde_json::json!(held_out_log);
2814 h["gap"] = serde_json::json!(failed > 0);
2815 }
2816
2817 let passing = attempt_status == "completed"
2819 && matches!(
2820 report.verdict,
2821 Some(
2822 speccoder_core::verify::Verdict::Verified
2823 | speccoder_core::verify::Verdict::RegressionOnly
2824 )
2825 );
2826 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
2827 stage(
2828 &*live,
2829 task,
2830 Stage::Mutation,
2831 StageState::Skipped,
2832 Some(if mutation_config.is_none() {
2833 "not configured".to_owned()
2834 } else {
2835 "suite = false: for acceptance strength only".to_owned()
2836 }),
2837 None,
2838 );
2839 }
2840 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
2841 let began = std::time::Instant::now();
2842 stage(
2843 &*live,
2844 task,
2845 Stage::Mutation,
2846 StageState::Started,
2847 None,
2848 None,
2849 );
2850 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2851 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
2852 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
2853 if m.mode == mutation::Mode::Require
2854 && let Some(reason) = mutation::failure(&result)
2855 {
2856 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2857 reasons: vec![reason],
2858 });
2859 }
2860 report.mutation = Some(result);
2861 }
2862 let held = !matches!(
2863 report.verdict,
2864 Some(speccoder_core::verify::Verdict::Failed { .. })
2865 );
2866 stage(
2867 &*live,
2868 task,
2869 Stage::Mutation,
2870 if held {
2871 StageState::Passed
2872 } else {
2873 StageState::Failed
2874 },
2875 report
2876 .mutation
2877 .as_ref()
2878 .and_then(crate::mutation::trailer)
2879 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
2880 Some(began),
2881 );
2882 }
2883
2884 usage = add_usage(usage.as_ref(), report.usage.as_ref());
2885 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
2887 (report.verdict.clone(), report.verification.as_ref())
2888 else {
2889 break (report, attempt_status);
2890 };
2891 if attempt_status != "completed" {
2892 break (report, attempt_status);
2893 }
2894 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
2895 let mut d =
2896 speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
2897 d.uncollected = report
2898 .collection
2899 .as_ref()
2900 .map(speccoder_core::collection::Collection::retry_lines)
2901 .unwrap_or_default();
2902 let fingerprint = speccoder_core::retry::fingerprint(&d);
2903 let stop = if bug_candidate.is_some() {
2904 Some("a candidate is judged, never retried".to_owned())
2905 } else {
2906 retry_stop(
2907 &findings,
2908 verification,
2909 earlier.last().map(|e| e.fingerprint.as_str()),
2910 &fingerprint,
2911 attempt_no,
2912 run_config.max_attempts,
2913 )
2914 };
2915 if let Some(stop) = stop {
2916 report.retry_stop = Some(stop);
2917 break (report, attempt_status);
2918 }
2919 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2920 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
2921 break (report, attempt_status);
2922 }
2923 earlier.push(speccoder_core::EarlierAttempt {
2924 run_id: report.run_id.clone(),
2925 attempt_status: attempt_status.clone(),
2926 verdict: report.verdict.clone(),
2927 fingerprint,
2928 digest: d.clone(),
2929 usage: report.usage.clone(),
2930 });
2931 digest = serde_json::to_value(&d).ok();
2932 attempt_no += 1;
2933 };
2934 report.earlier = earlier;
2935 if run_config.best_of <= 1 {
2936 break (report, attempt_status);
2937 }
2938 let end = match worktree::checkpoint(&wt_root, &tmp) {
2939 Ok(c) => c,
2940 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2941 };
2942 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
2943 let summary = sample_summary(&report, &attempt_status, &patch);
2944 samples.push((report, attempt_status, end, summary));
2945 if samples.len() >= run_config.best_of as usize {
2946 let best = best_sample(&samples);
2947 let summaries: Vec<serde_json::Value> = samples
2948 .iter()
2949 .enumerate()
2950 .map(|(i, s)| {
2951 let mut v = s.3.clone();
2952 v["kept"] = serde_json::json!(i == best);
2953 v
2954 })
2955 .collect();
2956 let (mut chosen, status, end, _) = samples.swap_remove(best);
2957 if best != samples.len() {
2958 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
2960 return fail(CommandStatus::GitPrecondition, e.0);
2961 }
2962 }
2963 chosen.samples = summaries;
2964 break (chosen, status);
2965 }
2966 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2967 return fail(CommandStatus::GitPrecondition, e.0);
2968 }
2969 };
2970 report.usage = super::commit::add_usage(usage.as_ref(), oracle_spent.as_ref());
2971 {
2974 use speccoder_core::context::ContextKind as K;
2975 let part = |k: &str| {
2976 bundle_value[k]
2977 .as_str()
2978 .unwrap_or_default()
2979 .as_bytes()
2980 .to_vec()
2981 };
2982 let mut m = crate::context::Manifest::new();
2983 m.push(
2984 K::Constitution,
2985 "worktree",
2986 "/bundle/constitution",
2987 &part("constitution"),
2988 )
2989 .push(K::Plan, "worktree", "/bundle/plan", &part("plan"))
2990 .push(K::Story, "worktree", "/bundle/story", &part("story"))
2991 .push(
2992 K::Task,
2993 "worktree",
2994 "/bundle/task",
2995 serde_json::to_string(&bundle_value["task"])
2996 .unwrap_or_default()
2997 .as_bytes(),
2998 )
2999 .push(
3000 K::TestLayout,
3001 "worktree",
3002 "/test_layout",
3003 test_layout_text.as_bytes(),
3004 );
3005 report.context = Some(m.finish());
3006 }
3007 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
3009
3010 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
3014 let mut attempt_status = attempt_status;
3015 let verified = attempt_status == "completed"
3016 && matches!(
3017 report.verdict,
3018 Some(speccoder_core::verify::Verdict::Verified)
3019 );
3020 let visible: Vec<Vec<String>> = match acceptance
3023 .as_ref()
3024 .filter(|a| a.red)
3025 .and_then(|a| a.argv.clone())
3026 {
3027 Some(argv) => vec![argv],
3028 None => report
3029 .acceptance
3030 .as_ref()
3031 .filter(|a| a["oracle"].is_string() && a["red"] == true)
3032 .and_then(|a| a["tests"].as_array().cloned())
3033 .unwrap_or_default()
3034 .iter()
3035 .filter_map(|t| serde_json::from_value::<Vec<String>>(t["argv"].clone()).ok())
3036 .collect(),
3037 };
3038 let visible_argv = visible.first().cloned();
3039 if verified && !run_config.strength.enabled {
3040 stage(
3041 &*live,
3042 task,
3043 Stage::Strength,
3044 StageState::Skipped,
3045 Some("strength = false".to_owned()),
3046 None,
3047 );
3048 }
3049 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
3050 use speccoder_core::strength::{Gate, gate, over_strict};
3051 let began = std::time::Instant::now();
3052 stage(
3053 &*live,
3054 task,
3055 Stage::Strength,
3056 StageState::Started,
3057 None,
3058 None,
3059 );
3060 let fin = worktree::checkpoint(&wt_root, &tmp);
3061 let patch = fin
3062 .as_ref()
3063 .ok()
3064 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
3065 .unwrap_or_default();
3066 let mut notes = Vec::new();
3067 let held_set: Option<(std::path::PathBuf, String)> = acceptance
3070 .as_ref()
3071 .and_then(|a| a.held_out.clone())
3072 .map(|sha| (held_out_store.clone(), sha))
3073 .or_else(|| {
3074 story_oracle
3075 .as_ref()
3076 .filter(|_| {
3077 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
3078 })
3079 .and_then(|o| o.held_out.as_ref())
3080 .map(|h| {
3081 (
3082 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &h.key),
3083 h.manifest_sha256.clone(),
3084 )
3085 })
3086 });
3087 let held: Vec<(crate::held_out::HeldOutFile, String)> = match held_set {
3088 None => Vec::new(),
3089 Some((store, sha)) => match crate::held_out::load_verified(&store, &sha) {
3090 Ok((manifest, contents)) => manifest
3091 .files
3092 .into_iter()
3093 .zip(contents.into_iter().map(|(_, c)| c))
3094 .collect(),
3095 Err(t) => {
3096 notes.push(format!("held-out set not overlaid: {}", t.0));
3097 Vec::new()
3098 }
3099 },
3100 };
3101 let rulings = crate::strength::rulings(&strength_dir);
3102 let mut commands = if visible.is_empty() {
3103 vec![argv]
3104 } else {
3105 visible.clone()
3106 };
3107 let measure = |commands: &[Vec<String>]| {
3108 crate::strength::measure(
3109 &crate::strength::Setup {
3110 wt_root: &wt_root,
3111 project: &project,
3112 tmp: &tmp,
3113 runner: &runner,
3114 policy: &policy,
3115 acceptance: commands,
3116 held_out: &held,
3117 mutation: mutation_config.as_ref(),
3118 patch: &patch,
3119 config: &run_config.strength,
3120 keep: &strength_dir,
3121 rulings: &rulings,
3122 },
3123 |changed| {
3124 let input = substrate::near_miss::input(
3126 &bundle_value,
3127 changed,
3128 &crate::mutation::source_patch(&patch),
3129 run_config.strength.near_miss,
3130 run_config.strength.permitted,
3131 );
3132 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
3133 Ok(o) => {
3134 let out = o
3135 .output
3136 .as_ref()
3137 .map(|v| v.peek().clone())
3138 .unwrap_or_default();
3139 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
3140 && !out["answer"].is_null();
3141 crate::strength::Authored {
3142 answer: if ok {
3143 Ok(out["answer"].clone())
3144 } else {
3145 Err(format!(
3146 "the variant author did not answer ({})",
3147 crate::substrate::why_not(&o.status)
3148 ))
3149 },
3150 run_id: Some(o.run_id.to_string()),
3151 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
3152 }
3153 }
3154 Err(e) => crate::strength::Authored {
3155 answer: Err(redact(&e.0, secret)),
3156 run_id: None,
3157 usage: None,
3158 },
3159 }
3160 },
3161 )
3162 };
3163 let mut measured = measure(&commands);
3164 let min = run_config.strength.min;
3167 if let (Ok(a), Ok(fin)) = (&measured, &fin)
3168 && gate(min, &a.combined, false) == Gate::Failed
3169 && over_strict(&a.permitted).is_empty()
3170 {
3171 let visible_tests: Vec<(String, String)> = report
3172 .acceptance
3173 .as_ref()
3174 .and_then(|a| a["files"].as_array().cloned())
3175 .unwrap_or_default()
3176 .iter()
3177 .filter_map(|p| p.as_str())
3178 .map(|p| {
3179 (
3180 p.to_owned(),
3181 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
3182 )
3183 })
3184 .collect();
3185 let setup = AcceptanceSetup {
3186 rt: &rt,
3187 plane: &plane,
3188 project: &project,
3189 worktree: &wt_root,
3190 tmp: &tmp,
3191 policy: &policy,
3192 feature: &feature_name,
3193 task,
3194 bundle: bundle_value.clone(),
3195 scenarios: &scenarios,
3196 instructions: &instructions,
3197 runner: &runner,
3198 layout: Some(&test_layout),
3199 told: &told,
3200 related: &related,
3201 oracle: false,
3202 };
3203 let weak = crate::strength::weak(a);
3204 match strengthen(&setup, &visible_tests, weak, &before, fin) {
3205 Ok(added) => {
3206 if let Some(acc) = report.acceptance.as_mut() {
3207 if let Some(files) = acc["files"].as_array_mut() {
3208 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
3209 }
3210 acc["strengthened"] = serde_json::json!({
3211 "files": added.files,
3212 "argv": added.argv,
3213 "run_id": added.report["run_id"],
3214 "usage": added.report["usage"],
3215 });
3216 }
3217 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
3218 commands.push(added.argv);
3219 let mut again = measure(&commands);
3220 if let Ok(b) = again.as_mut() {
3221 b.gate = gate(min, &b.combined, true);
3222 b.notes.insert(
3223 0,
3224 "strengthened once with added acceptance tests".to_owned(),
3225 );
3226 }
3227 measured = again;
3228 }
3229 Err(why) => {
3230 if let Ok(a) = measured.as_mut() {
3231 a.notes
3232 .insert(0, format!("could not strengthen the set: {why}"));
3233 }
3234 }
3235 }
3236 }
3237 match measured {
3238 Ok(mut a) => {
3239 if a.gate == Gate::Off {
3240 a.gate = gate(min, &a.combined, false);
3241 }
3242 a.notes.extend(notes);
3243 let findings = over_strict(&a.permitted);
3244 if !findings.is_empty() {
3245 attempt_status = "over_strict".to_owned();
3246 report.reason = Some(format!(
3247 "an acceptance test rejects an implementation the spec appears to allow: {}",
3248 findings
3249 .iter()
3250 .map(|p| format!("{} ({})", p.scenario, p.rationale))
3251 .collect::<Vec<_>>()
3252 .join("; ")
3253 ));
3254 } else if a.gate == Gate::Failed {
3255 attempt_status = "acceptance_weak".to_owned();
3256 report.reason = Some(format!(
3257 "acceptance set too weak: strength {} is below min_strength {}{}",
3258 speccoder_core::strength::summary(&a.combined, None),
3259 min.unwrap_or_default(),
3260 a.notes
3261 .first()
3262 .map(|n| format!(" ({n})"))
3263 .unwrap_or_default()
3264 ));
3265 } else {
3266 crate::strength::release(&strength_dir);
3267 }
3268 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
3270 report.strength = Some(a);
3271 }
3272 Err(e) => {
3273 report.integrity.push(e.clone());
3274 report.reason = Some(e);
3275 report.verdict = None;
3276 attempt_status = "integrity".to_owned();
3277 }
3278 }
3279 report.attempt_status.clone_from(&attempt_status);
3280 let held_up = attempt_status == "completed";
3281 stage(
3282 &*live,
3283 task,
3284 Stage::Strength,
3285 if held_up {
3286 StageState::Passed
3287 } else {
3288 StageState::Failed
3289 },
3290 report.strength.as_ref().map(|a| {
3291 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
3292 }),
3293 Some(began),
3294 );
3295 }
3296
3297 let verdict_passed = attempt_status == "completed"
3301 && matches!(
3302 report.verdict,
3303 Some(
3304 speccoder_core::verify::Verdict::Verified
3305 | speccoder_core::verify::Verdict::RegressionOnly
3306 )
3307 );
3308 let mut suspected = false;
3309 if !run_config.monitor && verdict_passed {
3310 stage(
3311 &*live,
3312 task,
3313 Stage::Monitor,
3314 StageState::Skipped,
3315 Some("not enabled".to_owned()),
3316 None,
3317 );
3318 }
3319 if run_config.monitor && verdict_passed {
3320 let began = std::time::Instant::now();
3321 stage(
3322 &*live,
3323 task,
3324 Stage::Monitor,
3325 StageState::Started,
3326 None,
3327 None,
3328 );
3329 let patch = worktree::checkpoint(&wt_root, &tmp)
3330 .and_then(|now| worktree::patch(&wt_root, &start, &now))
3331 .unwrap_or_default();
3332 let calls: Vec<serde_json::Value> = report
3333 .tool_calls
3334 .iter()
3335 .map(|c| {
3336 let tool = c["tool"].as_str().unwrap_or_default();
3337 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
3338 })
3339 .collect();
3340 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
3341 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
3342 report.monitor = Some(monitor);
3343 suspected = suspicion;
3344 stage(
3345 &*live,
3346 task,
3347 Stage::Monitor,
3348 if suspected {
3349 StageState::Failed
3350 } else {
3351 StageState::Passed
3352 },
3353 suspected.then(|| "suspects gaming".to_owned()),
3354 Some(began),
3355 );
3356 }
3357
3358 let in_place_stop = options
3361 .session
3362 .as_ref()
3363 .filter(|_| verdict_passed && !suspected)
3364 .and_then(|s| {
3365 s.lock()
3366 .ok()
3367 .and_then(|s| s.check_before_tick(&policy).err())
3368 });
3369 if let Some(e) = &in_place_stop {
3370 report.reason = Some(e.line());
3371 report.verdict = None;
3372 attempt_status = e.code.to_lowercase().replace('-', "_");
3374 report.attempt_status.clone_from(&attempt_status);
3375 }
3376 let passed = verdict_passed && !suspected && in_place_stop.is_none();
3377 if passed
3381 && matches!(
3382 report.verdict,
3383 Some(speccoder_core::verify::Verdict::RegressionOnly)
3384 )
3385 {
3386 use speccoder_core::regression::{Regression, RegressionFacts, RegressionReason, decide};
3387 let facts = RegressionFacts {
3388 acceptance_on: run_config.acceptance,
3389 story: acceptance_story
3391 .clone()
3392 .or_else(|| bug_fix.as_ref().map(|f| f.bug.requirement.clone()))
3393 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone())),
3394 scenarios: scenarios.len(),
3395 skip: acceptance.as_ref().and_then(|a| a.skip_cause.clone()),
3396 red_class: acceptance.as_ref().and_then(|a| {
3398 if a.red
3399 && report
3400 .acceptance
3401 .as_ref()
3402 .is_some_and(|r| r["green_flaky"] == true)
3403 {
3404 Some(speccoder_core::verify::RedClass::Flaky)
3405 } else {
3406 a.red_class
3407 }
3408 }),
3409 };
3410 let reason = if planning.is_some() {
3411 RegressionReason::PlanningWork
3412 } else if in_story && oracle_empty {
3413 RegressionReason::AuthorWroteNone
3414 } else if in_story {
3415 match &report.progress {
3417 Some(
3419 speccoder_core::story::Progress::Closed
3420 | speccoder_core::story::Progress::InProgress,
3421 ) if story_oracle
3422 .as_ref()
3423 .is_some_and(super::story::StoryOracle::green_from_the_start) =>
3424 {
3425 RegressionReason::AlreadyPassing
3426 }
3427 Some(speccoder_core::story::Progress::Closed) => RegressionReason::StoryVerified,
3428 _ if super::story::unchecked(&report) => RegressionReason::RegressionUnchecked,
3429 _ => RegressionReason::InProgress,
3430 }
3431 } else {
3432 decide(&facts)
3433 };
3434 report.regression = Some(Regression::of(reason));
3435 }
3436 let recorded_from = report.integrity.len();
3437 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
3438 let began = std::time::Instant::now();
3439 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3440 let message = tick_message(&TickFacts {
3441 feature: &feature_name,
3442 task,
3443 title: &t.title,
3444 report: &report,
3445 scenarios: &story_scenarios,
3446 red: acceptance
3447 .as_ref()
3448 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red()))
3449 .or_else(|| story_red(&report)),
3450 checklists_overridden,
3451 extra: bug_fix
3452 .as_ref()
3453 .map(|f| {
3454 super::bug::trailers(
3455 f,
3456 &report
3457 .verdict
3458 .as_ref()
3459 .map(super::outcome::verdict_word)
3460 .unwrap_or_default(),
3461 &crate::strength::rulings(&strength_dir),
3462 )
3463 })
3464 .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
3466 .unwrap_or_default()
3467 + &ruling_trailers(&ruled),
3468 });
3469 match &options.session {
3470 None => {
3472 match format.tick(file, task, &t.line_hash) {
3473 Ok(()) => report.ticked = true,
3474 Err(e) => report.integrity.push(format!("tick refused: {e}")),
3475 }
3476 if report.ticked && run_config.commit_per_task {
3477 match commit_attempt(&wt_root, &message) {
3478 Ok(id) => report.commit = Some(id),
3479 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3480 }
3481 }
3482 }
3483 Some(session) => {
3486 let digest =
3487 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
3488 if let Ok(mut s) = session.lock() {
3489 let done = s.commit_and_tick(
3490 &plane_dir,
3491 file,
3492 &t.line_hash,
3493 &message,
3494 &digest,
3495 || format.tick(file, task, &t.line_hash),
3496 );
3497 report.ticked = done.ticked;
3498 report.commit = done.commit;
3499 report.integrity.extend(done.problems);
3500 }
3501 }
3502 }
3503 report.instructions = attempt_instructions
3504 .iter()
3505 .map(|i| speccoder_core::InstructionFile {
3506 path: i.path.clone(),
3507 sha256: i.sha256.clone(),
3508 bytes: i.bytes,
3509 cut: i.cut,
3510 })
3511 .collect();
3512 if let Some(commit) = report
3514 .commit
3515 .clone()
3516 .filter(|_| report.ticked && run_config.attest.enabled)
3517 {
3518 let verdict = report
3519 .verdict
3520 .as_ref()
3521 .map(super::outcome::verdict_word)
3522 .unwrap_or_default();
3523 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3524 let feature_dir = snap
3525 .feature
3526 .as_ref()
3527 .map(|f| f.feature.dir.clone())
3528 .unwrap_or_default();
3529 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
3530 let tick = crate::attest::Tick {
3531 repo: &wt_root,
3532 config: &config_text,
3533 attest: &run_config.attest,
3534 format: &format!("{:?}", format.kind()).to_lowercase(),
3535 feature: &feature_name,
3536 feature_dir: &feature_dir,
3537 task,
3538 title: &t.title,
3539 line_hash: &t.line_hash,
3540 section: t.story.as_deref(),
3541 section_text: bundle_value["story"].as_str().unwrap_or_default(),
3542 requirements: &requirements,
3543 commit: &commit,
3544 verdict: &verdict,
3545 sandbox: serde_json::json!({
3546 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3547 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3548 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3549 }),
3550 executor: serde_json::json!({
3551 "kind": if bug_candidate.is_some() { "candidate".to_owned() } else { executor.as_ref().map_or_else(|| "native".to_owned(), |e| e.kind.name().to_owned()) },
3552 "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
3553 }),
3554 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3555 bug: bug_fix
3556 .as_ref()
3557 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3558 };
3559 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3560 }
3561 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
3562 stage(
3563 &*live,
3564 task,
3565 Stage::Commit,
3566 if committed {
3567 StageState::Passed
3568 } else {
3569 StageState::Failed
3570 },
3571 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3572 Some(began),
3573 );
3574 }
3575 if passed && let Some(n) = &native {
3578 let began = std::time::Instant::now();
3579 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3580 let verdict = report
3581 .verdict
3582 .as_ref()
3583 .map(super::outcome::verdict_word)
3584 .unwrap_or_default();
3585 let message = tick_message(&TickFacts {
3586 feature: &feature_name,
3587 task,
3588 title: &n.title,
3589 report: &report,
3590 scenarios: &story_scenarios,
3591 red: acceptance
3592 .as_ref()
3593 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red()))
3594 .or_else(|| story_red(&report)),
3595 checklists_overridden: false,
3596 extra: bug_fix
3597 .as_ref()
3598 .map(|f| {
3599 super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
3600 })
3601 .unwrap_or_default()
3602 + &ruling_trailers(&ruled),
3603 });
3604 match commit_attempt(&wt_root, &message) {
3605 Ok(id) => report.commit = Some(id),
3606 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3607 }
3608 if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
3609 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3610 let tick = crate::attest::Tick {
3611 repo: &wt_root,
3612 config: &config_text,
3613 attest: &run_config.attest,
3614 format: &format!("{:?}", format.kind()).to_lowercase(),
3615 feature: &feature_name,
3616 feature_dir: &n.dir,
3617 task,
3618 title: &n.title,
3619 line_hash: "",
3620 section: None,
3621 section_text: &n.story,
3622 requirements: &[],
3623 commit: &commit,
3624 verdict: &verdict,
3625 sandbox: serde_json::json!({
3626 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3627 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3628 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3629 }),
3630 executor: serde_json::json!({
3631 "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
3632 }),
3633 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3634 bug: bug_fix
3635 .as_ref()
3636 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3637 };
3638 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3639 }
3640 stage(
3641 &*live,
3642 task,
3643 Stage::Commit,
3644 if report.commit.is_some() {
3645 StageState::Passed
3646 } else {
3647 StageState::Failed
3648 },
3649 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3650 Some(began),
3651 );
3652 }
3653
3654 let unrecorded = report.integrity[recorded_from..]
3657 .iter()
3658 .find(|m| m.starts_with("tick refused") || m.starts_with("commit failed"))
3659 .cloned();
3660 let passed = passed && unrecorded.is_none();
3661 if let Some(why) = unrecorded {
3662 attempt_status = "tick_failed".to_owned();
3663 report.ticked = false;
3664 report.reason = Some(why);
3665 }
3666 if attempt_status == "repro_weak"
3671 && let Some(weak) = &bug_weak
3672 && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
3673 {
3674 report
3675 .integrity
3676 .push(format!("holding the attempt failed: {e}"));
3677 }
3678 if attempt_status == "over_strict" || attempt_status == "repro_weak" {
3679 let findings = report
3680 .strength
3681 .as_ref()
3682 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
3683 .unwrap_or_default();
3684 if attempt_status == "over_strict"
3685 && let Err(e) = crate::strength::hold(&strength_dir, &findings)
3686 {
3687 report
3688 .integrity
3689 .push(format!("holding the attempt failed: {e}"));
3690 }
3691 marker_guard.keep();
3692 }
3693 if report.ticked && !ruled.is_empty() {
3695 let _ = crate::acceptance_at::acted_on_task(&s.plane_dir, &feature_name, task);
3696 }
3697 let mut conflict: Option<String> = None;
3699 if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
3702 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
3703 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
3704 let reason = redact(&reason, secret);
3705 let story_open: Vec<String> = match &report.progress {
3710 Some(speccoder_core::story::Progress::StoryOpen { scenarios }) => scenarios.clone(),
3711 _ if outcome == "blocked" => story_oracle
3712 .as_ref()
3713 .and_then(|o| {
3714 let named = frozen_test_named(report.reason.as_deref()?)?;
3715 o.tests
3716 .iter()
3717 .find(|t| t.path == named)
3718 .map(|t| t.scenarios.clone())
3719 })
3720 .unwrap_or_default(),
3721 _ => Vec::new(),
3722 };
3723 let own_files: Vec<String> = report
3726 .acceptance
3727 .as_ref()
3728 .filter(|_| story_oracle.is_none() && story_key.is_none())
3730 .and_then(|a| a["files"].as_array())
3731 .into_iter()
3732 .flatten()
3733 .filter_map(|p| p.as_str().map(str::to_owned))
3734 .collect();
3735 let own_failed = report.verification.as_ref().is_some_and(|v| {
3736 v.checks
3737 .iter()
3738 .any(|c| c.name == "acceptance" && c.status.is_failure())
3739 });
3740 let named = (outcome == "blocked")
3741 .then(|| frozen_test_named(report.reason.as_deref()?))
3742 .flatten();
3743 let own_test = named
3744 .filter(|n| own_files.contains(n))
3745 .or_else(|| own_failed.then(|| own_files.first().cloned()).flatten());
3746 let spent = super::commit::all_usage(&report);
3748 let record = attempts::Record {
3749 run,
3750 outcome: &outcome,
3751 reason: &reason,
3752 pending: report.pending.as_ref(),
3753 usage: spent.as_ref(),
3754 story_open: &story_open,
3755 own_test: own_test.as_deref(),
3756 };
3757 conflict.clone_from(&own_test);
3758 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
3759 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
3760 {
3761 Ok(saved) => report.saved_attempt = Some(saved.reference),
3762 Err(e) => report
3763 .integrity
3764 .push(format!("saving the attempt failed: {}", e.0)),
3765 }
3766 }
3767
3768 let (status, mut message) = status_and_message(&report, &attempt_status, suspected);
3769 if status == CommandStatus::RunFailed
3772 && let Some(region) = s.model.region.as_deref()
3773 && report
3774 .reason
3775 .as_deref()
3776 .and_then(substrate::provider_refusal)
3777 .is_some()
3778 {
3779 message.push_str(&format!(
3780 " — Amazon Bedrock, region {region}, model {}",
3781 s.model.model
3782 ));
3783 }
3784 let refusal = |result: CommandResult| {
3787 if !result.has_explicit_code()
3788 && refused_account
3789 && result.status() == CommandStatus::RunFailed
3790 {
3791 result.with_code("PROVIDER-REFUSED").with_fix(
3792 substrate::model_config::provider_refused_fix(
3793 &s.model,
3794 crate::aws::detect_here().as_ref(),
3795 feature,
3796 ),
3797 )
3798 } else {
3799 result
3800 }
3801 };
3802 let offered = crate::ladder::offer_for(
3804 path,
3805 &worktree::project_state_dir(state, path),
3806 &format!("{feature_name}/{task}"),
3807 )
3808 .map(|o| progress::Event::LadderOffered {
3809 unit: o.unit,
3810 task: o.task,
3811 signals: o.signals.into_iter().map(|s| s.id).collect(),
3812 });
3813 if let Some(fix) = bug_fix.as_mut() {
3814 let done = refusal(super::bug::finished(
3815 &bug_ctx,
3816 fix,
3817 report,
3818 &attempt_status,
3819 passed,
3820 status,
3821 redact(&message, secret),
3822 ));
3823 if let Some(e) = &offered {
3824 live.event(e);
3825 }
3826 return done;
3827 }
3828 live.event(&finished_event(
3829 task,
3830 &report,
3831 &attempt_status,
3832 suspected,
3833 redact(&message, secret),
3834 ));
3835 if let Some(e) = &offered {
3836 live.event(e);
3837 }
3838 let code = collection_code(&report, &attempt_status).or_else(|| {
3840 matches!(
3841 report.progress,
3842 Some(speccoder_core::story::Progress::StoryOpen { .. })
3843 )
3844 .then_some("STORY-OPEN")
3845 });
3846 let never: Vec<(String, &str)> = match &report.progress {
3848 Some(
3849 speccoder_core::story::Progress::StoryOpen { scenarios }
3850 | speccoder_core::story::Progress::LinksOpen { scenarios },
3851 ) => scenarios
3852 .iter()
3853 .filter_map(|id| {
3854 let t = story_oracle
3855 .as_ref()?
3856 .tests
3857 .iter()
3858 .find(|t| t.scenarios.contains(id))?;
3859 let text = std::fs::read_to_string(project.join(&t.path)).ok()?;
3860 crate::integrity::never_passes(&t.path, &text).map(|why| (id.clone(), why))
3861 })
3862 .collect(),
3863 _ => Vec::new(),
3864 };
3865 for (id, why) in &never {
3866 message.push_str(&format!(" — {id}'s test can never pass: {why}"));
3867 }
3868 let rule = |id: &str| {
3869 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3870 let why = never.iter().find(|(s, _)| s == id).map_or("…", |(_, w)| *w);
3871 format!("speccoder task rule {task}{f} --tests-wrong --scenario {id} --reason \"{why}\"")
3872 };
3873 let nudge = || {
3874 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3875 format!("speccoder task nudge {task}{f} --note \"…\"")
3876 };
3877 let story_open_fix: Vec<String> = match &report.progress {
3878 Some(speccoder_core::story::Progress::StoryOpen { scenarios }) if !report.ticked => {
3880 scenarios
3881 .iter()
3882 .map(|id| rule(id))
3883 .chain(std::iter::once(nudge()))
3884 .collect()
3885 }
3886 Some(speccoder_core::story::Progress::LinksOpen { scenarios }) if !report.ticked => {
3889 let rules = scenarios.iter().map(|id| rule(id));
3890 if never.is_empty() {
3891 std::iter::once(nudge()).chain(rules).collect()
3892 } else {
3893 rules.chain(std::iter::once(nudge())).collect()
3894 }
3895 }
3896 _ => match &conflict {
3898 Some(path) if !report.ticked => {
3899 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3900 message.push_str(&format!(
3901 " — its own test `{path}` failed: if it asserts what the spec does not say, rule it wrong"
3902 ));
3903 vec![
3904 format!("speccoder task rule {task}{f} --tests-wrong --reason \"…\""),
3905 format!("speccoder task nudge {task}{f} --note \"…\""),
3906 ]
3907 }
3908 _ => Vec::new(),
3909 },
3910 };
3911 let result = CommandResult::new("run", status, &redact(&message, secret));
3912 let result = if story_open_fix.is_empty() {
3913 result
3914 } else {
3915 result.with_fix(story_open_fix)
3916 };
3917 match code {
3918 Some(c) => result.with_code(c),
3919 None => refusal(result),
3920 }
3921 .with_data(CommandData::Run(Box::new(report)))
3922}
3923
3924fn collection_code(
3927 report: &speccoder_core::RunReport,
3928 attempt_status: &str,
3929) -> Option<&'static str> {
3930 if attempt_status == "collection_unknown" {
3931 return Some("TESTS-COLLECTION-UNKNOWN");
3932 }
3933 match &report.verdict {
3934 Some(speccoder_core::verify::Verdict::Failed { reasons })
3935 if reasons
3936 .first()
3937 .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
3938 {
3939 Some("TESTS-NOT-COLLECTED")
3940 }
3941 _ => None,
3942 }
3943}
3944
3945enum Collected {
3951 Unknown(String),
3953 Changed(String),
3955}
3956
3957fn judge_collection(
3958 dirs: (&Path, &Path, &Path),
3959 runner: &sandbox::Runner,
3960 otherwise_passing: bool,
3961 report: &mut speccoder_core::RunReport,
3962 live: &dyn progress::Progress,
3963 task: &str,
3964) -> Option<Collected> {
3965 let (project, wt_root, tmp) = dirs;
3966 let before = worktree::checkpoint(wt_root, tmp).ok();
3968 let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
3969 .ok()
3970 .and_then(|t| crate::verify::config::parse(&t).ok())
3971 .unwrap_or_default()
3972 .into_iter()
3973 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
3974 .collect::<Vec<_>>();
3975 let results = report
3976 .verification
3977 .as_ref()
3978 .map(|v| v.checks.clone())
3979 .unwrap_or_default();
3980 let c = crate::collection::judge(&crate::collection::Input {
3981 root: project,
3982 changes: &report.changes,
3983 checks: &checks,
3984 results: &results,
3985 runner,
3986 listings: otherwise_passing,
3987 })?;
3988 if !c.listing.is_empty()
3989 && let Some(before) = &before
3990 {
3991 let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
3992 .and_then(|after| worktree::diff(wt_root, before, &after))
3993 .map(|d| {
3994 d.into_iter()
3995 .map(|c| c.path)
3996 .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
3997 .collect()
3998 })
3999 .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
4000 if !changed.is_empty() {
4001 report.collection = Some(c);
4002 return Some(Collected::Changed(format!(
4003 "integrity: a test listing changed the worktree: {}",
4004 changed.join(", ")
4005 )));
4006 }
4007 }
4008 live.event(&progress::Event::CollectionChecked {
4009 task: task.to_owned(),
4010 files: c
4011 .files
4012 .iter()
4013 .map(|f| progress::CollectionFile {
4014 path: f.path.clone(),
4015 state: f.state,
4016 decided_by: f.decided_by,
4017 })
4018 .collect(),
4019 });
4020 let never = c.reasons();
4021 let unknown: Vec<String> = c
4022 .unknown()
4023 .map(|f| {
4024 format!(
4025 "speccoder cannot tell whether the runner ran `{}` ({})",
4026 f.path,
4027 f.why.as_deref().unwrap_or("no report or listing names it")
4028 )
4029 })
4030 .collect();
4031 report.collection = Some(c);
4032 if !never.is_empty() {
4033 let mut reasons = never;
4034 if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
4035 report.verdict.take()
4036 {
4037 reasons.extend(more);
4038 }
4039 report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
4040 return None;
4041 }
4042 (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
4043}
4044
4045fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
4048 let said = c
4049 .stderr_tail
4050 .lines()
4051 .chain(c.stdout_tail.lines())
4052 .map(str::trim)
4053 .find(|l| l.starts_with("error"))
4054 .map(|l| l.chars().take(200).collect::<String>());
4055 let fix = match said.as_deref() {
4056 Some(l) if l.contains("profile `speccoder` not found") => Some(
4057 "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
4058 ),
4059 Some(l) if l.contains("no tests to run") => Some(
4060 "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",
4061 ),
4062 Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
4063 "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
4064 ),
4065 _ => None,
4066 };
4067 match (said, fix) {
4068 (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
4069 (Some(s), None) => format!("{note} ({s})"),
4070 _ => note.to_owned(),
4071 }
4072}
4073
4074#[must_use]
4079pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
4080 let why = broken_why(c)?;
4081 Some(match c.sandbox.denied.first() {
4082 Some(first) => {
4083 let others = c.sandbox.denied.len() - 1 + c.sandbox.more;
4084 let more = if others > 0 {
4085 format!(" (and {others} more)")
4086 } else {
4087 String::new()
4088 };
4089 format!("{why} — the sandbox denied {first}{more}")
4090 }
4091 None => why,
4092 })
4093}
4094
4095fn broken_why(c: &speccoder_core::verify::CheckResult) -> Option<String> {
4096 use speccoder_core::verify::CheckStatus;
4097 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
4098 return None;
4099 }
4100 if let Some(note) = &c.note {
4101 return Some(with_cause(note, c));
4102 }
4103 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
4104 return None;
4105 }
4106 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
4107 let runner = c
4111 .argv
4112 .first()
4113 .map(|a| a.rsplit('/').next().unwrap_or(a))
4114 .is_some_and(|a| {
4115 matches!(
4116 a,
4117 "cargo"
4118 | "pytest"
4119 | "python"
4120 | "python3"
4121 | "uv"
4122 | "npm"
4123 | "npx"
4124 | "pnpm"
4125 | "yarn"
4126 | "bun"
4127 | "go"
4128 | "jest"
4129 | "vitest"
4130 | "mvn"
4131 | "gradle"
4132 | "dotnet"
4133 | "mix"
4134 | "swift"
4135 )
4136 });
4137 let cannot_start = matches!(c.exit_code, Some(126 | 127));
4138 let is_broken = match &summary {
4139 _ if cannot_start => true,
4140 Some(s) if s.build_failed => true,
4141 Some(s) => s.passed + s.failed + s.errors == 0,
4142 None => runner,
4143 };
4144 match is_broken {
4145 false => None,
4146 true => {
4147 let first = c
4148 .stderr_tail
4149 .lines()
4150 .chain(c.stdout_tail.lines())
4151 .map(str::trim)
4152 .find(|l| l.to_lowercase().contains("error"))
4153 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
4154 .unwrap_or("it failed without running a test")
4155 .chars()
4156 .take(160)
4157 .collect::<String>();
4158 Some(first)
4159 }
4160 }
4161}
4162
4163fn login(
4167 fail: impl FnOnce(CommandStatus, String) -> CommandResult,
4168 shown: &str,
4169 agent: &crate::acp::agent::Agent,
4170 ways: &[crate::acp::session::SignIn],
4171) -> CommandResult {
4172 let words: Vec<String> = ways
4173 .iter()
4174 .filter(|w| w.command.is_none())
4175 .map(|w| {
4176 format!(
4177 "→ {}",
4178 w.description.clone().unwrap_or_else(|| w.name.clone())
4179 )
4180 })
4181 .collect();
4182 let mut commands: Vec<Vec<String>> = ways
4183 .iter()
4184 .filter_map(|w| w.command.as_deref())
4185 .map(|c| c.split_whitespace().map(str::to_owned).collect())
4186 .collect();
4187 if words.is_empty()
4188 && commands.is_empty()
4189 && let Some(p) = agent.preset()
4190 {
4191 commands.push(p.login.split_whitespace().map(str::to_owned).collect());
4192 }
4193 commands.dedup();
4194 let argvs: Vec<speccoder_core::step::Argvs> = commands.into_iter().map(|c| vec![c]).collect();
4195 let mut fix: Vec<String> = argvs
4196 .iter()
4197 .map(|a| speccoder_core::step::render(a))
4198 .collect();
4199 fix.extend(words);
4200 fix.push("speccoder doctor".to_owned());
4201 fail(
4202 CommandStatus::NeedsHuman,
4203 format!("{shown} needs signing in before it can make the attempt; nothing was attempted"),
4204 )
4205 .with_code("EXECUTOR-LOGIN")
4206 .with_fix(fix)
4207 .with_commands(argvs)
4208}
4209
4210fn story_red(report: &speccoder_core::RunReport) -> Option<speccoder_core::verify::RedClass> {
4213 let a = report
4214 .acceptance
4215 .as_ref()
4216 .filter(|a| a["oracle"].is_string())?;
4217 serde_json::from_value::<speccoder_core::verify::RedClass>(a["red_class"].clone())
4218 .ok()
4219 .filter(|c| c.is_red())
4220}
4221
4222fn with_oracle(bundle: &serde_json::Value, oracle: &serde_json::Value) -> serde_json::Value {
4225 let mut b = bundle.clone();
4226 if !oracle.is_null() {
4227 b["oracle"] = oracle.clone();
4228 }
4229 b
4230}
4231
4232fn ruling_trailers(ruled: &[crate::acceptance_at::Ruling]) -> String {
4235 ruled
4236 .iter()
4237 .map(|r| {
4238 format!(
4239 "Ruling: tests-wrong by {} — {}\n",
4240 r.by,
4241 r.reason.replace('\n', " ")
4242 )
4243 })
4244 .collect()
4245}
4246
4247fn frozen_test_named(reason: &str) -> Option<String> {
4250 Some(
4251 reason
4252 .split("frozen test `")
4253 .nth(1)?
4254 .split('`')
4255 .next()?
4256 .trim_start_matches("./")
4257 .to_owned(),
4258 )
4259}