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 super::bisect::prune(&wt_root, &s.plane_dir.join("tmp"));
228 let tmp = plane_dir.join("tmp");
229
230 let elsewhere = if options.lane.is_none() {
236 match crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task) {
237 Ok(e) => e,
238 Err(e) => {
239 return refuse(
240 CommandStatus::Error,
241 format!(
242 "the calls waiting for a person could not be read, so whether another task's attempt holds the worktree is unknown: {e}"
243 ),
244 );
245 }
246 }
247 } else {
248 crate::approvals::Elsewhere::default()
249 };
250 if let Some((other, covers)) = elsewhere.waiting {
251 return refuse(
252 CommandStatus::NeedsHuman,
253 format!(
254 "{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}`"
255 ),
256 );
257 }
258 if options.lane.is_none()
260 && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
261 {
262 return refuse(
263 CommandStatus::NeedsHuman,
264 format!(
265 "{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`"
266 ),
267 )
268 .with_code("TESTS-OVER-STRICT");
269 }
270 let commits_per_task = verify::find_config(&project)
274 .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
275 .and_then(|t| substrate::run_config::parse(&t).ok())
276 .is_none_or(|c| c.commit_per_task);
277 let marker_path = resume::path(&plane_dir, &feature_name, task);
280 if options.fresh_acceptance && options.lane.is_none() {
283 let _ = std::fs::remove_file(&marker_path);
284 crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
285 }
286 let head_now = git::Git::at(&wt_root)
287 .line(&["rev-parse", "HEAD"])
288 .unwrap_or_default();
289 let autopilot = options.mode == speccoder_core::oracle::RunMode::Autopilot;
294 let interrupted = resume::load(&marker_path).filter(|m| {
295 m.head == head_now && options.lane.is_none() && !head_now.is_empty() && !autopilot
296 });
297 if let Some(m) = &interrupted {
300 if let (Some(id), Ok(mut run)) = (&m.run_id, options.run_id.lock()) {
301 *run = crate::pipeline::RunId(id.clone());
302 }
303 if let Ok(mut asked) = options.asked.lock() {
304 *asked = (*asked).max(m.asked);
305 }
306 if let Ok(mut g) = options.run_grants.lock() {
307 for o in &m.run_grants {
308 if !g.contains(o) {
309 g.push(o.clone());
310 }
311 }
312 }
313 }
314 let marker_guard = resume::Guard::new(marker_path.clone());
315 let resumed_from = interrupted.as_ref().map(|_| {
318 std::fs::read_to_string(plane_dir.join(progress::FILE))
319 .ok()
320 .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
321 .filter(|l| l.task == task)
322 .map_or((None, None), |l| (l.stage, l.last_call))
323 });
324 let mut set_aside = None;
325 if interrupted.is_none()
327 && commits_per_task
328 && options.session.is_none()
329 && worktree::require_clean(&wt_root).is_err()
330 {
331 let left = worktree::leftovers(&wt_root);
335 if !left.is_empty() {
336 let mut what = Vec::new();
337 if !left.commit.is_empty() {
338 what.push(format!("{} is not committed", left.commit.join(", ")));
339 }
340 if !left.ignore.is_empty() {
341 what.push(format!("{} is not git-ignored", left.ignore.join(", ")));
342 }
343 let checkout = std::path::Path::new(&s.snapshot.root);
344 let remedy = crate::code::setup_remedy(checkout, Some(&feature_name));
345 let command = remedy
346 .as_ref()
347 .map_or_else(|| left.fix(checkout, &wt_root, &[]), |r| r.command.clone());
348 return refuse(
349 CommandStatus::GitPrecondition,
350 format!(
351 "{} — the checks write it on every run, so each run would find it in the worktree and set it aside",
352 what.join(" and "),
353 ),
354 )
355 .with_code("SETUP-INCOMPLETE")
356 .with_fix(vec!["speccoder fix".to_owned(), command])
357 .with_remedy(remedy);
358 }
359 match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
360 Ok(reference) => set_aside = Some(reference),
361 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
362 }
363 }
364
365 let format = crate::format::at(&project);
367 let Some(snap) = format.inspect(
368 &project,
369 if native.is_some() {
370 None
371 } else {
372 Some(&feature_name)
373 },
374 ) else {
375 return refuse(
376 CommandStatus::NotAProject,
377 format.messages().no_project_in_worktree.to_owned(),
378 );
379 };
380 if native.is_some()
381 && crate::bug::state::load(&s.plane_dir, &feature_name, task)
382 .is_some_and(|b| b.outcome == speccoder_core::bug::Outcome::Fixed)
383 {
384 return refuse(
385 CommandStatus::TaskNotReady,
386 format!("{task} is already fixed"),
387 )
388 .with_code("TASK-DONE");
389 }
390 if native.is_none() {
393 let Some(st) = snap
394 .feature
395 .as_ref()
396 .and_then(|f| f.schedule.as_ref())
397 .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
398 else {
399 return refuse(
400 CommandStatus::NotFound,
401 format!("no task {task} in {feature_name}"),
402 );
403 };
404 match st.state {
405 TaskState::Done => {
406 return refuse(
407 CommandStatus::TaskNotReady,
408 format!("{task} is already done"),
409 )
410 .with_code("TASK-DONE");
411 }
412 TaskState::Blocked => {
413 return refuse(
414 CommandStatus::TaskNotReady,
415 format!("{task} is blocked by {}", st.blocked_by.join(", ")),
416 )
417 .with_code("TASK-BLOCKED");
418 }
419 TaskState::Ready => {}
420 }
421 }
422 let checklists_overridden = if native.is_some() {
425 false
426 } else {
427 match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
428 Ok(overridden) => overridden,
429 Err(stop) => return *stop,
430 }
431 };
432 let parsed_task = snap
433 .feature
434 .as_ref()
435 .and_then(|f| f.tasks.as_ref())
436 .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
437 let tasks_file = snap
438 .feature
439 .as_ref()
440 .and_then(|f| f.tasks.as_ref())
441 .map(|t| project.join(&t.path));
442
443 let bundle = match native
444 .as_ref()
445 .map(|n| n.bundle(&project))
446 .map_or_else(|| format.bundle(&snap, task), Ok)
447 {
448 Ok(b) => b,
449 Err(_) => {
450 return refuse(
451 CommandStatus::NotFound,
452 format!("no task {task} in the worktree's {feature_name}"),
453 );
454 }
455 };
456 let acceptance_story = native
459 .is_none()
460 .then(|| crate::acceptance_at::story_at(&s.plane_dir, &feature_name, task))
461 .flatten();
462 let story_task: Option<String> = acceptance_story.as_ref().and_then(|st| {
463 snap.feature
464 .as_ref()
465 .and_then(|f| f.tasks.as_ref())
466 .and_then(|t| {
467 t.tasks
468 .iter()
469 .find(|t| t.story.as_deref() == Some(st.as_str()))
470 })
471 .map(|t| t.id.clone())
472 });
473 let bundle = match (&acceptance_story, &story_task) {
474 (Some(st), Some(other)) => {
475 let mut b = bundle;
476 if let Ok(of) = format.bundle(&snap, other) {
477 b.story = of.story;
478 }
479 b.task.story.clone_from(st);
480 b
481 }
482 _ => bundle,
483 };
484 let Some(config_root) = verify::find_config(&project) else {
485 return refuse(
486 CommandStatus::ConfigError,
487 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
488 )
489 .with_code("CONFIG-MISSING");
490 };
491 let config_text =
492 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
493 let run_config = match substrate::run_config::parse(&config_text) {
494 Ok(c) => c,
495 Err(e) => {
496 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
497 .with_code("CONFIG-INVALID");
498 }
499 };
500 if run_config.review_required
503 && let Some(f) = snap.feature.as_ref()
504 && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
505 && !t.complete()
506 {
507 return refuse(
508 CommandStatus::NeedsHuman,
509 format!(
510 "{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",
511 t.approved, t.open, t.changes_requested, t.lapsed, t.questions
512 ),
513 )
514 .with_code("REVIEW-PENDING");
515 }
516 if run_config.review_required {
519 let ambiguous = match crate::oracle::open_ambiguities(
520 &s.plane_dir,
521 &feature_name,
522 &format.oracle_requirements(&snap),
523 ) {
524 Ok(a) => a,
525 Err(t) => {
526 return refuse(
527 CommandStatus::NeedsHuman,
528 format!(
529 "{}: whether it is read two ways is unknown and [review] required is on",
530 t.0
531 ),
532 )
533 .with_code("ORACLE-TAMPERED");
534 }
535 };
536 if let Some((req, scenario)) = ambiguous.first() {
537 return refuse(
538 CommandStatus::NeedsHuman,
539 format!(
540 "{req} is read two ways ({scenario}) and [review] required is on: choose a reading or reword it first"
541 ),
542 )
543 .with_code("REVIEW-AMBIGUOUS")
544 .with_fix(vec![
545 format!("speccoder review tests {req} --reading A {scenario}"),
546 format!("speccoder review tests {req} --reading B {scenario}"),
547 ]);
548 }
549 }
550 let executor = match external::parse(&config_text) {
551 Ok(e) => e,
552 Err(e) => {
553 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
554 .with_code("CONFIG-INVALID");
555 }
556 };
557 let executor = match options.agent.as_deref() {
559 None => executor,
560 Some("native") => None,
561 Some(id) => match external::for_agent(id, executor.as_ref()) {
562 Ok(e) => Some(e),
563 Err(e) => return refuse(CommandStatus::UsageError, e),
564 },
565 };
566 let sandbox_config = match sandbox::parse(&config_text) {
568 Ok(c) => c,
569 Err(e) => {
570 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
571 .with_code("CONFIG-INVALID");
572 }
573 };
574 let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
575 Ok(r) => r,
576 Err(e) => return refuse(CommandStatus::ConfigError, e),
577 };
578 if options.mode == speccoder_core::oracle::RunMode::Autopilot && !runner.is_sandboxed() {
580 return refuse(
581 CommandStatus::ConfigError,
582 "autopilot needs the OS sandbox, and commands here would run unsandboxed".to_owned(),
583 )
584 .with_code("SANDBOX-UNAVAILABLE");
585 }
586 let mutation_config = match mutation::parse(&config_text) {
587 Ok(c) => c,
588 Err(e) => {
589 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
590 .with_code("CONFIG-INVALID");
591 }
592 };
593 let worktree_config = match crate::prepare::parse(&config_text) {
594 Ok(c) => c,
595 Err(e) => {
596 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
597 .with_code("CONFIG-INVALID");
598 }
599 };
600 let check_argvs = verify::check_argvs(&config_root);
601 let unit_dir = snap
605 .feature
606 .as_ref()
607 .map(|f| f.feature.dir.clone())
608 .unwrap_or_default();
609 let changed_code = crate::attempts::list(&wt_root, &feature_name)
610 .iter()
611 .any(|a| a.task == task && a.outcome == CHANGED_CODE);
612 let planning: Option<String> = parsed_task
613 .as_ref()
614 .filter(|t| {
615 native.is_none()
616 && !changed_code
617 && speccoder_core::lane::by_paths(&t.title, &unit_dir, &format.rules().judged)
618 == speccoder_core::lane::Lane::Planning
619 })
620 .map(|_| {
621 let prefix = project
622 .strip_prefix(&wt_root)
623 .map(|p| p.to_string_lossy().replace('\\', "/"))
624 .unwrap_or_default();
625 if prefix.is_empty() {
626 unit_dir.trim_end_matches('/').to_owned()
627 } else {
628 format!("{prefix}/{}", unit_dir.trim_end_matches('/'))
629 }
630 });
631 let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow)
632 .with_prefixes(run_config.allow_prefix.clone())
633 .rules(format.rules());
634 let policy = match &planning {
635 Some(dir) => policy.planning(dir),
636 None => policy,
637 };
638 let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
641 {
642 Some(s) => s.confine(policy, runner),
643 None => (policy, runner, None),
644 };
645 let sandbox_notice = sandbox_notice.or(carve_notice);
646 if let Some(e) = options.session.as_ref().and_then(|s| {
649 s.lock().ok().and_then(|s| {
650 if interrupted.is_some() {
652 s.check_before_tick(&policy).err()
653 } else {
654 s.check(&policy).err()
655 }
656 })
657 }) {
658 return e.result("run");
659 }
660
661 let wt_root_canonical = std::fs::canonicalize(&wt_root)
662 .unwrap_or_else(|_| wt_root.clone())
663 .display()
664 .to_string();
665
666 let this_run = options.run();
669 let (decisions, task_grants) = match approvals::Decisions::load(&s.plane_dir) {
670 Ok(mut d) => {
671 if !autopilot
674 && d.claim(&feature_name, task, this_run.as_str())
675 && let Err(e) = d.save(&s.plane_dir)
676 {
677 return refuse(CommandStatus::Error, e);
678 }
679 (
680 d.for_task(&feature_name, task),
681 if autopilot {
682 Vec::new()
683 } else {
684 d.for_run(&feature_name, task, this_run.as_str())
685 },
686 )
687 }
688 Err(e) => return refuse(CommandStatus::ConfigError, e),
689 };
690
691 if let Some((stage, last_call)) = &resumed_from {
692 live.event(&progress::Event::Resumed {
693 task: task.to_owned(),
694 stage: *stage,
695 last_call: last_call.clone(),
696 });
697 }
698 live.event(&progress::Event::TaskStarted {
699 feature: feature_name.clone(),
700 task: task.to_owned(),
701 title: parsed_task
702 .as_ref()
703 .map(|t| t.title.clone())
704 .unwrap_or_default(),
705 });
706 if planning.is_some()
707 && let Some(t) = &parsed_task
708 {
709 live.event(&progress::Event::Notice {
710 text: format!(
711 "📐 planning · writes {}",
712 speccoder_core::lane::task_paths(&t.title, &unit_dir).join(", ")
713 ),
714 });
715 }
716 let fail = |status: CommandStatus, m: String| {
718 live.event(&progress::Event::TaskFinished {
719 task: task.to_owned(),
720 outcome: "error".to_owned(),
721 message: redact(&m, secret),
722 run: None,
723 input_tokens: 0,
724 output_tokens: 0,
725 cache_read_tokens: 0,
726 cache_write_tokens: 0,
727 usd: None,
728 regression: None,
729 });
730 refuse(status, m)
731 };
732 let mut prepare_notes: Vec<String> = Vec::new();
736 let mut copied_files: Vec<String> = Vec::new();
739 if options.session.is_none() {
740 match crate::include::copy(
741 &wt_root,
742 &project,
743 Path::new(&s.snapshot.root),
744 &worktree_config.include,
745 ) {
746 Ok(c) => {
747 copied_files.clone_from(&c.files);
748 if !c.now.is_empty() {
749 prepare_notes.push(format!(
750 "copied from your checkout ([worktree] include): {}",
751 c.now.join(", ")
752 ));
753 }
754 prepare_notes.extend(
755 c.skipped
756 .iter()
757 .map(|n| format!("not copied ([worktree] include): {n}")),
758 );
759 for n in &prepare_notes {
760 live.event(&progress::Event::Notice { text: n.clone() });
761 }
762 }
763 Err(e) => {
764 return fail(
765 CommandStatus::ConfigError,
766 format!("the files [worktree] include names could not be copied: {e}"),
767 )
768 .with_code("PREPARE-FAILED");
769 }
770 }
771 }
772 let prepares =
773 options.session.is_none() && worktree_config.prepare && crate::prepare::needed(&project);
774 if prepares {
775 let began = std::time::Instant::now();
776 stage(
777 &*live,
778 task,
779 Stage::Prepare,
780 StageState::Started,
781 None,
782 None,
783 );
784 let prepared = crate::prepare::prepare(&crate::prepare::Prepare {
785 worktree: &wt_root,
786 project: &project,
787 config: &worktree_config,
788 runner: &runner,
789 state: Some(state),
790 });
791 match prepared {
792 Ok(None) => {}
793 Ok(Some(p)) => {
794 stage(
795 &*live,
796 task,
797 Stage::Prepare,
798 if p.done.is_empty() {
799 StageState::Skipped
800 } else {
801 StageState::Passed
802 },
803 Some(if p.done.is_empty() {
804 "reused: the lockfiles are unchanged".to_owned()
805 } else {
806 p.done.join(", ")
807 }),
808 Some(began),
809 );
810 }
811 Err(f) => {
812 stage(
813 &*live,
814 task,
815 Stage::Prepare,
816 StageState::Failed,
817 Some(f.message.clone()),
818 Some(began),
819 );
820 return fail(CommandStatus::ConfigError, f.message)
821 .with_code("PREPARE-FAILED")
822 .with_fix(f.fix)
823 .with_commands(f.commands);
824 }
825 }
826 } else if options.session.is_none() {
827 crate::prepare::forget(&wt_root);
829 }
830
831 let began = std::time::Instant::now();
833 stage(
834 &*live,
835 task,
836 Stage::Baseline,
837 StageState::Started,
838 None,
839 None,
840 );
841 let baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
842 Ok(b) => b,
843 Err(e) => {
844 stage(
845 &*live,
846 task,
847 Stage::Baseline,
848 StageState::Failed,
849 None,
850 Some(began),
851 );
852 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
853 }
854 };
855 if !options.allow_failing_baseline
860 && let Some((c, why)) = baseline
861 .checks
862 .iter()
863 .find_map(|c| broken(c).map(|w| (c, w)))
864 {
865 stage(
866 &*live,
867 task,
868 Stage::Baseline,
869 StageState::Failed,
870 Some(format!("`{}` cannot run", c.name)),
871 Some(began),
872 );
873 let checkout = std::path::Path::new(&s.snapshot.root);
876 if let Some(remedy) = crate::code::setup_remedy(checkout, Some(&feature_name)) {
877 return fail(
878 CommandStatus::GitPrecondition,
879 format!(
880 "check `{}` cannot run in the worktree: your checkout has not committed its setup — {why}",
881 c.name
882 ),
883 )
884 .with_code("SETUP-INCOMPLETE")
885 .with_fix(vec!["speccoder fix".to_owned(), remedy.command.clone()])
886 .with_remedy(Some(remedy));
887 }
888 return fail(
889 CommandStatus::ConfigError,
890 if why.contains("git add") || why.contains("--allow-failing-baseline") {
893 format!(
894 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why}",
895 c.name
896 )
897 } else {
898 format!(
899 "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",
900 c.name
901 )
902 },
903 )
904 .with_code("CHECK-BROKEN");
905 }
906 let failing = baseline
908 .checks
909 .iter()
910 .filter(|c| c.status.is_failure())
911 .count();
912 stage(
913 &*live,
914 task,
915 Stage::Baseline,
916 StageState::Passed,
917 (failing > 0).then(|| format!("{failing} check(s) already failing")),
918 Some(began),
919 );
920
921 let rt = match tokio_runtime() {
922 Ok(rt) => rt,
923 Err(e) => return fail(CommandStatus::RunFailed, e),
924 };
925 let bundle_value = match serde_json::to_value(&bundle) {
926 Ok(v) => v,
927 Err(e) => return fail(CommandStatus::Error, e.to_string()),
928 };
929 let instructions = crate::instructions::collect(
933 &wt_root,
934 &project,
935 &speccoder_core::scope::named_paths(&bundle.task.title),
936 &run_config.instructions,
937 );
938 let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
939 &instructions
940 } else {
941 &[]
942 };
943
944 let servers = match mcp::parse(&config_text) {
946 Ok(s) => s,
947 Err(e) => {
948 return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
949 .with_code("CONFIG-INVALID");
950 }
951 };
952 let mut connected = if servers.is_empty() {
953 mcp::Connected::default()
954 } else {
955 live.event(&progress::Event::Notice {
956 text: format!("starting {} MCP server(s)", servers.len()),
957 });
958 rt.block_on(mcp::connect(&servers, &project, &runner))
959 };
960 let approved_tools: Vec<String> = task_grants
962 .iter()
963 .filter_map(|g| g.tool_name().map(str::to_owned))
964 .collect();
965 let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
966 connected.notices.extend(pinned);
967
968 let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
971 Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
972 Err(e) => return fail(CommandStatus::Error, e.to_string()),
973 };
974 let swept =
977 match crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc()) {
978 Ok(p) => p,
979 Err(e) => {
980 return fail(
981 CommandStatus::RunFailed,
982 format!("the calls past their deadline could not be denied: {e}"),
983 );
984 }
985 };
986 let present = crate::approvals::PresentContext {
988 root: None,
989 sandbox: runner.is_sandboxed().then(|| {
990 if cfg!(target_os = "macos") {
991 "Seatbelt".to_owned()
992 } else {
993 "Landlock+seccomp".to_owned()
994 }
995 }),
996 network: match sandbox_config.network {
997 sandbox::Network::Off => "none",
998 sandbox::Network::Loopback => "loopback",
999 sandbox::Network::On => "unrestricted",
1000 }
1001 .to_owned(),
1002 file_text: None,
1003 };
1004 let plane = match rt.block_on(async {
1005 substrate::Plane::open_with(
1006 &plane_dir,
1007 &s.model,
1008 provider,
1009 Some(tools_server),
1010 substrate::Hooks {
1011 progress: Some(Arc::clone(&live)),
1012 mcp: connected.clients.clone(),
1013 mcp_tools: connected.tools.clone(),
1014 format: Some(format),
1015 present: present.clone(),
1016 },
1017 )
1018 }) {
1019 Ok(p) => p,
1020 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1021 };
1022 let mut notices: Vec<String> = elsewhere
1023 .replaced_journal
1024 .iter()
1025 .chain(swept.iter())
1026 .chain(plane.replaced_journal.iter())
1027 .map(|p| {
1028 format!(
1029 "the journal could not be read by this build and was moved to {}; past runs stay in git",
1030 p.display()
1031 )
1032 })
1033 .chain(connected.notices.iter().cloned())
1034 .chain(sandbox_notice.clone())
1035 .chain(prepare_notes.iter().cloned())
1036 .chain(resumed_from.as_ref().map(|(stage, last_call)| {
1037 format!(
1038 "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
1039 stage.map_or("a run", speccoder_core::stage::Stage::name),
1040 last_call
1041 .as_ref()
1042 .map(|c| format!(" ({c})"))
1043 .unwrap_or_default()
1044 )
1045 }))
1046 .collect();
1047
1048 let demand_red =
1051 run_config.acceptance && planning.is_none() && format.is_convergence(&bundle.task.phase);
1052 let start = match &interrupted {
1058 Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
1059 Ok(tree) => worktree::Checkpoint(tree),
1060 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1061 },
1062 None => match worktree::checkpoint(&wt_root, &tmp) {
1063 Ok(c) => c,
1064 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1065 },
1066 };
1067
1068 let began = std::time::Instant::now();
1069 let bug_ctx = super::bug::Ctx {
1074 rt: &rt,
1075 plane: &plane,
1076 project: &project,
1077 wt_root: &wt_root,
1078 wt_path: &wt_path,
1079 tmp: &tmp,
1080 policy: &policy,
1081 runner: &runner,
1082 state: &s.plane_dir,
1083 feature: &feature_name,
1084 task,
1085 bundle: &bundle_value,
1086 instructions: &instructions,
1087 live: &*live,
1088 approver: approver.as_deref(),
1089 held_out: run_config.held_out,
1090 max_tries: run_config.reproduce_tries,
1091 bug_test: run_config.bug_test.as_deref(),
1092 start: &start,
1093 };
1094 let mut bug_fix = match super::bug::phase(&s.plane_dir, &feature_name, task, &project, format) {
1095 Err(e) => return fail(CommandStatus::NeedsHuman, e),
1096 Ok(None) => None,
1097 Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
1098 Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
1099 match super::bug::reproduce(&bug_ctx, *b) {
1100 Ok(f) => Some(f),
1101 Err(stop) => return *stop,
1102 }
1103 }
1104 Ok(Some(super::bug::Phase::Reproduce(_))) => None,
1105 };
1106 let bug_candidate = bug_fix.as_ref().and_then(|f| {
1107 f.bug.candidate.clone().and_then(|c| {
1108 crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
1109 .candidate_patch
1110 .map(|d| (d, c))
1111 })
1112 });
1113 let test_layout = crate::layout::detect(&project, &check_argvs);
1116 let test_layout_text = test_layout.text();
1117 let story_files = super::commit::story_files(&wt_root, &feature_name);
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 judged = format.rules().judged;
1126 let share = bug_fix
1127 .is_none()
1128 .then(|| {
1129 super::acceptance::share_of(&super::acceptance::ShareInput {
1130 repo: &wt_root,
1131 plane: &s.plane_dir,
1132 feature: &feature_name,
1133 task,
1134 story: acceptance_story
1135 .as_deref()
1136 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.as_deref())),
1137 whole_unit: format.kind() == speccoder_core::spec::FormatKind::Openspec
1138 && acceptance_story.is_none(),
1139 tasks: snap.feature.as_ref().and_then(|f| f.tasks.as_ref()),
1140 unit_dir: &unit_dir,
1141 judged: &judged,
1142 scenarios: &story_scenarios,
1143 as_deferred: false,
1144 })
1145 })
1146 .flatten();
1147 let scenarios: Vec<speccoder_core::spec::ScenarioRef> = match &share {
1148 Some(sh) => story_scenarios
1149 .iter()
1150 .filter(|s| sh.scenarios().contains(&s.id))
1151 .cloned()
1152 .collect(),
1153 None => story_scenarios.clone(),
1154 };
1155 let deferred_to = match &share {
1156 Some(speccoder_core::story::Share::Deferred { checkpoint }) => Some(checkpoint.clone()),
1157 _ => None,
1158 };
1159 let story_verified = matches!(
1160 &share,
1161 Some(speccoder_core::story::Share::Checkpoint { scenarios, .. }) if scenarios.is_empty()
1162 );
1163 let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
1166 let mut review_reuse = None;
1167 let partial_link = matches!(
1170 &share,
1171 Some(speccoder_core::story::Share::Linked { scenarios }) if scenarios.len() < story_scenarios.len()
1172 );
1173 if partial_link {
1174 notices.push(format!(
1175 "{task} names {} of its story's scenarios: its own acceptance tests are authored for them, not the ones frozen at review",
1176 scenarios.len()
1177 ));
1178 }
1179 if bug_fix.is_none()
1180 && run_config.acceptance
1181 && !options.fresh_acceptance
1182 && planning.is_none()
1183 && !partial_link
1184 {
1185 match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
1186 Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
1187 Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
1188 "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
1189 )),
1190 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
1191 notices.push(format!("{req}: frozen oracle not present here — authoring"))
1192 }
1193 Some(crate::oracle::TaskReuse::Lapsed {
1194 req,
1195 frozen,
1196 current,
1197 }) => {
1198 if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
1199 live.event(&progress::Event::Oracle {
1200 step: progress::OracleEvent::Lapsed {
1201 unit: feature_name.clone(),
1202 requirement: req.clone(),
1203 frozen_wording_sha256: frozen,
1204 current_wording_sha256: current,
1205 },
1206 });
1207 }
1208 notices.push(format!(
1209 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
1210 ));
1211 }
1212 Some(crate::oracle::TaskReuse::Tampered(why)) => {
1213 return fail(
1214 CommandStatus::NeedsHuman,
1215 format!("{why}: the task stops for a person, nothing is ticked"),
1216 )
1217 .with_code("ORACLE-TAMPERED");
1218 }
1219 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
1220 return fail(
1221 CommandStatus::NeedsHuman,
1222 format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
1223 )
1224 .with_code("REVIEW-UNREADABLE");
1225 }
1226 None => {}
1227 }
1228 }
1229 let mut acceptance = if let Some(prepared) = interrupted
1230 .as_ref()
1231 .and_then(|m| m.acceptance.clone())
1232 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
1233 {
1234 let mut prepared = prepared;
1238 if let Some(sealed) = bug_fix
1239 .as_ref()
1240 .and_then(|f| f.record.held_out_sealed.as_ref())
1241 {
1242 prepared.held_out = Some(sealed.manifest_sha256.clone());
1243 prepared.report["held_out"] = serde_json::json!({
1244 "state": "on",
1245 "manifest": format!("sha256:{}", sealed.manifest_sha256),
1246 "kept": sealed.count,
1247 });
1248 }
1249 Some(prepared)
1250 } else if let Some(f) = &bug_fix {
1251 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1252 match super::bug::reuse(&bug_ctx, f) {
1253 Ok(a) => Some(a),
1254 Err(e) => {
1255 let _ = worktree::restore(&wt_root, &tmp, &start);
1256 stage(
1257 &*live,
1258 task,
1259 Stage::Red,
1260 StageState::Failed,
1261 Some(e.clone()),
1262 Some(began),
1263 );
1264 return fail(CommandStatus::NeedsHuman, e);
1265 }
1266 }
1267 } else if let Some(reuse) = review_reuse.take() {
1268 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1271 let crate::oracle::TaskReuse::Ready {
1272 record,
1273 files,
1274 tests,
1275 manifest,
1276 } = reuse
1277 else {
1278 return fail(CommandStatus::Error, "no frozen oracle".to_owned());
1279 };
1280 match reuse_pre_frozen(
1281 &project,
1282 &record,
1283 &files,
1284 &tests,
1285 &runner,
1286 RedRule::RunTime,
1287 Some(&SealedCheck {
1288 state: &s.plane_dir,
1289 unit: &feature_name,
1290 wt_root: &wt_root,
1291 tmp: &tmp,
1292 policy: &policy,
1293 }),
1294 ) {
1295 Ok(mut a) => {
1296 a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
1297 notices.push(format!(
1298 "acceptance tests frozen at review by {} ({}) for {}: no author call",
1299 record.decided.by, record.decided.via, record.requirement
1300 ));
1301 review_frozen = Some((*record, manifest));
1302 Some(a)
1303 }
1304 Err(e) => {
1305 let _ = worktree::restore(&wt_root, &tmp, &start);
1306 stage(
1307 &*live,
1308 task,
1309 Stage::Red,
1310 StageState::Failed,
1311 Some(e.clone()),
1312 Some(began),
1313 );
1314 return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
1315 }
1316 }
1317 } else if run_config.acceptance
1318 && planning.is_none()
1319 && deferred_to.is_none()
1320 && !story_verified
1321 && format.acceptance_testable(&bundle)
1322 {
1323 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1324 Some(prepare_acceptance(&AcceptanceSetup {
1325 rt: &rt,
1326 plane: &plane,
1327 project: &project,
1328 worktree: &wt_root,
1329 tmp: &tmp,
1330 policy: &policy,
1331 feature: &feature_name,
1332 task,
1333 bundle: bundle_value.clone(),
1334 scenarios: &scenarios,
1335 instructions: &instructions,
1336 runner: &runner,
1337 layout: Some(&test_layout),
1338 story_files: &story_files,
1339 }))
1340 } else {
1341 None
1342 };
1343 if review_frozen.is_none()
1344 && acceptance
1345 .as_ref()
1346 .is_some_and(|a| a.report["source"] == "review")
1347 && let Some(crate::oracle::TaskReuse::Ready {
1348 record, manifest, ..
1349 }) = review_reuse.take()
1350 {
1351 review_frozen = Some((*record, manifest));
1352 }
1353 let held_out_store = match (&bug_fix, &review_frozen) {
1356 (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1357 (None, Some((r, _))) => {
1358 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1359 }
1360 (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1361 };
1362 if let Some(a) = acceptance
1363 .as_mut()
1364 .filter(|a| a.report.get("held_out").is_none())
1365 {
1366 if !run_config.held_out {
1367 a.report["held_out"] = serde_json::json!({ "state": "off" });
1368 } else if a.argv.is_none() {
1369 a.report["held_out"] =
1370 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1371 } else if !a.red {
1372 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1373 } else if scenarios.is_empty() {
1374 a.report["held_out"] =
1375 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1376 } else {
1377 let (held, sha) = prepare_held_out(
1378 &AcceptanceSetup {
1379 rt: &rt,
1380 plane: &plane,
1381 project: &project,
1382 worktree: &wt_root,
1383 tmp: &tmp,
1384 policy: &policy,
1385 feature: &feature_name,
1386 task,
1387 bundle: bundle_value.clone(),
1388 scenarios: &scenarios,
1389 instructions: &instructions,
1390 runner: &runner,
1391 layout: Some(&test_layout),
1392 story_files: &story_files,
1393 },
1394 a,
1395 &scenarios,
1396 &held_out_store,
1397 match &share {
1398 Some(speccoder_core::story::Share::Checkpoint { composition, .. }) => {
1399 *composition
1400 }
1401 Some(_) => false,
1402 None => story_checkpoint(
1403 snap.feature.as_ref().and_then(|f| f.tasks.as_ref()),
1404 task,
1405 &scenarios,
1406 ),
1407 },
1408 );
1409 a.report["held_out"] = held;
1410 a.held_out = sha;
1411 }
1412 }
1413 if run_config.held_out && !run_config.acceptance {
1414 notices.push(
1415 "[acceptance] held_out has no effect while generate = false: no held-out tests"
1416 .to_owned(),
1417 );
1418 }
1419 match &acceptance {
1420 None => stage(
1421 &*live,
1422 task,
1423 Stage::Red,
1424 StageState::Skipped,
1425 Some(if planning.is_some() {
1426 "planning work: design documents".to_owned()
1427 } else if let Some(at) = &deferred_to {
1428 format!("judged at {at}, as you decided")
1429 } else if story_verified {
1430 "its story's scenarios were verified by earlier ticks".to_owned()
1431 } else {
1432 "no acceptance examples".to_owned()
1433 }),
1434 None,
1435 ),
1436 Some(a) if a.red => stage(
1437 &*live,
1438 task,
1439 Stage::Red,
1440 StageState::Passed,
1441 a.red_class.map(class_word),
1442 Some(began),
1443 ),
1444 Some(a) => stage(
1445 &*live,
1446 task,
1447 Stage::Red,
1448 StageState::Failed,
1449 Some(a.red_class.map_or_else(
1450 || "not demonstrated".to_owned(),
1451 |c| format!("not demonstrated ({})", class_word(c)),
1452 )),
1453 Some(began),
1454 ),
1455 }
1456
1457 if let Some(why) = acceptance
1461 .as_ref()
1462 .and_then(|a| a.generation_failed.clone())
1463 {
1464 let reason = format!(
1465 "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"
1466 );
1467 let _ = worktree::restore(&wt_root, &tmp, &start);
1468 let report = RunReport {
1469 reason: Some(reason.clone()),
1470 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1471 set_aside,
1472 notices: notices.clone(),
1473 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1474 };
1475 live.event(&finished_event(
1476 task,
1477 &report,
1478 "acceptance_failed",
1479 false,
1480 redact(&reason, secret),
1481 ));
1482 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1483 .with_code("ACCEPTANCE-UNWRITTEN")
1484 .with_data(CommandData::Run(Box::new(report)));
1485 }
1486
1487 if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
1488 let why = undemonstrated_why(acceptance.as_ref());
1489 let reason = format!(
1490 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1491 );
1492 let saved = attempts::save(
1494 &wt_root,
1495 &tmp,
1496 &feature_name,
1497 task,
1498 &attempts::Record {
1499 run: None,
1500 outcome: "not_demonstrated",
1501 reason: &reason,
1502 pending: None,
1503 usage: None,
1504 defer_to: None,
1505 },
1506 )
1507 .ok()
1508 .map(|s| s.reference);
1509 let _ = worktree::restore(&wt_root, &tmp, &start);
1510 let report = RunReport {
1511 reason: Some(reason.clone()),
1512 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1513 saved_attempt: saved,
1514 set_aside,
1515 notices: notices.clone(),
1516 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1517 };
1518 live.event(&finished_event(
1519 task,
1520 &report,
1521 "not_demonstrated",
1522 false,
1523 redact(&reason, secret),
1524 ));
1525 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1526 .with_data(CommandData::Run(Box::new(report)));
1527 }
1528
1529 let before = match &interrupted {
1532 Some(m) => worktree::Checkpoint(m.before.clone()),
1533 None => match worktree::checkpoint(&wt_root, &tmp) {
1534 Ok(c) => c,
1535 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1536 },
1537 };
1538 let acceptance_saved = acceptance
1539 .as_ref()
1540 .and_then(|a| serde_json::to_value(a).ok());
1541 let frozen: Vec<String> = worktree::files(&wt_root, &before)
1543 .unwrap_or_default()
1544 .into_iter()
1545 .filter(|p| tools::paths::is_test_path(p))
1546 .collect();
1547 let mut usage: Option<serde_json::Value> = None;
1548 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1551 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1553 let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1555 let mut refused_account = false;
1557 let (mut report, attempt_status) = loop {
1558 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1559 let mut digest: Option<serde_json::Value> = None;
1560 let mut attempt_no = interrupted
1563 .as_ref()
1564 .filter(|m| m.sample == samples.len())
1565 .map_or(1, |m| m.attempt.max(1));
1566 let (mut report, attempt_status) = loop {
1567 let hidden: Vec<String> = options
1569 .session
1570 .as_ref()
1571 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1572 .unwrap_or_default();
1573 let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1579 Ok(_) if autopilot => (Vec::new(), Vec::new()),
1582 Ok(mut d) => {
1583 let once = d.take_once(&feature_name, task, this_run.as_str());
1584 if !once.is_empty()
1585 && let Err(e) = d.save(&s.plane_dir)
1586 {
1587 return fail(CommandStatus::Error, e);
1588 }
1589 (d.for_run(&feature_name, task, this_run.as_str()), once)
1590 }
1591 Err(e) => return fail(CommandStatus::ConfigError, e),
1592 };
1593 let run_approved: Vec<crate::tools::policy::Object> = options
1595 .run_grants
1596 .lock()
1597 .map(|g| g.clone())
1598 .unwrap_or_default();
1599 let input = substrate::attempt::input(
1601 bundle_value.clone(),
1602 &substrate::attempt::Settings {
1603 checks: &check_argvs,
1604 allow: &run_config.allow,
1605 allow_prefix: &run_config.allow_prefix,
1606 max_turns: run_config.max_turns,
1607 max_tool_calls: run_config.max_tool_calls,
1608 reasoning: s.model.reasoning_summary,
1609 frozen: &frozen,
1610 approved: &task_grants,
1611 rejected: &decisions.rejected,
1612 run_approved: &run_approved,
1613 approved_once: &approved_once,
1614 nudges: &decisions.nudges,
1615 mcp: &connected.tools,
1616 root: &wt_root_canonical,
1617 frozen_additions: run_config.frozen_additions,
1618 asks: if options.lane.is_some() {
1621 "stop"
1622 } else {
1623 "tasks"
1624 },
1625 wait_secs: run_config.approval_wait.as_secs(),
1626 network_allow: &sandbox_config.network_allow,
1627 network_allow_prefix: &sandbox_config.network_allow_prefix,
1628 run_id: this_run.as_str(),
1629 asked: options.asked_now(),
1630 budget: run_config.approval_budget,
1631 prompts: approver.is_some() && options.lane.is_none(),
1632 in_place: options.session.is_some(),
1633 hidden: &hidden,
1634 autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1635 planning: planning.as_deref(),
1636 },
1637 digest.as_ref(),
1638 attempt_instructions,
1639 (!test_layout.runners.is_empty()).then_some(test_layout_text.as_str()),
1641 );
1642 let attempt_began = std::time::Instant::now();
1643 let attempt_detail = if run_config.best_of > 1 {
1644 format!(
1645 "{attempt_no}/{} · sample {}/{}",
1646 run_config.max_attempts,
1647 samples.len() + 1,
1648 run_config.best_of
1649 )
1650 } else {
1651 format!("{attempt_no}/{}", run_config.max_attempts)
1652 };
1653 stage(
1654 &*live,
1655 task,
1656 Stage::Attempt,
1657 StageState::Started,
1658 Some(attempt_detail.clone()),
1659 None,
1660 );
1661 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1664 let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1667 let held: Vec<String> = bug_fix
1668 .as_ref()
1669 .and_then(|f| {
1670 f.bug
1671 .held_out_sealed
1672 .clone()
1673 .map(|h| (f.bug.unit.clone(), h))
1674 })
1675 .and_then(|(unit, h)| {
1676 crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1677 })
1678 .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1679 .unwrap_or_default();
1680 match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1681 Ok(x) => x,
1682 Err(stop) => return *stop,
1683 }
1684 } else if let Some(exec) = &executor {
1685 let allow: Vec<Vec<String>> = check_argvs
1686 .iter()
1687 .chain(&run_config.allow)
1688 .cloned()
1689 .collect();
1690 let prompt = external::prompt_in(format.wording(), &bundle_value, digest.as_ref());
1691 let withheld = acceptance
1692 .as_ref()
1693 .and_then(|a| a.held_out.as_ref())
1694 .map(|_| held_out_store.as_path());
1695 if withheld.is_some()
1696 && !notices
1697 .iter()
1698 .any(|n| n.starts_with("held-out set withheld"))
1699 {
1700 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());
1701 }
1702 let _ = allow;
1703 let o = match &exec.kind {
1704 external::Kind::Command(argv) => {
1705 external::run(argv, exec.timeout, &wt_root, &prompt, &tmp)
1706 }
1707 external::Kind::Acp(agent) => {
1708 let policy = match substrate::attempt::policy_of(
1711 &input.peek()["settings"],
1712 format.rules(),
1713 ) {
1714 Ok(p) => p,
1715 Err(e) => return fail(CommandStatus::RunFailed, e.to_string()),
1716 };
1717 let tools = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
1718 Ok(t) => t.with_runner(runner.clone()),
1719 Err(e) => {
1720 return fail(
1721 CommandStatus::Error,
1722 format!("{}: {e}", wt_root.display()),
1723 );
1724 }
1725 };
1726 let governor = Arc::new(crate::acp::govern::Governor::new(
1727 crate::acp::govern::Setup {
1728 root: std::fs::canonicalize(&wt_root)
1729 .unwrap_or_else(|_| wt_root.clone()),
1730 tools,
1731 policy,
1732 approver: approver.clone().filter(|_| options.lane.is_none()),
1734 present: present.clone(),
1735 task: format!("{feature_name}/{task}"),
1736 task_title: parsed_task
1737 .as_ref()
1738 .map(|t| t.title.clone())
1739 .unwrap_or_default(),
1740 run: Some(this_run.to_string()),
1741 frozen_additions: run_config.frozen_additions,
1742 run_approved: run_approved.clone(),
1743 approved_once: approved_once.clone(),
1744 progress: Some((task.to_owned(), Arc::clone(&live))),
1745 copied: copied_files.clone(),
1746 agent: agent.id.clone(),
1747 },
1748 ));
1749 let a = external::run_acp(
1750 exec,
1751 agent,
1752 &wt_root,
1753 &prompt,
1754 &runner,
1755 governor,
1756 Some((task.to_owned(), Arc::clone(&live))),
1757 );
1758 if let Some(ways) = &a.sign_in {
1759 return login(fail, &a.agent, agent, ways);
1760 }
1761 for n in a.outcome.output["acp"]["notices"]
1762 .as_array()
1763 .into_iter()
1764 .flatten()
1765 .filter_map(serde_json::Value::as_str)
1766 {
1767 notices.push(format!("{}: {n}", a.agent));
1768 }
1769 a.outcome
1770 }
1771 };
1772 (o.run_id, true, o.output)
1773 } else {
1774 let key = match &interrupted {
1776 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
1777 m.key.clone()
1778 }
1779 _ => resume::new_key(&feature_name, task),
1780 };
1781 let _ = resume::save(
1782 &marker_path,
1783 &resume::Marker {
1784 key: key.clone(),
1785 before: before.0.clone(),
1786 head: head_now.clone(),
1787 attempt: attempt_no,
1788 sample: samples.len(),
1789 acceptance: acceptance_saved.clone(),
1790 run_id: Some(this_run.to_string()),
1791 asked: options.asked_now(),
1792 run_grants: run_approved.clone(),
1793 },
1794 );
1795 marker_guard.arm();
1796 let outcome = match rt.block_on(plane.attempt_keyed(
1797 &feature_name,
1798 task,
1799 input,
1800 &key,
1801 )) {
1802 Ok((o, resumed)) => {
1803 if resumed {
1804 notices.push(format!(
1805 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
1806 o.run_id
1807 ));
1808 }
1809 o
1810 }
1811 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1812 };
1813 let context = crate::approvals::PresentContext {
1817 root: Some(wt_root.clone()),
1818 ..present.clone()
1819 };
1820 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
1821 outcome,
1822 approver.as_deref(),
1823 &context,
1824 )) {
1825 Ok(x) => {
1826 notices.extend(crate::actions::allow_always_inline(
1829 path,
1830 &feature_name,
1831 &x.always,
1832 state,
1833 ));
1834 (x.outcome, x.waiting)
1835 }
1836 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1837 };
1838 if let Some(task_row) = waiting {
1839 marker_guard.keep();
1842 let action = &task_row.justification.proposed_action;
1843 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
1844 report.share.clone_from(&share);
1845 report.run_id = outcome.run_id.to_string();
1846 report.notices = notices;
1847 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
1848 report.tool_calls.push(serde_json::json!({
1850 "tool": action["tool"],
1851 "arguments": action["arguments"],
1852 "decision": "approval",
1853 "reason": action["request"]["reason"],
1854 "object": action["request"]["object"],
1855 "waiting": true,
1856 "call": task_row.id.to_hex(),
1857 }));
1858 report.pending = Some(serde_json::json!({
1859 "tool": action["tool"],
1860 "arguments": action["arguments"],
1861 "reason": action["request"]["reason"],
1862 "object": action["request"]["object"],
1863 "request": action["request"],
1864 "call": task_row.id.to_hex(),
1865 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
1866 "waiting": true,
1867 "batched": action["request"]["batched"],
1868 }));
1869 if let (Some(n), Ok(mut asked)) = (
1872 action["request"]["asked"]
1873 .as_u64()
1874 .and_then(|n| u32::try_from(n).ok()),
1875 options.asked.lock(),
1876 ) {
1877 *asked = (*asked).max(n);
1878 }
1879 report.reason = Some(format!(
1880 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
1881 task_row.justification.summary.peek()
1882 ));
1883 let (status, message) = status_and_message(&report, "needs_approval", false);
1884 live.event(&finished_event(
1885 task,
1886 &report,
1887 "needs_approval",
1888 false,
1889 redact(&message, secret),
1890 ));
1891 return CommandResult::new("run", status, &redact(&message, secret))
1892 .with_data(CommandData::Run(Box::new(report)));
1893 }
1894 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
1895 refused_account = outcome
1896 .failed_effect
1897 .as_ref()
1898 .is_some_and(agentplane::journal::FailedEffect::refuses_the_account);
1899 let mut output = outcome
1900 .output
1901 .as_ref()
1902 .map(|o| o.peek().clone())
1903 .unwrap_or(serde_json::Value::Null);
1904 if !succeeded && output["reason"].as_str().is_none() {
1905 if !output.is_object() {
1906 output = serde_json::json!({});
1907 }
1908 let reason = if rt.block_on(plane.holds_a_pre_054_decision(outcome.run_id)) {
1911 substrate::PRE_054_DECISION.to_owned()
1912 } else if let agentplane::prelude::RunStatus::Exhausted(limit) = &outcome.status
1913 {
1914 output["status"] = serde_json::json!("budget_exhausted");
1916 substrate::exhausted(limit)
1917 } else if let agentplane::prelude::RunStatus::Quarantined(why) = &outcome.status
1918 {
1919 output["status"] = serde_json::json!("quarantined");
1920 why.clone()
1921 } else {
1922 substrate::why_not(&outcome.status)
1923 };
1924 output["reason"] = serde_json::Value::String(redact(&reason, secret));
1925 }
1926 (outcome.run_id.to_string(), succeeded, output)
1927 };
1928 if bug_candidate.is_some()
1929 && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
1930 {
1931 c.applied = true;
1932 }
1933 if let Some(argv) = run_config.format.as_ref().filter(|_| {
1936 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
1937 }) {
1938 use verify::process::CommandRunner as _;
1939 let ran = runner.run(&verify::process::Invocation {
1940 argv: argv.clone(),
1941 cwd: project.clone(),
1942 env: verify::process::scrubbed_env(std::env::vars()),
1943 timeout: std::time::Duration::from_secs(300),
1944 output_cap: verify::process::OUTPUT_CAP,
1945 });
1946 if ran.exit_code != Some(0) {
1947 notices.push(format!(
1948 "the formatter (`{}`) did not succeed",
1949 argv.join(" ")
1950 ));
1951 }
1952 }
1953 if prepares {
1957 let began = std::time::Instant::now();
1958 match crate::prepare::recheck(&crate::prepare::Prepare {
1959 worktree: &wt_root,
1960 project: &project,
1961 config: &worktree_config,
1962 runner: &runner,
1963 state: Some(state),
1964 }) {
1965 Ok(done) if done.is_empty() => {}
1966 Ok(done) => {
1967 stage(
1968 &*live,
1969 task,
1970 Stage::Prepare,
1971 StageState::Passed,
1972 Some(format!(
1973 "{}: the attempt changed its lockfile",
1974 done.join(", ")
1975 )),
1976 Some(began),
1977 );
1978 }
1979 Err(f) => {
1980 stage(
1981 &*live,
1982 task,
1983 Stage::Prepare,
1984 StageState::Failed,
1985 Some(f.message.clone()),
1986 Some(began),
1987 );
1988 let reason = redact(&f.message, secret);
1992 let saved = attempts::save(
1993 &wt_root,
1994 &tmp,
1995 &feature_name,
1996 task,
1997 &attempts::Record {
1998 run: (!run_id.is_empty()).then_some(run_id.as_str()),
1999 outcome: "prepare_failed",
2000 reason: &reason,
2001 pending: None,
2002 usage: output.get("usage"),
2003 defer_to: None,
2004 },
2005 )
2006 .and_then(|saved| {
2007 worktree::restore(&wt_root, &tmp, &before).map(|_| saved)
2008 });
2009 let message = match saved {
2010 Ok(saved) => format!(
2011 "{} — the attempt is saved as {} and undone",
2012 f.message, saved.reference
2013 ),
2014 Err(e) => format!("{} — saving the attempt failed: {}", f.message, e.0),
2015 };
2016 return fail(CommandStatus::ConfigError, message)
2017 .with_code("PREPARE-FAILED")
2018 .with_fix(f.fix)
2019 .with_commands(f.commands);
2020 }
2021 }
2022 }
2023 let after = match worktree::checkpoint(&wt_root, &tmp) {
2024 Ok(c) => c,
2025 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2026 };
2027 let raw_changes = worktree::diff(&wt_root, &before, &after).unwrap_or_default();
2028 if let (Some(n), Ok(mut asked)) = (
2033 output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
2034 options.asked.lock(),
2035 ) {
2036 *asked = n;
2037 }
2038 let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
2039 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2040 if let Some(name) = crate::include::added_secret(&patch) {
2043 let _ = worktree::restore(&wt_root, &tmp, &before);
2044 return fail(
2045 CommandStatus::NeedsHuman,
2046 format!(
2047 "the attempt's change adds the value of {name}, copied from your checkout ([worktree] include): undone, nothing judged or committed"
2048 ),
2049 )
2050 .with_code("SECRET-IN-DIFF");
2051 }
2052 let findings = attempt_findings(&Audited {
2053 wt_root: &wt_root,
2054 policy: &policy,
2055 judged_before: &judged_before,
2056 raw_changes: &raw_changes,
2057 patch: &patch,
2058 frozen: &frozen,
2059 frozen_additions: run_config.frozen_additions,
2060 approved_writes: &approved_writes,
2061 command_drift: &output["integrity"],
2062 });
2063 let changes = changes_of(raw_changes);
2064
2065 let quarantined = !succeeded && output["status"] == "quarantined";
2070 if quarantined {
2071 output["reason"] = serde_json::Value::String(
2072 "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(),
2073 );
2074 }
2075 let mut attempt_status = if succeeded {
2076 output["status"].as_str().unwrap_or("failed").to_owned()
2077 } else if quarantined {
2078 "interrupted".to_owned()
2079 } else if output["status"] == "budget_exhausted" {
2080 "budget_exhausted".to_owned()
2081 } else {
2082 "failed".to_owned()
2083 };
2084 if attempt_status == "completed" && changes.is_empty() && output.get("acp").is_some() {
2088 attempt_status = "blocked".to_owned();
2089 output["reason"] = serde_json::json!(format!(
2090 "{} ended its turn without changing anything",
2091 output["acp"]["agent"].as_str().unwrap_or("the agent")
2092 ));
2093 }
2094 let mut report = RunReport {
2095 run_id: run_id.clone(),
2096 model_report: output.get("report").cloned(),
2097 reason: output["reason"].as_str().map(str::to_owned),
2098 pending: output.get("pending").cloned(),
2099 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
2100 changes,
2101 usage: output.get("usage").cloned(),
2102 integrity: findings.clone(),
2103 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
2104 set_aside: set_aside.clone(),
2105 notices: notices.clone(),
2106 agent: external::agent_attempt(&output),
2107 share: share.clone(),
2108 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
2109 };
2110 if let Err(e) = record_inline_approvals(
2111 &s.plane_dir,
2112 &feature_name,
2113 task,
2114 &report.tool_calls,
2115 &options.run_grants,
2116 this_run.as_str(),
2117 ) {
2118 report.notices.push(format!(
2119 "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
2120 ));
2121 }
2122 for c in report
2124 .tool_calls
2125 .iter()
2126 .filter(|c| c["approved_by"] == "person")
2127 {
2128 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
2129 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
2130 }
2131 }
2132
2133 stage(
2134 &*live,
2135 task,
2136 Stage::Attempt,
2137 if attempt_status == "completed" {
2138 StageState::Passed
2139 } else {
2140 StageState::Failed
2141 },
2142 Some(if attempt_status == "completed" {
2143 attempt_detail
2144 } else {
2145 format!("{attempt_detail} {attempt_status}")
2146 }),
2147 Some(attempt_began),
2148 );
2149 if attempt_status == "integrity" {
2152 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2153 reasons: findings.clone(),
2154 });
2155 }
2156 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
2158 if judged {
2159 let began = std::time::Instant::now();
2160 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
2161 if let Err(e) = judge_checks(
2162 &project,
2163 &runner,
2164 &baseline,
2165 acceptance.as_ref(),
2166 &findings,
2167 &mut report,
2168 ) {
2169 stage(
2170 &*live,
2171 task,
2172 Stage::Verify,
2173 StageState::Failed,
2174 None,
2175 Some(began),
2176 );
2177 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
2178 }
2179 if let Some(dir) = planning.as_ref().filter(|_| {
2183 matches!(
2184 report.verdict,
2185 Some(
2186 speccoder_core::verify::Verdict::Verified
2187 | speccoder_core::verify::Verdict::RegressionOnly
2188 )
2189 )
2190 }) {
2191 let inside = format!("{dir}/");
2192 let outside: Vec<&str> = report
2193 .changes
2194 .iter()
2195 .filter(|(p, _)| !p.starts_with(&inside))
2196 .map(|(p, _)| p.as_str())
2197 .collect();
2198 let reason = if !outside.is_empty() {
2199 Some(format!(
2200 "a planning task changed code: {}",
2201 outside.join(", ")
2202 ))
2203 } else if !report.changes.iter().any(|(_, kind)| kind != "deleted") {
2204 Some(format!("wrote no planning file under {inside}"))
2205 } else {
2206 None
2207 };
2208 if !outside.is_empty() {
2209 attempt_status = CHANGED_CODE.to_owned();
2211 report.attempt_status = CHANGED_CODE.to_owned();
2212 }
2213 if let Some(r) = reason {
2214 report.verdict =
2215 Some(speccoder_core::verify::Verdict::Failed { reasons: vec![r] });
2216 }
2217 }
2218 let otherwise_passing = attempt_status == "completed"
2222 && findings.is_empty()
2223 && matches!(
2224 report.verdict,
2225 Some(
2226 speccoder_core::verify::Verdict::Verified
2227 | speccoder_core::verify::Verdict::RegressionOnly
2228 )
2229 );
2230 match judge_collection(
2231 (&project, &wt_root, &tmp),
2232 &runner,
2233 otherwise_passing,
2234 &mut report,
2235 &*live,
2236 task,
2237 ) {
2238 Some(Collected::Unknown(why)) => {
2239 attempt_status = "collection_unknown".to_owned();
2240 report.verdict = None;
2241 report.reason = Some(why);
2242 }
2243 Some(Collected::Changed(finding)) => {
2245 report.integrity.push(finding.clone());
2246 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2247 reasons: vec![finding],
2248 });
2249 attempt_status = "integrity".to_owned();
2250 }
2251 None => {}
2252 }
2253 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
2254 stage_with_tail(
2255 &*live,
2256 task,
2257 Stage::Verify,
2258 if checks_held {
2259 StageState::Passed
2260 } else {
2261 StageState::Failed
2262 },
2263 None,
2264 Some(began),
2265 report
2266 .verification
2267 .as_ref()
2268 .map(failing_tail)
2269 .unwrap_or_default(),
2270 );
2271 }
2272 if judged || attempt_status == "integrity" {
2273 stage(
2274 &*live,
2275 task,
2276 Stage::Audit,
2277 if findings.is_empty() {
2278 StageState::Passed
2279 } else {
2280 StageState::Failed
2281 },
2282 findings
2283 .first()
2284 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
2285 None,
2286 );
2287 }
2288
2289 let visibly_passed = judged
2293 && findings.is_empty()
2294 && attempt_status == "completed"
2295 && matches!(
2296 report.verdict,
2297 Some(
2298 speccoder_core::verify::Verdict::Verified
2299 | speccoder_core::verify::Verdict::RegressionOnly
2300 )
2301 );
2302 if let Some(sha) = acceptance
2303 .as_ref()
2304 .and_then(|a| a.held_out.clone())
2305 .filter(|_| visibly_passed)
2306 {
2307 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2308 let mut known = format!(
2309 "{}\n{}\n{}",
2310 bundle_value["story"].as_str().unwrap_or_default(),
2311 bundle_value["plan"].as_str().unwrap_or_default(),
2312 bundle_value["constitution"].as_str().unwrap_or_default()
2313 );
2314 for p in report
2315 .acceptance
2316 .as_ref()
2317 .and_then(|a| a["files"].as_array().cloned())
2318 .unwrap_or_default()
2319 {
2320 if let Some(p) = p.as_str() {
2321 known.push_str(
2322 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2323 );
2324 }
2325 }
2326 match judge_held_out(
2327 &held_out_store,
2328 &sha,
2329 &scenarios,
2330 &known,
2331 &wt_root,
2332 &project,
2333 &tmp,
2334 &before,
2335 &patch,
2336 &runner,
2337 &policy,
2338 ) {
2339 Ok(j) => {
2340 let failed_ids: Vec<String> =
2341 j.failed.iter().map(|s| s.id.clone()).collect();
2342 held_out_log.push(serde_json::json!({
2343 "attempt": held_out_log.len() + 1,
2344 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
2345 "scenarios": failed_ids,
2346 }));
2347 if let Some(v) = report.verdict.take() {
2348 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
2349 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
2350 && !j.failed.is_empty()
2351 && let Some(last) = reasons.last_mut()
2352 {
2353 *last = speccoder_core::retry::held_out_reason(&j.failed);
2354 }
2355 report.verdict = Some(v);
2356 }
2357 }
2358 Err((outcome, why)) => {
2359 held_out_log.push(serde_json::json!({
2360 "attempt": held_out_log.len() + 1,
2361 "outcome": outcome,
2362 }));
2363 report.verdict = None;
2364 report.reason = Some(why);
2365 attempt_status = "held_out".to_owned();
2366 }
2367 }
2368 }
2369 if let Some(fix) = bug_fix.as_mut().filter(|_| {
2372 attempt_status == "completed"
2373 && findings.is_empty()
2374 && matches!(
2375 report.verdict,
2376 Some(
2377 speccoder_core::verify::Verdict::Verified
2378 | speccoder_core::verify::Verdict::RegressionOnly
2379 )
2380 )
2381 }) {
2382 let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
2383 let judging = super::bug::Judging {
2384 before: &before,
2385 baseline: &baseline,
2386 verification: report.verification.as_ref(),
2387 base: report
2388 .verdict
2389 .clone()
2390 .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
2391 keep: &keep,
2392 };
2393 match super::bug::judge(&bug_ctx, fix, &judging) {
2394 Ok(j) => {
2395 report.verdict = Some(j.verdict);
2396 if let Some(weak) = j.weak {
2397 report.reason = Some(format!(
2398 "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
2399 weak.scenario, weak.rationale
2400 ));
2401 bug_weak = Some(weak);
2402 attempt_status = "repro_weak".to_owned();
2403 }
2404 }
2405 Err(e) => {
2406 report.integrity.push(e.clone());
2407 report.reason = Some(e);
2408 report.verdict = None;
2409 attempt_status = "integrity".to_owned();
2410 }
2411 }
2412 }
2413 if let Some(h) = report
2414 .acceptance
2415 .as_mut()
2416 .and_then(|a| a.get_mut("held_out"))
2417 .filter(|h| h["state"] == "on")
2418 {
2419 let failed = held_out_log
2420 .iter()
2421 .filter(|o| o["outcome"] == "failed")
2422 .count();
2423 h["attempts"] = serde_json::json!(held_out_log);
2424 h["gap"] = serde_json::json!(failed > 0);
2425 }
2426
2427 let passing = attempt_status == "completed"
2429 && matches!(
2430 report.verdict,
2431 Some(
2432 speccoder_core::verify::Verdict::Verified
2433 | speccoder_core::verify::Verdict::RegressionOnly
2434 )
2435 );
2436 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
2437 stage(
2438 &*live,
2439 task,
2440 Stage::Mutation,
2441 StageState::Skipped,
2442 Some(if mutation_config.is_none() {
2443 "not configured".to_owned()
2444 } else {
2445 "suite = false: for acceptance strength only".to_owned()
2446 }),
2447 None,
2448 );
2449 }
2450 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
2451 let began = std::time::Instant::now();
2452 stage(
2453 &*live,
2454 task,
2455 Stage::Mutation,
2456 StageState::Started,
2457 None,
2458 None,
2459 );
2460 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2461 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
2462 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
2463 if m.mode == mutation::Mode::Require
2464 && let Some(reason) = mutation::failure(&result)
2465 {
2466 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2467 reasons: vec![reason],
2468 });
2469 }
2470 report.mutation = Some(result);
2471 }
2472 let held = !matches!(
2473 report.verdict,
2474 Some(speccoder_core::verify::Verdict::Failed { .. })
2475 );
2476 stage(
2477 &*live,
2478 task,
2479 Stage::Mutation,
2480 if held {
2481 StageState::Passed
2482 } else {
2483 StageState::Failed
2484 },
2485 report
2486 .mutation
2487 .as_ref()
2488 .and_then(crate::mutation::trailer)
2489 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
2490 Some(began),
2491 );
2492 }
2493
2494 usage = add_usage(usage.as_ref(), report.usage.as_ref());
2495 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
2497 (report.verdict.clone(), report.verification.as_ref())
2498 else {
2499 break (report, attempt_status);
2500 };
2501 if attempt_status != "completed" {
2502 break (report, attempt_status);
2503 }
2504 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
2505 let mut d =
2506 speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
2507 d.uncollected = report
2508 .collection
2509 .as_ref()
2510 .map(speccoder_core::collection::Collection::retry_lines)
2511 .unwrap_or_default();
2512 let fingerprint = speccoder_core::retry::fingerprint(&d);
2513 let stop = if bug_candidate.is_some() {
2514 Some("a candidate is judged, never retried".to_owned())
2515 } else {
2516 retry_stop(
2517 &findings,
2518 verification,
2519 earlier.last().map(|e| e.fingerprint.as_str()),
2520 &fingerprint,
2521 attempt_no,
2522 run_config.max_attempts,
2523 )
2524 };
2525 if let Some(stop) = stop {
2526 report.retry_stop = Some(stop);
2527 break (report, attempt_status);
2528 }
2529 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2530 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
2531 break (report, attempt_status);
2532 }
2533 earlier.push(speccoder_core::EarlierAttempt {
2534 run_id: report.run_id.clone(),
2535 attempt_status: attempt_status.clone(),
2536 verdict: report.verdict.clone(),
2537 fingerprint,
2538 digest: d.clone(),
2539 usage: report.usage.clone(),
2540 });
2541 digest = serde_json::to_value(&d).ok();
2542 attempt_no += 1;
2543 };
2544 report.earlier = earlier;
2545 if run_config.best_of <= 1 {
2546 break (report, attempt_status);
2547 }
2548 let end = match worktree::checkpoint(&wt_root, &tmp) {
2549 Ok(c) => c,
2550 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2551 };
2552 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
2553 let summary = sample_summary(&report, &attempt_status, &patch);
2554 samples.push((report, attempt_status, end, summary));
2555 if samples.len() >= run_config.best_of as usize {
2556 let best = best_sample(&samples);
2557 let summaries: Vec<serde_json::Value> = samples
2558 .iter()
2559 .enumerate()
2560 .map(|(i, s)| {
2561 let mut v = s.3.clone();
2562 v["kept"] = serde_json::json!(i == best);
2563 v
2564 })
2565 .collect();
2566 let (mut chosen, status, end, _) = samples.swap_remove(best);
2567 if best != samples.len() {
2568 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
2570 return fail(CommandStatus::GitPrecondition, e.0);
2571 }
2572 }
2573 chosen.samples = summaries;
2574 break (chosen, status);
2575 }
2576 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2577 return fail(CommandStatus::GitPrecondition, e.0);
2578 }
2579 };
2580 report.usage = usage;
2581 {
2584 use speccoder_core::context::ContextKind as K;
2585 let part = |k: &str| {
2586 bundle_value[k]
2587 .as_str()
2588 .unwrap_or_default()
2589 .as_bytes()
2590 .to_vec()
2591 };
2592 let mut m = crate::context::Manifest::new();
2593 m.push(
2594 K::Constitution,
2595 "worktree",
2596 "/bundle/constitution",
2597 &part("constitution"),
2598 )
2599 .push(K::Plan, "worktree", "/bundle/plan", &part("plan"))
2600 .push(K::Story, "worktree", "/bundle/story", &part("story"))
2601 .push(
2602 K::Task,
2603 "worktree",
2604 "/bundle/task",
2605 serde_json::to_string(&bundle_value["task"])
2606 .unwrap_or_default()
2607 .as_bytes(),
2608 )
2609 .push(
2610 K::TestLayout,
2611 "worktree",
2612 "/test_layout",
2613 test_layout_text.as_bytes(),
2614 );
2615 report.context = Some(m.finish());
2616 }
2617 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
2619
2620 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
2624 let mut attempt_status = attempt_status;
2625 let verified = attempt_status == "completed"
2626 && matches!(
2627 report.verdict,
2628 Some(speccoder_core::verify::Verdict::Verified)
2629 );
2630 let visible_argv = acceptance
2631 .as_ref()
2632 .filter(|a| a.red)
2633 .and_then(|a| a.argv.clone());
2634 if verified && !run_config.strength.enabled {
2635 stage(
2636 &*live,
2637 task,
2638 Stage::Strength,
2639 StageState::Skipped,
2640 Some("strength = false".to_owned()),
2641 None,
2642 );
2643 }
2644 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
2645 use speccoder_core::strength::{Gate, gate, over_strict};
2646 let began = std::time::Instant::now();
2647 stage(
2648 &*live,
2649 task,
2650 Stage::Strength,
2651 StageState::Started,
2652 None,
2653 None,
2654 );
2655 let fin = worktree::checkpoint(&wt_root, &tmp);
2656 let patch = fin
2657 .as_ref()
2658 .ok()
2659 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
2660 .unwrap_or_default();
2661 let mut notes = Vec::new();
2662 let held: Vec<(crate::held_out::HeldOutFile, String)> =
2663 match acceptance.as_ref().and_then(|a| a.held_out.clone()) {
2664 None => Vec::new(),
2665 Some(sha) => match crate::held_out::load_verified(&held_out_store, &sha) {
2666 Ok((manifest, contents)) => manifest
2667 .files
2668 .into_iter()
2669 .zip(contents.into_iter().map(|(_, c)| c))
2670 .collect(),
2671 Err(t) => {
2672 notes.push(format!("held-out set not overlaid: {}", t.0));
2673 Vec::new()
2674 }
2675 },
2676 };
2677 let rulings = crate::strength::rulings(&strength_dir);
2678 let mut commands = vec![argv];
2679 let measure = |commands: &[Vec<String>]| {
2680 crate::strength::measure(
2681 &crate::strength::Setup {
2682 wt_root: &wt_root,
2683 project: &project,
2684 tmp: &tmp,
2685 runner: &runner,
2686 policy: &policy,
2687 acceptance: commands,
2688 held_out: &held,
2689 mutation: mutation_config.as_ref(),
2690 patch: &patch,
2691 config: &run_config.strength,
2692 keep: &strength_dir,
2693 rulings: &rulings,
2694 },
2695 |changed| {
2696 let input = substrate::near_miss::input(
2698 &bundle_value,
2699 changed,
2700 &crate::mutation::source_patch(&patch),
2701 run_config.strength.near_miss,
2702 run_config.strength.permitted,
2703 );
2704 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
2705 Ok(o) => {
2706 let out = o
2707 .output
2708 .as_ref()
2709 .map(|v| v.peek().clone())
2710 .unwrap_or_default();
2711 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
2712 && !out["answer"].is_null();
2713 crate::strength::Authored {
2714 answer: if ok {
2715 Ok(out["answer"].clone())
2716 } else {
2717 Err(format!(
2718 "the variant author did not answer ({})",
2719 crate::substrate::why_not(&o.status)
2720 ))
2721 },
2722 run_id: Some(o.run_id.to_string()),
2723 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
2724 }
2725 }
2726 Err(e) => crate::strength::Authored {
2727 answer: Err(redact(&e.0, secret)),
2728 run_id: None,
2729 usage: None,
2730 },
2731 }
2732 },
2733 )
2734 };
2735 let mut measured = measure(&commands);
2736 let min = run_config.strength.min;
2739 if let (Ok(a), Ok(fin)) = (&measured, &fin)
2740 && gate(min, &a.combined, false) == Gate::Failed
2741 && over_strict(&a.permitted).is_empty()
2742 {
2743 let visible_tests: Vec<(String, String)> = report
2744 .acceptance
2745 .as_ref()
2746 .and_then(|a| a["files"].as_array().cloned())
2747 .unwrap_or_default()
2748 .iter()
2749 .filter_map(|p| p.as_str())
2750 .map(|p| {
2751 (
2752 p.to_owned(),
2753 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2754 )
2755 })
2756 .collect();
2757 let setup = AcceptanceSetup {
2758 rt: &rt,
2759 plane: &plane,
2760 project: &project,
2761 worktree: &wt_root,
2762 tmp: &tmp,
2763 policy: &policy,
2764 feature: &feature_name,
2765 task,
2766 bundle: bundle_value.clone(),
2767 scenarios: &scenarios,
2768 instructions: &instructions,
2769 runner: &runner,
2770 layout: Some(&test_layout),
2771 story_files: &story_files,
2772 };
2773 let weak = crate::strength::weak(a);
2774 match strengthen(&setup, &visible_tests, weak, &before, fin) {
2775 Ok(added) => {
2776 if let Some(acc) = report.acceptance.as_mut() {
2777 if let Some(files) = acc["files"].as_array_mut() {
2778 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
2779 }
2780 acc["strengthened"] = serde_json::json!({
2781 "files": added.files,
2782 "argv": added.argv,
2783 "run_id": added.report["run_id"],
2784 "usage": added.report["usage"],
2785 });
2786 }
2787 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
2788 commands.push(added.argv);
2789 let mut again = measure(&commands);
2790 if let Ok(b) = again.as_mut() {
2791 b.gate = gate(min, &b.combined, true);
2792 b.notes.insert(
2793 0,
2794 "strengthened once with added acceptance tests".to_owned(),
2795 );
2796 }
2797 measured = again;
2798 }
2799 Err(why) => {
2800 if let Ok(a) = measured.as_mut() {
2801 a.notes
2802 .insert(0, format!("could not strengthen the set: {why}"));
2803 }
2804 }
2805 }
2806 }
2807 match measured {
2808 Ok(mut a) => {
2809 if a.gate == Gate::Off {
2810 a.gate = gate(min, &a.combined, false);
2811 }
2812 a.notes.extend(notes);
2813 let findings = over_strict(&a.permitted);
2814 if !findings.is_empty() {
2815 attempt_status = "over_strict".to_owned();
2816 report.reason = Some(format!(
2817 "an acceptance test rejects an implementation the spec appears to allow: {}",
2818 findings
2819 .iter()
2820 .map(|p| format!("{} ({})", p.scenario, p.rationale))
2821 .collect::<Vec<_>>()
2822 .join("; ")
2823 ));
2824 } else if a.gate == Gate::Failed {
2825 attempt_status = "acceptance_weak".to_owned();
2826 report.reason = Some(format!(
2827 "acceptance set too weak: strength {} is below min_strength {}{}",
2828 speccoder_core::strength::summary(&a.combined, None),
2829 min.unwrap_or_default(),
2830 a.notes
2831 .first()
2832 .map(|n| format!(" ({n})"))
2833 .unwrap_or_default()
2834 ));
2835 } else {
2836 crate::strength::release(&strength_dir);
2837 }
2838 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
2840 report.strength = Some(a);
2841 }
2842 Err(e) => {
2843 report.integrity.push(e.clone());
2844 report.reason = Some(e);
2845 report.verdict = None;
2846 attempt_status = "integrity".to_owned();
2847 }
2848 }
2849 report.attempt_status.clone_from(&attempt_status);
2850 let held_up = attempt_status == "completed";
2851 stage(
2852 &*live,
2853 task,
2854 Stage::Strength,
2855 if held_up {
2856 StageState::Passed
2857 } else {
2858 StageState::Failed
2859 },
2860 report.strength.as_ref().map(|a| {
2861 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
2862 }),
2863 Some(began),
2864 );
2865 }
2866
2867 let verdict_passed = attempt_status == "completed"
2871 && matches!(
2872 report.verdict,
2873 Some(
2874 speccoder_core::verify::Verdict::Verified
2875 | speccoder_core::verify::Verdict::RegressionOnly
2876 )
2877 );
2878 let mut suspected = false;
2879 if !run_config.monitor && verdict_passed {
2880 stage(
2881 &*live,
2882 task,
2883 Stage::Monitor,
2884 StageState::Skipped,
2885 Some("not enabled".to_owned()),
2886 None,
2887 );
2888 }
2889 if run_config.monitor && verdict_passed {
2890 let began = std::time::Instant::now();
2891 stage(
2892 &*live,
2893 task,
2894 Stage::Monitor,
2895 StageState::Started,
2896 None,
2897 None,
2898 );
2899 let patch = worktree::checkpoint(&wt_root, &tmp)
2900 .and_then(|now| worktree::patch(&wt_root, &start, &now))
2901 .unwrap_or_default();
2902 let calls: Vec<serde_json::Value> = report
2903 .tool_calls
2904 .iter()
2905 .map(|c| {
2906 let tool = c["tool"].as_str().unwrap_or_default();
2907 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
2908 })
2909 .collect();
2910 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
2911 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
2912 report.monitor = Some(monitor);
2913 suspected = suspicion;
2914 stage(
2915 &*live,
2916 task,
2917 Stage::Monitor,
2918 if suspected {
2919 StageState::Failed
2920 } else {
2921 StageState::Passed
2922 },
2923 suspected.then(|| "suspects gaming".to_owned()),
2924 Some(began),
2925 );
2926 }
2927
2928 let in_place_stop = options
2931 .session
2932 .as_ref()
2933 .filter(|_| verdict_passed && !suspected)
2934 .and_then(|s| {
2935 s.lock()
2936 .ok()
2937 .and_then(|s| s.check_before_tick(&policy).err())
2938 });
2939 if let Some(e) = &in_place_stop {
2940 report.reason = Some(e.line());
2941 report.verdict = None;
2942 attempt_status = e.code.to_lowercase().replace('-', "_");
2944 report.attempt_status.clone_from(&attempt_status);
2945 }
2946 let passed = verdict_passed && !suspected && in_place_stop.is_none();
2947 if passed
2951 && matches!(
2952 report.verdict,
2953 Some(speccoder_core::verify::Verdict::RegressionOnly)
2954 )
2955 {
2956 use speccoder_core::regression::{Regression, RegressionFacts, RegressionReason, decide};
2957 let facts = RegressionFacts {
2958 acceptance_on: run_config.acceptance,
2959 story: acceptance_story
2961 .clone()
2962 .or_else(|| bug_fix.as_ref().map(|f| f.bug.requirement.clone()))
2963 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone())),
2964 scenarios: scenarios.len(),
2965 skip: acceptance.as_ref().and_then(|a| a.skip_cause.clone()),
2966 red_class: acceptance.as_ref().and_then(|a| {
2968 if a.red
2969 && report
2970 .acceptance
2971 .as_ref()
2972 .is_some_and(|r| r["green_flaky"] == true)
2973 {
2974 Some(speccoder_core::verify::RedClass::Flaky)
2975 } else {
2976 a.red_class
2977 }
2978 }),
2979 };
2980 let mut reason = if planning.is_some() {
2981 RegressionReason::PlanningWork
2982 } else if let Some(checkpoint) = deferred_to.clone() {
2983 RegressionReason::DeferredToCheckpoint { checkpoint }
2984 } else if story_verified {
2985 RegressionReason::StoryVerified
2986 } else {
2987 decide(&facts)
2988 };
2989 if matches!(reason, RegressionReason::AlreadyPassing { .. })
2992 && bug_fix.is_none()
2993 && let Some(a) = acceptance.as_ref()
2994 && let Some(argv) = a.argv.as_ref()
2995 {
2996 let mut files: Vec<(String, String)> = a.report["files"]
2997 .as_array()
2998 .into_iter()
2999 .flatten()
3000 .filter_map(|f| f.as_str())
3001 .filter_map(|f| {
3002 std::fs::read_to_string(project.join(f))
3003 .ok()
3004 .map(|c| (f.to_owned(), c))
3005 })
3006 .collect();
3007 let prefix = project
3008 .strip_prefix(&wt_root)
3009 .map(|p| p.to_string_lossy().replace('\\', "/"))
3010 .unwrap_or_default();
3011 let mut command = Some(argv.clone());
3014 if a.report["appended"] == true
3015 && let Some(story) = a.report["story_file"].as_str()
3016 {
3017 let at = if prefix.is_empty() {
3018 story.to_owned()
3019 } else {
3020 format!("{prefix}/{story}")
3021 };
3022 let old = crate::git::Git::at(&wt_root)
3023 .run(&["show", &format!("HEAD:{at}")])
3024 .map(|b| String::from_utf8_lossy(&b).into_owned())
3025 .unwrap_or_default();
3026 command = None;
3027 if let Some((_, content)) = files.iter_mut().find(|(f, _)| f == story)
3028 && let Some(added) = content.strip_prefix(old.as_str()).map(str::to_owned)
3029 {
3030 command = super::bisect::section_command(argv, story, &added);
3031 let imports: String = old
3032 .lines()
3033 .filter(|l| {
3034 let t = l.trim_start();
3035 ["use ", "import ", "from ", "package ", "extern crate "]
3036 .iter()
3037 .any(|k| t.starts_with(k))
3038 })
3039 .map(|l| format!("{l}\n"))
3040 .collect();
3041 *content = format!("{imports}{added}");
3042 }
3043 }
3044 let timeout = std::fs::read_to_string(project.join(crate::verify::config::FILE))
3045 .ok()
3046 .and_then(|t| crate::verify::config::parse(&t).ok())
3047 .and_then(|cs| {
3048 cs.into_iter()
3049 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
3050 .map(|c| c.timeout)
3051 .max()
3052 })
3053 .unwrap_or(std::time::Duration::from_secs(600));
3054 let found = match &command {
3055 Some(argv) => super::bisect::search(&super::bisect::Search {
3056 repo: &wt_root,
3057 prefix: &prefix,
3058 feature: &feature_name,
3059 task,
3060 files: &files,
3061 argv,
3062 scratch: &s.plane_dir.join("tmp"),
3063 sandbox: &sandbox_config,
3064 timeout,
3065 worktree: &worktree_config,
3066 checkout: Path::new(&s.snapshot.root),
3067 state: Some(state),
3068 }),
3069 None => super::bisect::Found {
3071 implemented_by: None,
3072 runs: 0,
3073 },
3074 };
3075 live.event(&progress::Event::RegressionBisected {
3076 task: task.to_owned(),
3077 runs: found.runs,
3078 result: found.implemented_by.as_ref().map(|i| match i {
3079 speccoder_core::regression::ImplementedBy::Task { task, .. } => task.clone(),
3080 speccoder_core::regression::ImplementedBy::BeforeFeature { .. } => {
3081 "before_feature".to_owned()
3082 }
3083 }),
3084 });
3085 reason = RegressionReason::AlreadyPassing {
3086 implemented_by: found.implemented_by,
3087 };
3088 }
3089 report.regression = Some(Regression::of(reason));
3090 }
3091 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
3092 let began = std::time::Instant::now();
3093 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3094 let message = tick_message(&TickFacts {
3095 feature: &feature_name,
3096 task,
3097 title: &t.title,
3098 report: &report,
3099 scenarios: &story_scenarios,
3100 red: acceptance
3101 .as_ref()
3102 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
3103 checklists_overridden,
3104 extra: bug_fix
3105 .as_ref()
3106 .map(|f| {
3107 super::bug::trailers(
3108 f,
3109 &report
3110 .verdict
3111 .as_ref()
3112 .map(super::outcome::verdict_word)
3113 .unwrap_or_default(),
3114 &crate::strength::rulings(&strength_dir),
3115 )
3116 })
3117 .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
3119 .unwrap_or_default(),
3120 });
3121 match &options.session {
3122 None => {
3124 match format.tick(file, task, &t.line_hash) {
3125 Ok(()) => report.ticked = true,
3126 Err(e) => report.integrity.push(format!("tick refused: {e}")),
3127 }
3128 if report.ticked && run_config.commit_per_task {
3129 match commit_attempt(&wt_root, &message) {
3130 Ok(id) => report.commit = Some(id),
3131 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3132 }
3133 }
3134 }
3135 Some(session) => {
3138 let digest =
3139 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
3140 if let Ok(mut s) = session.lock() {
3141 let done = s.commit_and_tick(
3142 &plane_dir,
3143 file,
3144 &t.line_hash,
3145 &message,
3146 &digest,
3147 || format.tick(file, task, &t.line_hash),
3148 );
3149 report.ticked = done.ticked;
3150 report.commit = done.commit;
3151 report.integrity.extend(done.problems);
3152 }
3153 }
3154 }
3155 report.instructions = attempt_instructions
3156 .iter()
3157 .map(|i| speccoder_core::InstructionFile {
3158 path: i.path.clone(),
3159 sha256: i.sha256.clone(),
3160 bytes: i.bytes,
3161 cut: i.cut,
3162 })
3163 .collect();
3164 if let Some(commit) = report
3166 .commit
3167 .clone()
3168 .filter(|_| report.ticked && run_config.attest.enabled)
3169 {
3170 let verdict = report
3171 .verdict
3172 .as_ref()
3173 .map(super::outcome::verdict_word)
3174 .unwrap_or_default();
3175 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3176 let feature_dir = snap
3177 .feature
3178 .as_ref()
3179 .map(|f| f.feature.dir.clone())
3180 .unwrap_or_default();
3181 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
3182 let tick = crate::attest::Tick {
3183 repo: &wt_root,
3184 config: &config_text,
3185 attest: &run_config.attest,
3186 format: &format!("{:?}", format.kind()).to_lowercase(),
3187 feature: &feature_name,
3188 feature_dir: &feature_dir,
3189 task,
3190 title: &t.title,
3191 line_hash: &t.line_hash,
3192 section: t.story.as_deref(),
3193 section_text: bundle_value["story"].as_str().unwrap_or_default(),
3194 requirements: &requirements,
3195 commit: &commit,
3196 verdict: &verdict,
3197 sandbox: serde_json::json!({
3198 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3199 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3200 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3201 }),
3202 executor: serde_json::json!({
3203 "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()) },
3204 "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
3205 }),
3206 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3207 bug: bug_fix
3208 .as_ref()
3209 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3210 };
3211 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3212 }
3213 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
3214 stage(
3215 &*live,
3216 task,
3217 Stage::Commit,
3218 if committed {
3219 StageState::Passed
3220 } else {
3221 StageState::Failed
3222 },
3223 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3224 Some(began),
3225 );
3226 }
3227 if passed && let Some(n) = &native {
3230 let began = std::time::Instant::now();
3231 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3232 let verdict = report
3233 .verdict
3234 .as_ref()
3235 .map(super::outcome::verdict_word)
3236 .unwrap_or_default();
3237 let message = tick_message(&TickFacts {
3238 feature: &feature_name,
3239 task,
3240 title: &n.title,
3241 report: &report,
3242 scenarios: &story_scenarios,
3243 red: acceptance
3244 .as_ref()
3245 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
3246 checklists_overridden: false,
3247 extra: bug_fix
3248 .as_ref()
3249 .map(|f| {
3250 super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
3251 })
3252 .unwrap_or_default(),
3253 });
3254 match commit_attempt(&wt_root, &message) {
3255 Ok(id) => report.commit = Some(id),
3256 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3257 }
3258 if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
3259 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3260 let tick = crate::attest::Tick {
3261 repo: &wt_root,
3262 config: &config_text,
3263 attest: &run_config.attest,
3264 format: &format!("{:?}", format.kind()).to_lowercase(),
3265 feature: &feature_name,
3266 feature_dir: &n.dir,
3267 task,
3268 title: &n.title,
3269 line_hash: "",
3270 section: None,
3271 section_text: &n.story,
3272 requirements: &[],
3273 commit: &commit,
3274 verdict: &verdict,
3275 sandbox: serde_json::json!({
3276 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3277 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3278 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3279 }),
3280 executor: serde_json::json!({
3281 "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
3282 }),
3283 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3284 bug: bug_fix
3285 .as_ref()
3286 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3287 };
3288 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3289 }
3290 stage(
3291 &*live,
3292 task,
3293 Stage::Commit,
3294 if report.commit.is_some() {
3295 StageState::Passed
3296 } else {
3297 StageState::Failed
3298 },
3299 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3300 Some(began),
3301 );
3302 }
3303
3304 if attempt_status == "repro_weak"
3309 && let Some(weak) = &bug_weak
3310 && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
3311 {
3312 report
3313 .integrity
3314 .push(format!("holding the attempt failed: {e}"));
3315 }
3316 if attempt_status == "over_strict" || attempt_status == "repro_weak" {
3317 let findings = report
3318 .strength
3319 .as_ref()
3320 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
3321 .unwrap_or_default();
3322 if attempt_status == "over_strict"
3323 && let Err(e) = crate::strength::hold(&strength_dir, &findings)
3324 {
3325 report
3326 .integrity
3327 .push(format!("holding the attempt failed: {e}"));
3328 }
3329 marker_guard.keep();
3330 }
3331 if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
3334 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
3335 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
3336 let reason = redact(&reason, secret);
3337 let defer_to = match &share {
3340 Some(speccoder_core::story::Share::Authored {
3341 checkpoint: Some(c),
3342 ..
3343 }) if matches!(
3344 outcome.as_str(),
3345 "incomplete" | "verification_failed" | "blocked"
3346 ) && report.acceptance.as_ref().is_some_and(|a| a["red"] == true) =>
3347 {
3348 Some(c.as_str())
3349 }
3350 _ => None,
3351 };
3352 let record = attempts::Record {
3353 run,
3354 outcome: &outcome,
3355 reason: &reason,
3356 pending: report.pending.as_ref(),
3357 usage: report.usage.as_ref(),
3358 defer_to,
3359 };
3360 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
3361 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
3362 {
3363 Ok(saved) => report.saved_attempt = Some(saved.reference),
3364 Err(e) => report
3365 .integrity
3366 .push(format!("saving the attempt failed: {}", e.0)),
3367 }
3368 }
3369
3370 let (status, mut message) = status_and_message(&report, &attempt_status, suspected);
3371 if status == CommandStatus::RunFailed
3374 && let Some(region) = s.model.region.as_deref()
3375 && report
3376 .reason
3377 .as_deref()
3378 .and_then(substrate::provider_refusal)
3379 .is_some()
3380 {
3381 message.push_str(&format!(
3382 " — Amazon Bedrock, region {region}, model {}",
3383 s.model.model
3384 ));
3385 }
3386 let refusal = |result: CommandResult| {
3389 if !result.has_explicit_code()
3390 && refused_account
3391 && result.status() == CommandStatus::RunFailed
3392 {
3393 result.with_code("PROVIDER-REFUSED").with_fix(
3394 substrate::model_config::provider_refused_fix(
3395 &s.model,
3396 crate::aws::detect_here().as_ref(),
3397 feature,
3398 ),
3399 )
3400 } else {
3401 result
3402 }
3403 };
3404 let offered = crate::ladder::offer_for(
3406 path,
3407 &worktree::project_state_dir(state, path),
3408 &format!("{feature_name}/{task}"),
3409 )
3410 .map(|o| progress::Event::LadderOffered {
3411 unit: o.unit,
3412 task: o.task,
3413 signals: o.signals.into_iter().map(|s| s.id).collect(),
3414 });
3415 if let Some(fix) = bug_fix.as_mut() {
3416 let done = refusal(super::bug::finished(
3417 &bug_ctx,
3418 fix,
3419 report,
3420 &attempt_status,
3421 passed,
3422 status,
3423 redact(&message, secret),
3424 ));
3425 if let Some(e) = &offered {
3426 live.event(e);
3427 }
3428 return done;
3429 }
3430 live.event(&finished_event(
3431 task,
3432 &report,
3433 &attempt_status,
3434 suspected,
3435 redact(&message, secret),
3436 ));
3437 if let Some(e) = &offered {
3438 live.event(e);
3439 }
3440 let code = collection_code(&report, &attempt_status);
3441 let result = CommandResult::new("run", status, &redact(&message, secret));
3442 match code {
3443 Some(c) => result.with_code(c),
3444 None => refusal(result),
3445 }
3446 .with_data(CommandData::Run(Box::new(report)))
3447}
3448
3449fn collection_code(
3452 report: &speccoder_core::RunReport,
3453 attempt_status: &str,
3454) -> Option<&'static str> {
3455 if attempt_status == "collection_unknown" {
3456 return Some("TESTS-COLLECTION-UNKNOWN");
3457 }
3458 match &report.verdict {
3459 Some(speccoder_core::verify::Verdict::Failed { reasons })
3460 if reasons
3461 .first()
3462 .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
3463 {
3464 Some("TESTS-NOT-COLLECTED")
3465 }
3466 _ => None,
3467 }
3468}
3469
3470enum Collected {
3476 Unknown(String),
3478 Changed(String),
3480}
3481
3482fn judge_collection(
3483 dirs: (&Path, &Path, &Path),
3484 runner: &sandbox::Runner,
3485 otherwise_passing: bool,
3486 report: &mut speccoder_core::RunReport,
3487 live: &dyn progress::Progress,
3488 task: &str,
3489) -> Option<Collected> {
3490 let (project, wt_root, tmp) = dirs;
3491 let before = worktree::checkpoint(wt_root, tmp).ok();
3493 let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
3494 .ok()
3495 .and_then(|t| crate::verify::config::parse(&t).ok())
3496 .unwrap_or_default()
3497 .into_iter()
3498 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
3499 .collect::<Vec<_>>();
3500 let results = report
3501 .verification
3502 .as_ref()
3503 .map(|v| v.checks.clone())
3504 .unwrap_or_default();
3505 let c = crate::collection::judge(&crate::collection::Input {
3506 root: project,
3507 changes: &report.changes,
3508 checks: &checks,
3509 results: &results,
3510 runner,
3511 listings: otherwise_passing,
3512 })?;
3513 if !c.listing.is_empty()
3514 && let Some(before) = &before
3515 {
3516 let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
3517 .and_then(|after| worktree::diff(wt_root, before, &after))
3518 .map(|d| {
3519 d.into_iter()
3520 .map(|c| c.path)
3521 .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
3522 .collect()
3523 })
3524 .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
3525 if !changed.is_empty() {
3526 report.collection = Some(c);
3527 return Some(Collected::Changed(format!(
3528 "integrity: a test listing changed the worktree: {}",
3529 changed.join(", ")
3530 )));
3531 }
3532 }
3533 live.event(&progress::Event::CollectionChecked {
3534 task: task.to_owned(),
3535 files: c
3536 .files
3537 .iter()
3538 .map(|f| progress::CollectionFile {
3539 path: f.path.clone(),
3540 state: f.state,
3541 decided_by: f.decided_by,
3542 })
3543 .collect(),
3544 });
3545 let never = c.reasons();
3546 let unknown: Vec<String> = c
3547 .unknown()
3548 .map(|f| {
3549 format!(
3550 "speccoder cannot tell whether the runner ran `{}` ({})",
3551 f.path,
3552 f.why.as_deref().unwrap_or("no report or listing names it")
3553 )
3554 })
3555 .collect();
3556 report.collection = Some(c);
3557 if !never.is_empty() {
3558 let mut reasons = never;
3559 if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
3560 report.verdict.take()
3561 {
3562 reasons.extend(more);
3563 }
3564 report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
3565 return None;
3566 }
3567 (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
3568}
3569
3570fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
3573 let said = c
3574 .stderr_tail
3575 .lines()
3576 .chain(c.stdout_tail.lines())
3577 .map(str::trim)
3578 .find(|l| l.starts_with("error"))
3579 .map(|l| l.chars().take(200).collect::<String>());
3580 let fix = match said.as_deref() {
3581 Some(l) if l.contains("profile `speccoder` not found") => Some(
3582 "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
3583 ),
3584 Some(l) if l.contains("no tests to run") => Some(
3585 "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",
3586 ),
3587 Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
3588 "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
3589 ),
3590 _ => None,
3591 };
3592 match (said, fix) {
3593 (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
3594 (Some(s), None) => format!("{note} ({s})"),
3595 _ => note.to_owned(),
3596 }
3597}
3598
3599#[must_use]
3603pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
3604 use speccoder_core::verify::CheckStatus;
3605 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
3606 return None;
3607 }
3608 if let Some(note) = &c.note {
3609 return Some(with_cause(note, c));
3610 }
3611 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
3612 return None;
3613 }
3614 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
3615 let runner = c
3619 .argv
3620 .first()
3621 .map(|a| a.rsplit('/').next().unwrap_or(a))
3622 .is_some_and(|a| {
3623 matches!(
3624 a,
3625 "cargo"
3626 | "pytest"
3627 | "python"
3628 | "python3"
3629 | "uv"
3630 | "npm"
3631 | "npx"
3632 | "pnpm"
3633 | "yarn"
3634 | "bun"
3635 | "go"
3636 | "jest"
3637 | "vitest"
3638 | "mvn"
3639 | "gradle"
3640 | "dotnet"
3641 | "mix"
3642 | "swift"
3643 )
3644 });
3645 let cannot_start = matches!(c.exit_code, Some(126 | 127));
3646 let is_broken = match &summary {
3647 _ if cannot_start => true,
3648 Some(s) if s.build_failed => true,
3649 Some(s) => s.passed + s.failed + s.errors == 0,
3650 None => runner,
3651 };
3652 match is_broken {
3653 false => None,
3654 true => {
3655 let first = c
3656 .stderr_tail
3657 .lines()
3658 .chain(c.stdout_tail.lines())
3659 .map(str::trim)
3660 .find(|l| l.to_lowercase().contains("error"))
3661 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
3662 .unwrap_or("it failed without running a test")
3663 .chars()
3664 .take(160)
3665 .collect::<String>();
3666 Some(first)
3667 }
3668 }
3669}
3670
3671fn login(
3675 fail: impl FnOnce(CommandStatus, String) -> CommandResult,
3676 shown: &str,
3677 agent: &crate::acp::agent::Agent,
3678 ways: &[crate::acp::session::SignIn],
3679) -> CommandResult {
3680 let words: Vec<String> = ways
3681 .iter()
3682 .filter(|w| w.command.is_none())
3683 .map(|w| {
3684 format!(
3685 "→ {}",
3686 w.description.clone().unwrap_or_else(|| w.name.clone())
3687 )
3688 })
3689 .collect();
3690 let mut commands: Vec<Vec<String>> = ways
3691 .iter()
3692 .filter_map(|w| w.command.as_deref())
3693 .map(|c| c.split_whitespace().map(str::to_owned).collect())
3694 .collect();
3695 if words.is_empty()
3696 && commands.is_empty()
3697 && let Some(p) = agent.preset()
3698 {
3699 commands.push(p.login.split_whitespace().map(str::to_owned).collect());
3700 }
3701 commands.dedup();
3702 let argvs: Vec<speccoder_core::step::Argvs> = commands.into_iter().map(|c| vec![c]).collect();
3703 let mut fix: Vec<String> = argvs
3704 .iter()
3705 .map(|a| speccoder_core::step::render(a))
3706 .collect();
3707 fix.extend(words);
3708 fix.push("speccoder doctor".to_owned());
3709 fail(
3710 CommandStatus::NeedsHuman,
3711 format!("{shown} needs signing in before it can make the attempt; nothing was attempted"),
3712 )
3713 .with_code("EXECUTOR-LOGIN")
3714 .with_fix(fix)
3715 .with_commands(argvs)
3716}