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