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#[allow(clippy::too_many_lines)] pub(crate) fn run_task(
95 path: &Path,
96 feature: Option<&str>,
97 task: &str,
98 state: &Path,
99 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
100 secret: Option<&str>,
101 options: &RunOptions,
102) -> CommandResult {
103 use speccoder_core::RunReport;
104 if options.in_place && options.session.is_none() {
106 return in_session("run", path, feature, state, options, |o| {
107 run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
108 });
109 }
110 let approver = options.approver.clone();
111 use speccoder_core::snapshot::TaskState;
112
113 let refuse =
114 |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
115
116 let s = match setup(path, feature, state, "run") {
118 Ok(s) => s,
119 Err(r) => return *r,
120 };
121 let Some(f) = s.snapshot.feature.as_ref() else {
122 return refuse(
123 CommandStatus::SelectionNeeded,
124 "no feature selected; pass --feature".to_owned(),
125 );
126 };
127 let feature_name = f.feature.name.clone();
128 let plane_dir = match &options.lane {
131 Some(lane) => s.plane_dir.join("lanes").join(lane),
132 None => s.plane_dir.clone(),
133 };
134 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
136 &plane_dir,
137 s.model.prices,
138 options.progress.clone(),
139 ));
140
141 let wt = workspace(path, Some(&feature_name), state, options);
143 let Some(CommandData::Worktree(w)) = wt.data() else {
144 return CommandResult::carried("run", &wt);
145 };
146 if wt.status() != CommandStatus::Ok {
147 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
148 }
149 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
150 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
151 };
152 let (wt_path, project_dir) = match &options.lane {
153 None => (wt_path, project_dir),
154 Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
155 {
156 Ok(paths) => paths,
157 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
158 },
159 };
160 let project = std::path::PathBuf::from(&project_dir);
161 let wt_root = std::path::PathBuf::from(&wt_path);
162 let tmp = plane_dir.join("tmp");
163
164 if options.lane.is_none()
167 && let Some((other, covers)) =
168 crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task)
169 {
170 return refuse(
171 CommandStatus::NeedsHuman,
172 format!(
173 "{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}`"
174 ),
175 );
176 }
177 if options.lane.is_none()
179 && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
180 {
181 return refuse(
182 CommandStatus::NeedsHuman,
183 format!(
184 "{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`"
185 ),
186 )
187 .with_code("TESTS-OVER-STRICT");
188 }
189 let commits_per_task = verify::find_config(&project)
193 .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
194 .and_then(|t| substrate::run_config::parse(&t).ok())
195 .is_none_or(|c| c.commit_per_task);
196 let marker_path = resume::path(&plane_dir, &feature_name, task);
199 if options.fresh_acceptance && options.lane.is_none() {
202 let _ = std::fs::remove_file(&marker_path);
203 crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
204 }
205 let head_now = git::Git::at(&wt_root)
206 .line(&["rev-parse", "HEAD"])
207 .unwrap_or_default();
208 let interrupted = resume::load(&marker_path)
209 .filter(|m| m.head == head_now && options.lane.is_none() && !head_now.is_empty());
210 let marker_guard = resume::Guard::new(marker_path.clone());
211 let resumed_from = interrupted.as_ref().map(|_| {
214 std::fs::read_to_string(plane_dir.join(progress::FILE))
215 .ok()
216 .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
217 .filter(|l| l.task == task)
218 .map_or((None, None), |l| (l.stage, l.last_call))
219 });
220 let mut set_aside = None;
221 if interrupted.is_none()
223 && commits_per_task
224 && options.session.is_none()
225 && worktree::require_clean(&wt_root).is_err()
226 {
227 match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
228 Ok(reference) => set_aside = Some(reference),
229 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
230 }
231 }
232
233 let format = crate::format::at(&project);
235 let Some(snap) = format.inspect(&project, Some(&feature_name)) else {
236 return refuse(
237 CommandStatus::NotAProject,
238 format.messages().no_project_in_worktree.to_owned(),
239 );
240 };
241 let Some(st) = snap
244 .feature
245 .as_ref()
246 .and_then(|f| f.schedule.as_ref())
247 .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
248 else {
249 return refuse(
250 CommandStatus::NotFound,
251 format!("no task {task} in {feature_name}"),
252 );
253 };
254 match st.state {
255 TaskState::Done => {
256 return refuse(
257 CommandStatus::TaskNotReady,
258 format!("{task} is already done"),
259 )
260 .with_code("TASK-DONE");
261 }
262 TaskState::Blocked => {
263 return refuse(
264 CommandStatus::TaskNotReady,
265 format!("{task} is blocked by {}", st.blocked_by.join(", ")),
266 )
267 .with_code("TASK-BLOCKED");
268 }
269 TaskState::Ready => {}
270 }
271 let checklists_overridden =
274 match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
275 Ok(overridden) => overridden,
276 Err(stop) => return *stop,
277 };
278 let parsed_task = snap
279 .feature
280 .as_ref()
281 .and_then(|f| f.tasks.as_ref())
282 .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
283 let tasks_file = snap
284 .feature
285 .as_ref()
286 .and_then(|f| f.tasks.as_ref())
287 .map(|t| project.join(&t.path));
288
289 let bundle = match format.bundle(&snap, task) {
290 Ok(b) => b,
291 Err(_) => {
292 return refuse(
293 CommandStatus::NotFound,
294 format!("no task {task} in the worktree's {feature_name}"),
295 );
296 }
297 };
298 let Some(config_root) = verify::find_config(&project) else {
299 return refuse(
300 CommandStatus::ConfigError,
301 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
302 )
303 .with_code("CONFIG-MISSING");
304 };
305 let config_text =
306 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
307 let run_config = match substrate::run_config::parse(&config_text) {
308 Ok(c) => c,
309 Err(e) => {
310 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
311 .with_code("CONFIG-INVALID");
312 }
313 };
314 if run_config.review_required
317 && let Some(f) = snap.feature.as_ref()
318 && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
319 && !t.complete()
320 {
321 return refuse(
322 CommandStatus::NeedsHuman,
323 format!(
324 "{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",
325 t.approved, t.open, t.changes_requested, t.lapsed, t.questions
326 ),
327 )
328 .with_code("REVIEW-PENDING");
329 }
330 let executor = match external::parse(&config_text) {
331 Ok(e) => e,
332 Err(e) => {
333 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
334 .with_code("CONFIG-INVALID");
335 }
336 };
337 let sandbox_config = match sandbox::parse(&config_text) {
339 Ok(c) => c,
340 Err(e) => {
341 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
342 .with_code("CONFIG-INVALID");
343 }
344 };
345 let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
346 Ok(r) => r,
347 Err(e) => return refuse(CommandStatus::ConfigError, e),
348 };
349 let mutation_config = match mutation::parse(&config_text) {
350 Ok(c) => c,
351 Err(e) => {
352 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
353 .with_code("CONFIG-INVALID");
354 }
355 };
356 let check_argvs = verify::check_argvs(&config_root);
357 let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow).rules(format.rules());
358 let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
361 {
362 Some(s) => s.confine(policy, runner),
363 None => (policy, runner, None),
364 };
365 let sandbox_notice = sandbox_notice.or(carve_notice);
366 if let Some(e) = options.session.as_ref().and_then(|s| {
369 s.lock().ok().and_then(|s| {
370 if interrupted.is_some() {
372 s.check_before_tick(&policy).err()
373 } else {
374 s.check(&policy).err()
375 }
376 })
377 }) {
378 return e.result("run");
379 }
380
381 let wt_root_canonical = std::fs::canonicalize(&wt_root)
382 .unwrap_or_else(|_| wt_root.clone())
383 .display()
384 .to_string();
385
386 let decisions = match approvals::Decisions::load(&s.plane_dir) {
388 Ok(d) => d.for_task(&feature_name, task),
389 Err(e) => return refuse(CommandStatus::ConfigError, e),
390 };
391
392 if let Some((stage, last_call)) = &resumed_from {
393 live.event(&progress::Event::Resumed {
394 task: task.to_owned(),
395 stage: *stage,
396 last_call: last_call.clone(),
397 });
398 }
399 live.event(&progress::Event::TaskStarted {
400 feature: feature_name.clone(),
401 task: task.to_owned(),
402 title: parsed_task
403 .as_ref()
404 .map(|t| t.title.clone())
405 .unwrap_or_default(),
406 });
407 let fail = |status: CommandStatus, m: String| {
409 live.event(&progress::Event::TaskFinished {
410 task: task.to_owned(),
411 outcome: "error".to_owned(),
412 message: redact(&m, secret),
413 run: None,
414 input_tokens: 0,
415 output_tokens: 0,
416 cache_read_tokens: 0,
417 cache_write_tokens: 0,
418 usd: None,
419 });
420 refuse(status, m)
421 };
422 let began = std::time::Instant::now();
424 stage(
425 &*live,
426 task,
427 Stage::Baseline,
428 StageState::Started,
429 None,
430 None,
431 );
432 let baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
433 Ok(b) => b,
434 Err(e) => {
435 stage(
436 &*live,
437 task,
438 Stage::Baseline,
439 StageState::Failed,
440 None,
441 Some(began),
442 );
443 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
444 }
445 };
446 if !options.allow_failing_baseline
451 && let Some((c, why)) = baseline
452 .checks
453 .iter()
454 .find_map(|c| broken(c).map(|w| (c, w)))
455 {
456 stage(
457 &*live,
458 task,
459 Stage::Baseline,
460 StageState::Failed,
461 Some(format!("`{}` cannot run", c.name)),
462 Some(began),
463 );
464 return fail(
465 CommandStatus::ConfigError,
466 format!(
467 "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",
468 c.name
469 ),
470 )
471 .with_code("CHECK-BROKEN");
472 }
473 let failing = baseline
475 .checks
476 .iter()
477 .filter(|c| c.status.is_failure())
478 .count();
479 stage(
480 &*live,
481 task,
482 Stage::Baseline,
483 StageState::Passed,
484 (failing > 0).then(|| format!("{failing} check(s) already failing")),
485 Some(began),
486 );
487
488 let rt = match tokio_runtime() {
489 Ok(rt) => rt,
490 Err(e) => return fail(CommandStatus::RunFailed, e),
491 };
492 let bundle_value = match serde_json::to_value(&bundle) {
493 Ok(v) => v,
494 Err(e) => return fail(CommandStatus::Error, e.to_string()),
495 };
496 let instructions = crate::instructions::collect(
500 &wt_root,
501 &project,
502 &speccoder_core::scope::named_paths(&bundle.task.title),
503 &run_config.instructions,
504 );
505 let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
506 &instructions
507 } else {
508 &[]
509 };
510
511 let servers = match mcp::parse(&config_text) {
513 Ok(s) => s,
514 Err(e) => {
515 return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
516 .with_code("CONFIG-INVALID");
517 }
518 };
519 let mut connected = if servers.is_empty() {
520 mcp::Connected::default()
521 } else {
522 live.event(&progress::Event::Notice {
523 text: format!("starting {} MCP server(s)", servers.len()),
524 });
525 rt.block_on(mcp::connect(&servers, &project, &runner))
526 };
527 let approved_tools: Vec<String> = decisions
529 .approved
530 .iter()
531 .filter_map(|g| match g {
532 tools::policy::Grant::Tool { name } => Some(name.clone()),
533 _ => None,
534 })
535 .collect();
536 let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
537 connected.notices.extend(pinned);
538
539 let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
542 Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
543 Err(e) => return fail(CommandStatus::Error, e.to_string()),
544 };
545 let _ = crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc());
547 let present = crate::approvals::PresentContext {
549 root: None,
550 sandbox: runner.is_sandboxed().then(|| {
551 if cfg!(target_os = "macos") {
552 "Seatbelt".to_owned()
553 } else {
554 "Landlock+seccomp".to_owned()
555 }
556 }),
557 network: match sandbox_config.network {
558 sandbox::Network::Off => "none",
559 sandbox::Network::Loopback => "loopback",
560 sandbox::Network::On => "unrestricted",
561 }
562 .to_owned(),
563 file_text: None,
564 };
565 let plane = match rt.block_on(async {
566 substrate::Plane::open_with(
567 &plane_dir,
568 &s.model,
569 provider,
570 Some(tools_server),
571 substrate::Hooks {
572 progress: Some(Arc::clone(&live)),
573 mcp: connected.clients.clone(),
574 mcp_tools: connected.tools.clone(),
575 format: Some(format),
576 present: present.clone(),
577 },
578 )
579 }) {
580 Ok(p) => p,
581 Err(e) => return fail(CommandStatus::RunFailed, e.0),
582 };
583 let mut notices: Vec<String> = plane
584 .replaced_journal
585 .iter()
586 .map(|p| {
587 format!(
588 "the journal could not be read by this build and was moved to {}; past runs stay in git",
589 p.display()
590 )
591 })
592 .chain(connected.notices.iter().cloned())
593 .chain(sandbox_notice.clone())
594 .chain(resumed_from.as_ref().map(|(stage, last_call)| {
595 format!(
596 "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
597 stage.map_or("a run", speccoder_core::stage::Stage::name),
598 last_call
599 .as_ref()
600 .map(|c| format!(" ({c})"))
601 .unwrap_or_default()
602 )
603 }))
604 .collect();
605
606 let demand_red = run_config.acceptance && format.is_convergence(&bundle.task.phase);
609 let start = match &interrupted {
615 Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
616 Ok(tree) => worktree::Checkpoint(tree),
617 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
618 },
619 None => match worktree::checkpoint(&wt_root, &tmp) {
620 Ok(c) => c,
621 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
622 },
623 };
624
625 let began = std::time::Instant::now();
626 let scenarios = format.scenarios(&snap, task);
629 let mut acceptance = if let Some(prepared) = interrupted
630 .as_ref()
631 .and_then(|m| m.acceptance.clone())
632 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
633 {
634 Some(prepared)
636 } else if run_config.acceptance && format.acceptance_testable(&bundle) {
637 stage(&*live, task, Stage::Red, StageState::Started, None, None);
638 Some(prepare_acceptance(&AcceptanceSetup {
639 rt: &rt,
640 plane: &plane,
641 project: &project,
642 worktree: &wt_root,
643 tmp: &tmp,
644 policy: &policy,
645 feature: &feature_name,
646 task,
647 bundle: bundle_value.clone(),
648 scenarios: &scenarios,
649 instructions: &instructions,
650 runner: &runner,
651 }))
652 } else {
653 None
654 };
655 let held_out_store = crate::held_out::dir(&s.plane_dir, &feature_name, task);
658 if let Some(a) = acceptance
659 .as_mut()
660 .filter(|a| a.report.get("held_out").is_none())
661 {
662 if !run_config.held_out {
663 a.report["held_out"] = serde_json::json!({ "state": "off" });
664 } else if a.argv.is_none() {
665 a.report["held_out"] =
666 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
667 } else if !a.red {
668 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
669 } else if scenarios.is_empty() {
670 a.report["held_out"] =
671 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
672 } else {
673 let (held, sha) = prepare_held_out(
674 &AcceptanceSetup {
675 rt: &rt,
676 plane: &plane,
677 project: &project,
678 worktree: &wt_root,
679 tmp: &tmp,
680 policy: &policy,
681 feature: &feature_name,
682 task,
683 bundle: bundle_value.clone(),
684 scenarios: &scenarios,
685 instructions: &instructions,
686 runner: &runner,
687 },
688 a,
689 &scenarios,
690 &held_out_store,
691 story_checkpoint(
692 snap.feature.as_ref().and_then(|f| f.tasks.as_ref()),
693 task,
694 &scenarios,
695 ),
696 );
697 a.report["held_out"] = held;
698 a.held_out = sha;
699 }
700 }
701 if run_config.held_out && !run_config.acceptance {
702 notices.push(
703 "[acceptance] held_out has no effect while generate = false: no held-out tests"
704 .to_owned(),
705 );
706 }
707 match &acceptance {
708 None => stage(
709 &*live,
710 task,
711 Stage::Red,
712 StageState::Skipped,
713 Some("no acceptance examples".to_owned()),
714 None,
715 ),
716 Some(a) if a.red => stage(
717 &*live,
718 task,
719 Stage::Red,
720 StageState::Passed,
721 a.red_class.map(class_word),
722 Some(began),
723 ),
724 Some(a) => stage(
725 &*live,
726 task,
727 Stage::Red,
728 StageState::Failed,
729 Some(a.red_class.map_or_else(
730 || "not demonstrated".to_owned(),
731 |c| format!("not demonstrated ({})", class_word(c)),
732 )),
733 Some(began),
734 ),
735 }
736
737 if let Some(why) = acceptance
741 .as_ref()
742 .and_then(|a| a.generation_failed.clone())
743 {
744 let reason = format!(
745 "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"
746 );
747 let _ = worktree::restore(&wt_root, &tmp, &start);
748 let report = RunReport {
749 reason: Some(reason.clone()),
750 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
751 set_aside,
752 notices: notices.clone(),
753 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
754 };
755 live.event(&finished_event(
756 task,
757 &report,
758 "acceptance_failed",
759 false,
760 redact(&reason, secret),
761 ));
762 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
763 .with_code("ACCEPTANCE-UNWRITTEN")
764 .with_data(CommandData::Run(Box::new(report)));
765 }
766
767 if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
768 let why = undemonstrated_why(acceptance.as_ref());
769 let reason = format!(
770 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
771 );
772 let saved = attempts::save(
774 &wt_root,
775 &tmp,
776 &feature_name,
777 task,
778 &attempts::Record {
779 run: None,
780 outcome: "not_demonstrated",
781 reason: &reason,
782 pending: None,
783 usage: None,
784 },
785 )
786 .ok()
787 .map(|s| s.reference);
788 let _ = worktree::restore(&wt_root, &tmp, &start);
789 let report = RunReport {
790 reason: Some(reason.clone()),
791 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
792 saved_attempt: saved,
793 set_aside,
794 notices: notices.clone(),
795 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
796 };
797 live.event(&finished_event(
798 task,
799 &report,
800 "not_demonstrated",
801 false,
802 redact(&reason, secret),
803 ));
804 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
805 .with_data(CommandData::Run(Box::new(report)));
806 }
807
808 let before = match &interrupted {
811 Some(m) => worktree::Checkpoint(m.before.clone()),
812 None => match worktree::checkpoint(&wt_root, &tmp) {
813 Ok(c) => c,
814 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
815 },
816 };
817 let acceptance_saved = acceptance
818 .as_ref()
819 .and_then(|a| serde_json::to_value(a).ok());
820 let frozen: Vec<String> = worktree::files(&wt_root, &before)
822 .unwrap_or_default()
823 .into_iter()
824 .filter(|p| tools::paths::is_test_path(p))
825 .collect();
826 let mut usage: Option<serde_json::Value> = None;
827 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
830 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
832 let (mut report, attempt_status) = loop {
833 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
834 let mut digest: Option<serde_json::Value> = None;
835 let mut attempt_no = interrupted
838 .as_ref()
839 .filter(|m| m.sample == samples.len())
840 .map_or(1, |m| m.attempt.max(1));
841 let (mut report, attempt_status) = loop {
842 let hidden: Vec<String> = options
844 .session
845 .as_ref()
846 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
847 .unwrap_or_default();
848 let run_approved: Vec<crate::tools::policy::Grant> = options
850 .run_grants
851 .lock()
852 .map(|g| g.clone())
853 .unwrap_or_default();
854 let input = substrate::attempt::input(
856 bundle_value.clone(),
857 &substrate::attempt::Settings {
858 checks: &check_argvs,
859 allow: &run_config.allow,
860 max_turns: run_config.max_turns,
861 max_tool_calls: run_config.max_tool_calls,
862 frozen: &frozen,
863 approved: &decisions.approved,
864 rejected: &decisions.rejected,
865 run_approved: &run_approved,
866 nudges: &decisions.nudges,
867 mcp: &connected.tools,
868 root: &wt_root_canonical,
869 frozen_additions: run_config.frozen_additions,
870 asks: if options.lane.is_some() {
873 "stop"
874 } else {
875 "tasks"
876 },
877 wait_secs: run_config.approval_wait.as_secs(),
878 network_allow: &sandbox_config.network_allow,
879 in_place: options.session.is_some(),
880 hidden: &hidden,
881 },
882 digest.as_ref(),
883 attempt_instructions,
884 );
885 let attempt_began = std::time::Instant::now();
886 let attempt_detail = if run_config.best_of > 1 {
887 format!(
888 "{attempt_no}/{} · sample {}/{}",
889 run_config.max_attempts,
890 samples.len() + 1,
891 run_config.best_of
892 )
893 } else {
894 format!("{attempt_no}/{}", run_config.max_attempts)
895 };
896 stage(
897 &*live,
898 task,
899 Stage::Attempt,
900 StageState::Started,
901 Some(attempt_detail.clone()),
902 None,
903 );
904 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
907 let (run_id, succeeded, mut output) = if let Some(exec) = &executor {
910 let allow: Vec<Vec<String>> = check_argvs
911 .iter()
912 .chain(&run_config.allow)
913 .cloned()
914 .collect();
915 let prompt = external::prompt_in(format.wording(), &bundle_value, digest.as_ref());
916 let withheld = acceptance
917 .as_ref()
918 .and_then(|a| a.held_out.as_ref())
919 .map(|_| held_out_store.as_path());
920 if withheld.is_some()
921 && !notices
922 .iter()
923 .any(|n| n.starts_with("held-out set withheld"))
924 {
925 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());
926 }
927 let o = external::run(exec, &wt_root, &prompt, &allow, &tmp, withheld);
928 (o.run_id, true, o.output)
929 } else {
930 let key = match &interrupted {
932 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
933 m.key.clone()
934 }
935 _ => resume::new_key(&feature_name, task),
936 };
937 let _ = resume::save(
938 &marker_path,
939 &resume::Marker {
940 key: key.clone(),
941 before: before.0.clone(),
942 head: head_now.clone(),
943 attempt: attempt_no,
944 sample: samples.len(),
945 acceptance: acceptance_saved.clone(),
946 },
947 );
948 marker_guard.arm();
949 let outcome = match rt.block_on(plane.attempt_keyed(
950 &feature_name,
951 task,
952 input,
953 &key,
954 )) {
955 Ok((o, resumed)) => {
956 if resumed {
957 notices.push(format!(
958 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
959 o.run_id
960 ));
961 }
962 o
963 }
964 Err(e) => return fail(CommandStatus::RunFailed, e.0),
965 };
966 let context = crate::approvals::PresentContext {
970 root: Some(wt_root.clone()),
971 ..present.clone()
972 };
973 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
974 outcome,
975 approver.as_deref(),
976 &context,
977 )) {
978 Ok(x) => x,
979 Err(e) => return fail(CommandStatus::RunFailed, e.0),
980 };
981 if let Some(task_row) = waiting {
982 marker_guard.keep();
985 let action = &task_row.justification.proposed_action;
986 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
987 report.run_id = outcome.run_id.to_string();
988 report.notices = notices;
989 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
990 report.tool_calls.push(serde_json::json!({
992 "tool": action["tool"],
993 "arguments": action["arguments"],
994 "decision": "approval",
995 "reason": action["request"]["reason"],
996 "waiting": true,
997 "call": task_row.id.to_hex(),
998 }));
999 report.pending = Some(serde_json::json!({
1000 "tool": action["tool"],
1001 "arguments": action["arguments"],
1002 "reason": action["request"]["reason"],
1003 "call": task_row.id.to_hex(),
1004 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
1005 "waiting": true,
1006 }));
1007 report.reason = Some(format!(
1008 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
1009 task_row.justification.summary.peek()
1010 ));
1011 let (status, message) = status_and_message(&report, "needs_approval", false);
1012 live.event(&finished_event(
1013 task,
1014 &report,
1015 "needs_approval",
1016 false,
1017 redact(&message, secret),
1018 ));
1019 return CommandResult::new("run", status, &redact(&message, secret))
1020 .with_data(CommandData::Run(Box::new(report)));
1021 }
1022 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
1023 let mut output = outcome
1024 .output
1025 .as_ref()
1026 .map(|o| o.peek().clone())
1027 .unwrap_or(serde_json::Value::Null);
1028 if !succeeded && output["reason"].as_str().is_none() {
1029 if !output.is_object() {
1030 output = serde_json::json!({});
1031 }
1032 output["reason"] =
1033 serde_json::Value::String(redact(&format!("{:?}", outcome.status), secret));
1034 }
1035 (outcome.run_id.to_string(), succeeded, output)
1036 };
1037 if let Some(argv) = run_config.format.as_ref().filter(|_| {
1040 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
1041 }) {
1042 use verify::process::CommandRunner as _;
1043 let ran = runner.run(&verify::process::Invocation {
1044 argv: argv.clone(),
1045 cwd: project.clone(),
1046 env: verify::process::scrubbed_env(std::env::vars()),
1047 timeout: std::time::Duration::from_secs(300),
1048 output_cap: verify::process::OUTPUT_CAP,
1049 });
1050 if ran.exit_code != Some(0) {
1051 notices.push(format!(
1052 "the formatter (`{}`) did not succeed",
1053 argv.join(" ")
1054 ));
1055 }
1056 }
1057 let after = match worktree::checkpoint(&wt_root, &tmp) {
1058 Ok(c) => c,
1059 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1060 };
1061 let raw_changes = worktree::diff(&wt_root, &before, &after).unwrap_or_default();
1062 let approved_writes = approved_writes(&decisions.approved, &output["tool_calls"]);
1066 let findings = attempt_findings(&Audited {
1067 wt_root: &wt_root,
1068 policy: &policy,
1069 judged_before: &judged_before,
1070 raw_changes: &raw_changes,
1071 patch: &worktree::patch(&wt_root, &before, &after).unwrap_or_default(),
1072 frozen: &frozen,
1073 frozen_additions: run_config.frozen_additions,
1074 approved_writes: &approved_writes,
1075 command_drift: &output["integrity"],
1076 });
1077 let changes = changes_of(raw_changes);
1078
1079 let quarantined = !succeeded
1084 && output["reason"]
1085 .as_str()
1086 .is_some_and(|r| r.starts_with("Quarantined("));
1087 if quarantined {
1088 output["reason"] = serde_json::Value::String(
1089 "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(),
1090 );
1091 }
1092 let mut attempt_status = if succeeded {
1093 output["status"].as_str().unwrap_or("failed").to_owned()
1094 } else if quarantined {
1095 "interrupted".to_owned()
1096 } else {
1097 "failed".to_owned()
1098 };
1099 let mut report = RunReport {
1100 run_id: run_id.clone(),
1101 model_report: output.get("report").cloned(),
1102 reason: output["reason"].as_str().map(str::to_owned),
1103 pending: output.get("pending").cloned(),
1104 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
1105 changes,
1106 usage: output.get("usage").cloned(),
1107 integrity: findings.clone(),
1108 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1109 set_aside: set_aside.clone(),
1110 notices: notices.clone(),
1111 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
1112 };
1113 record_inline_approvals(
1114 &s.plane_dir,
1115 &feature_name,
1116 task,
1117 &report.tool_calls,
1118 &options.run_grants,
1119 );
1120 for c in report
1122 .tool_calls
1123 .iter()
1124 .filter(|c| c["approved_by"] == "person")
1125 {
1126 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
1127 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
1128 }
1129 }
1130
1131 stage(
1132 &*live,
1133 task,
1134 Stage::Attempt,
1135 if attempt_status == "completed" {
1136 StageState::Passed
1137 } else {
1138 StageState::Failed
1139 },
1140 Some(if attempt_status == "completed" {
1141 attempt_detail
1142 } else {
1143 format!("{attempt_detail} {attempt_status}")
1144 }),
1145 Some(attempt_began),
1146 );
1147 if attempt_status == "integrity" {
1150 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1151 reasons: findings.clone(),
1152 });
1153 }
1154 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
1156 if judged {
1157 let began = std::time::Instant::now();
1158 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
1159 if let Err(e) = judge_checks(
1160 &project,
1161 &runner,
1162 &baseline,
1163 acceptance.as_ref(),
1164 &findings,
1165 &mut report,
1166 ) {
1167 stage(
1168 &*live,
1169 task,
1170 Stage::Verify,
1171 StageState::Failed,
1172 None,
1173 Some(began),
1174 );
1175 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
1176 }
1177 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
1178 stage_with_tail(
1179 &*live,
1180 task,
1181 Stage::Verify,
1182 if checks_held {
1183 StageState::Passed
1184 } else {
1185 StageState::Failed
1186 },
1187 None,
1188 Some(began),
1189 report
1190 .verification
1191 .as_ref()
1192 .map(failing_tail)
1193 .unwrap_or_default(),
1194 );
1195 }
1196 if judged || attempt_status == "integrity" {
1197 stage(
1198 &*live,
1199 task,
1200 Stage::Audit,
1201 if findings.is_empty() {
1202 StageState::Passed
1203 } else {
1204 StageState::Failed
1205 },
1206 findings
1207 .first()
1208 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
1209 None,
1210 );
1211 }
1212
1213 let visibly_passed = judged
1217 && findings.is_empty()
1218 && attempt_status == "completed"
1219 && matches!(
1220 report.verdict,
1221 Some(
1222 speccoder_core::verify::Verdict::Verified
1223 | speccoder_core::verify::Verdict::RegressionOnly
1224 )
1225 );
1226 if let Some(sha) = acceptance
1227 .as_ref()
1228 .and_then(|a| a.held_out.clone())
1229 .filter(|_| visibly_passed)
1230 {
1231 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1232 let mut known = format!(
1233 "{}\n{}\n{}",
1234 bundle_value["story"].as_str().unwrap_or_default(),
1235 bundle_value["plan"].as_str().unwrap_or_default(),
1236 bundle_value["constitution"].as_str().unwrap_or_default()
1237 );
1238 for p in report
1239 .acceptance
1240 .as_ref()
1241 .and_then(|a| a["files"].as_array().cloned())
1242 .unwrap_or_default()
1243 {
1244 if let Some(p) = p.as_str() {
1245 known.push_str(
1246 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
1247 );
1248 }
1249 }
1250 match judge_held_out(
1251 &held_out_store,
1252 &sha,
1253 &scenarios,
1254 &known,
1255 &wt_root,
1256 &project,
1257 &tmp,
1258 &before,
1259 &patch,
1260 &runner,
1261 &policy,
1262 ) {
1263 Ok(j) => {
1264 let failed_ids: Vec<String> =
1265 j.failed.iter().map(|s| s.id.clone()).collect();
1266 held_out_log.push(serde_json::json!({
1267 "attempt": held_out_log.len() + 1,
1268 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
1269 "scenarios": failed_ids,
1270 }));
1271 if let Some(v) = report.verdict.take() {
1272 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
1273 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
1274 && !j.failed.is_empty()
1275 && let Some(last) = reasons.last_mut()
1276 {
1277 *last = speccoder_core::retry::held_out_reason(&j.failed);
1278 }
1279 report.verdict = Some(v);
1280 }
1281 }
1282 Err((outcome, why)) => {
1283 held_out_log.push(serde_json::json!({
1284 "attempt": held_out_log.len() + 1,
1285 "outcome": outcome,
1286 }));
1287 report.verdict = None;
1288 report.reason = Some(why);
1289 attempt_status = "held_out".to_owned();
1290 }
1291 }
1292 }
1293 if let Some(h) = report
1294 .acceptance
1295 .as_mut()
1296 .and_then(|a| a.get_mut("held_out"))
1297 .filter(|h| h["state"] == "on")
1298 {
1299 let failed = held_out_log
1300 .iter()
1301 .filter(|o| o["outcome"] == "failed")
1302 .count();
1303 h["attempts"] = serde_json::json!(held_out_log);
1304 h["gap"] = serde_json::json!(failed > 0);
1305 }
1306
1307 let passing = attempt_status == "completed"
1309 && matches!(
1310 report.verdict,
1311 Some(
1312 speccoder_core::verify::Verdict::Verified
1313 | speccoder_core::verify::Verdict::RegressionOnly
1314 )
1315 );
1316 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
1317 stage(
1318 &*live,
1319 task,
1320 Stage::Mutation,
1321 StageState::Skipped,
1322 Some(if mutation_config.is_none() {
1323 "not configured".to_owned()
1324 } else {
1325 "suite = false: for acceptance strength only".to_owned()
1326 }),
1327 None,
1328 );
1329 }
1330 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
1331 let began = std::time::Instant::now();
1332 stage(
1333 &*live,
1334 task,
1335 Stage::Mutation,
1336 StageState::Started,
1337 None,
1338 None,
1339 );
1340 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1341 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
1342 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
1343 if m.mode == mutation::Mode::Require
1344 && let Some(reason) = mutation::failure(&result)
1345 {
1346 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1347 reasons: vec![reason],
1348 });
1349 }
1350 report.mutation = Some(result);
1351 }
1352 let held = !matches!(
1353 report.verdict,
1354 Some(speccoder_core::verify::Verdict::Failed { .. })
1355 );
1356 stage(
1357 &*live,
1358 task,
1359 Stage::Mutation,
1360 if held {
1361 StageState::Passed
1362 } else {
1363 StageState::Failed
1364 },
1365 report
1366 .mutation
1367 .as_ref()
1368 .and_then(crate::mutation::trailer)
1369 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
1370 Some(began),
1371 );
1372 }
1373
1374 usage = add_usage(usage.as_ref(), report.usage.as_ref());
1375 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
1377 (report.verdict.clone(), report.verification.as_ref())
1378 else {
1379 break (report, attempt_status);
1380 };
1381 if attempt_status != "completed" {
1382 break (report, attempt_status);
1383 }
1384 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
1385 let d = speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
1386 let fingerprint = speccoder_core::retry::fingerprint(&d);
1387 let stop = retry_stop(
1388 &findings,
1389 verification,
1390 earlier.last().map(|e| e.fingerprint.as_str()),
1391 &fingerprint,
1392 attempt_no,
1393 run_config.max_attempts,
1394 );
1395 if let Some(stop) = stop {
1396 report.retry_stop = Some(stop);
1397 break (report, attempt_status);
1398 }
1399 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
1400 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
1401 break (report, attempt_status);
1402 }
1403 earlier.push(speccoder_core::EarlierAttempt {
1404 run_id: report.run_id.clone(),
1405 attempt_status: attempt_status.clone(),
1406 verdict: report.verdict.clone(),
1407 fingerprint,
1408 digest: d.clone(),
1409 usage: report.usage.clone(),
1410 });
1411 digest = serde_json::to_value(&d).ok();
1412 attempt_no += 1;
1413 };
1414 report.earlier = earlier;
1415 if run_config.best_of <= 1 {
1416 break (report, attempt_status);
1417 }
1418 let end = match worktree::checkpoint(&wt_root, &tmp) {
1419 Ok(c) => c,
1420 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1421 };
1422 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
1423 let summary = sample_summary(&report, &attempt_status, &patch);
1424 samples.push((report, attempt_status, end, summary));
1425 if samples.len() >= run_config.best_of as usize {
1426 let best = best_sample(&samples);
1427 let summaries: Vec<serde_json::Value> = samples
1428 .iter()
1429 .enumerate()
1430 .map(|(i, s)| {
1431 let mut v = s.3.clone();
1432 v["kept"] = serde_json::json!(i == best);
1433 v
1434 })
1435 .collect();
1436 let (mut chosen, status, end, _) = samples.swap_remove(best);
1437 if best != samples.len() {
1438 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
1440 return fail(CommandStatus::GitPrecondition, e.0);
1441 }
1442 }
1443 chosen.samples = summaries;
1444 break (chosen, status);
1445 }
1446 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
1447 return fail(CommandStatus::GitPrecondition, e.0);
1448 }
1449 };
1450 report.usage = usage;
1451 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
1453
1454 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
1458 let mut attempt_status = attempt_status;
1459 let verified = attempt_status == "completed"
1460 && matches!(
1461 report.verdict,
1462 Some(speccoder_core::verify::Verdict::Verified)
1463 );
1464 let visible_argv = acceptance
1465 .as_ref()
1466 .filter(|a| a.red)
1467 .and_then(|a| a.argv.clone());
1468 if verified && !run_config.strength.enabled {
1469 stage(
1470 &*live,
1471 task,
1472 Stage::Strength,
1473 StageState::Skipped,
1474 Some("strength = false".to_owned()),
1475 None,
1476 );
1477 }
1478 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
1479 use speccoder_core::strength::{Gate, gate, over_strict};
1480 let began = std::time::Instant::now();
1481 stage(
1482 &*live,
1483 task,
1484 Stage::Strength,
1485 StageState::Started,
1486 None,
1487 None,
1488 );
1489 let fin = worktree::checkpoint(&wt_root, &tmp);
1490 let patch = fin
1491 .as_ref()
1492 .ok()
1493 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
1494 .unwrap_or_default();
1495 let mut notes = Vec::new();
1496 let held: Vec<(crate::held_out::HeldOutFile, String)> =
1497 match acceptance.as_ref().and_then(|a| a.held_out.clone()) {
1498 None => Vec::new(),
1499 Some(sha) => match crate::held_out::load_verified(&held_out_store, &sha) {
1500 Ok((manifest, contents)) => manifest
1501 .files
1502 .into_iter()
1503 .zip(contents.into_iter().map(|(_, c)| c))
1504 .collect(),
1505 Err(t) => {
1506 notes.push(format!("held-out set not overlaid: {}", t.0));
1507 Vec::new()
1508 }
1509 },
1510 };
1511 let rulings = crate::strength::rulings(&strength_dir);
1512 let mut commands = vec![argv];
1513 let measure = |commands: &[Vec<String>]| {
1514 crate::strength::measure(
1515 &crate::strength::Setup {
1516 wt_root: &wt_root,
1517 project: &project,
1518 tmp: &tmp,
1519 runner: &runner,
1520 policy: &policy,
1521 acceptance: commands,
1522 held_out: &held,
1523 mutation: mutation_config.as_ref(),
1524 patch: &patch,
1525 config: &run_config.strength,
1526 keep: &strength_dir,
1527 rulings: &rulings,
1528 },
1529 |changed| {
1530 let input = substrate::near_miss::input(
1532 &bundle_value,
1533 changed,
1534 &crate::mutation::source_patch(&patch),
1535 run_config.strength.near_miss,
1536 run_config.strength.permitted,
1537 );
1538 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
1539 Ok(o) => {
1540 let out = o
1541 .output
1542 .as_ref()
1543 .map(|v| v.peek().clone())
1544 .unwrap_or_default();
1545 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
1546 && !out["answer"].is_null();
1547 crate::strength::Authored {
1548 answer: if ok {
1549 Ok(out["answer"].clone())
1550 } else {
1551 Err(format!(
1552 "the variant author did not answer ({})",
1553 o.status.as_str()
1554 ))
1555 },
1556 run_id: Some(o.run_id.to_string()),
1557 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
1558 }
1559 }
1560 Err(e) => crate::strength::Authored {
1561 answer: Err(redact(&e.0, secret)),
1562 run_id: None,
1563 usage: None,
1564 },
1565 }
1566 },
1567 )
1568 };
1569 let mut measured = measure(&commands);
1570 let min = run_config.strength.min;
1573 if let (Ok(a), Ok(fin)) = (&measured, &fin)
1574 && gate(min, &a.combined, false) == Gate::Failed
1575 && over_strict(&a.permitted).is_empty()
1576 {
1577 let visible_tests: Vec<(String, String)> = report
1578 .acceptance
1579 .as_ref()
1580 .and_then(|a| a["files"].as_array().cloned())
1581 .unwrap_or_default()
1582 .iter()
1583 .filter_map(|p| p.as_str())
1584 .map(|p| {
1585 (
1586 p.to_owned(),
1587 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
1588 )
1589 })
1590 .collect();
1591 let setup = AcceptanceSetup {
1592 rt: &rt,
1593 plane: &plane,
1594 project: &project,
1595 worktree: &wt_root,
1596 tmp: &tmp,
1597 policy: &policy,
1598 feature: &feature_name,
1599 task,
1600 bundle: bundle_value.clone(),
1601 scenarios: &scenarios,
1602 instructions: &instructions,
1603 runner: &runner,
1604 };
1605 let weak = crate::strength::weak(a);
1606 match strengthen(&setup, &visible_tests, weak, &before, fin) {
1607 Ok(added) => {
1608 if let Some(acc) = report.acceptance.as_mut() {
1609 if let Some(files) = acc["files"].as_array_mut() {
1610 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
1611 }
1612 acc["strengthened"] = serde_json::json!({
1613 "files": added.files,
1614 "argv": added.argv,
1615 "run_id": added.report["run_id"],
1616 "usage": added.report["usage"],
1617 });
1618 }
1619 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
1620 commands.push(added.argv);
1621 let mut again = measure(&commands);
1622 if let Ok(b) = again.as_mut() {
1623 b.gate = gate(min, &b.combined, true);
1624 b.notes.insert(
1625 0,
1626 "strengthened once with added acceptance tests".to_owned(),
1627 );
1628 }
1629 measured = again;
1630 }
1631 Err(why) => {
1632 if let Ok(a) = measured.as_mut() {
1633 a.notes
1634 .insert(0, format!("could not strengthen the set: {why}"));
1635 }
1636 }
1637 }
1638 }
1639 match measured {
1640 Ok(mut a) => {
1641 if a.gate == Gate::Off {
1642 a.gate = gate(min, &a.combined, false);
1643 }
1644 a.notes.extend(notes);
1645 let findings = over_strict(&a.permitted);
1646 if !findings.is_empty() {
1647 attempt_status = "over_strict".to_owned();
1648 report.reason = Some(format!(
1649 "an acceptance test rejects an implementation the spec appears to allow: {}",
1650 findings
1651 .iter()
1652 .map(|p| format!("{} ({})", p.scenario, p.rationale))
1653 .collect::<Vec<_>>()
1654 .join("; ")
1655 ));
1656 } else if a.gate == Gate::Failed {
1657 attempt_status = "acceptance_weak".to_owned();
1658 report.reason = Some(format!(
1659 "acceptance set too weak: strength {} is below min_strength {}{}",
1660 speccoder_core::strength::summary(&a.combined, None),
1661 min.unwrap_or_default(),
1662 a.notes
1663 .first()
1664 .map(|n| format!(" ({n})"))
1665 .unwrap_or_default()
1666 ));
1667 } else {
1668 crate::strength::release(&strength_dir);
1669 }
1670 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
1672 report.strength = Some(a);
1673 }
1674 Err(e) => {
1675 report.integrity.push(e.clone());
1676 report.reason = Some(e);
1677 report.verdict = None;
1678 attempt_status = "integrity".to_owned();
1679 }
1680 }
1681 report.attempt_status.clone_from(&attempt_status);
1682 let held_up = attempt_status == "completed";
1683 stage(
1684 &*live,
1685 task,
1686 Stage::Strength,
1687 if held_up {
1688 StageState::Passed
1689 } else {
1690 StageState::Failed
1691 },
1692 report.strength.as_ref().map(|a| {
1693 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
1694 }),
1695 Some(began),
1696 );
1697 }
1698
1699 let verdict_passed = attempt_status == "completed"
1703 && matches!(
1704 report.verdict,
1705 Some(
1706 speccoder_core::verify::Verdict::Verified
1707 | speccoder_core::verify::Verdict::RegressionOnly
1708 )
1709 );
1710 let mut suspected = false;
1711 if !run_config.monitor && verdict_passed {
1712 stage(
1713 &*live,
1714 task,
1715 Stage::Monitor,
1716 StageState::Skipped,
1717 Some("not enabled".to_owned()),
1718 None,
1719 );
1720 }
1721 if run_config.monitor && verdict_passed {
1722 let began = std::time::Instant::now();
1723 stage(
1724 &*live,
1725 task,
1726 Stage::Monitor,
1727 StageState::Started,
1728 None,
1729 None,
1730 );
1731 let patch = worktree::checkpoint(&wt_root, &tmp)
1732 .and_then(|now| worktree::patch(&wt_root, &start, &now))
1733 .unwrap_or_default();
1734 let calls: Vec<serde_json::Value> = report
1735 .tool_calls
1736 .iter()
1737 .map(|c| {
1738 let tool = c["tool"].as_str().unwrap_or_default();
1739 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
1740 })
1741 .collect();
1742 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
1743 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
1744 report.monitor = Some(monitor);
1745 suspected = suspicion;
1746 stage(
1747 &*live,
1748 task,
1749 Stage::Monitor,
1750 if suspected {
1751 StageState::Failed
1752 } else {
1753 StageState::Passed
1754 },
1755 suspected.then(|| "suspects gaming".to_owned()),
1756 Some(began),
1757 );
1758 }
1759
1760 let in_place_stop = options
1763 .session
1764 .as_ref()
1765 .filter(|_| verdict_passed && !suspected)
1766 .and_then(|s| {
1767 s.lock()
1768 .ok()
1769 .and_then(|s| s.check_before_tick(&policy).err())
1770 });
1771 if let Some(e) = &in_place_stop {
1772 report.reason = Some(e.line());
1773 report.verdict = None;
1774 attempt_status = e.code.to_lowercase().replace('-', "_");
1776 report.attempt_status.clone_from(&attempt_status);
1777 }
1778 let passed = verdict_passed && !suspected && in_place_stop.is_none();
1779 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
1780 let began = std::time::Instant::now();
1781 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
1782 let message = tick_message(&TickFacts {
1783 feature: &feature_name,
1784 task,
1785 title: &t.title,
1786 report: &report,
1787 red: acceptance
1788 .as_ref()
1789 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
1790 checklists_overridden,
1791 });
1792 match &options.session {
1793 None => {
1795 match format.tick(file, task, &t.line_hash) {
1796 Ok(()) => report.ticked = true,
1797 Err(e) => report.integrity.push(format!("tick refused: {e}")),
1798 }
1799 if report.ticked && run_config.commit_per_task {
1800 match commit_attempt(&wt_root, &message) {
1801 Ok(id) => report.commit = Some(id),
1802 Err(e) => report.integrity.push(format!("commit failed: {e}")),
1803 }
1804 }
1805 }
1806 Some(session) => {
1809 let digest =
1810 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
1811 if let Ok(mut s) = session.lock() {
1812 let done = s.commit_and_tick(
1813 &plane_dir,
1814 file,
1815 &t.line_hash,
1816 &message,
1817 &digest,
1818 || format.tick(file, task, &t.line_hash),
1819 );
1820 report.ticked = done.ticked;
1821 report.commit = done.commit;
1822 report.integrity.extend(done.problems);
1823 }
1824 }
1825 }
1826 report.instructions = attempt_instructions
1827 .iter()
1828 .map(|i| speccoder_core::InstructionFile {
1829 path: i.path.clone(),
1830 sha256: i.sha256.clone(),
1831 bytes: i.bytes,
1832 cut: i.cut,
1833 })
1834 .collect();
1835 if let Some(commit) = report
1837 .commit
1838 .clone()
1839 .filter(|_| report.ticked && run_config.attest.enabled)
1840 {
1841 let verdict = report
1842 .verdict
1843 .as_ref()
1844 .map(super::outcome::verdict_word)
1845 .unwrap_or_default();
1846 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
1847 let feature_dir = snap
1848 .feature
1849 .as_ref()
1850 .map(|f| f.feature.dir.clone())
1851 .unwrap_or_default();
1852 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
1853 let tick = crate::attest::Tick {
1854 repo: &wt_root,
1855 config: &config_text,
1856 attest: &run_config.attest,
1857 format: &format!("{:?}", format.kind()).to_lowercase(),
1858 feature: &feature_name,
1859 feature_dir: &feature_dir,
1860 task,
1861 title: &t.title,
1862 line_hash: &t.line_hash,
1863 section: t.story.as_deref(),
1864 section_text: bundle_value["story"].as_str().unwrap_or_default(),
1865 requirements: &requirements,
1866 commit: &commit,
1867 verdict: &verdict,
1868 sandbox: serde_json::json!({
1869 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
1870 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
1871 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
1872 }),
1873 executor: serde_json::json!({
1874 "kind": executor.as_ref().map_or_else(|| "native".to_owned(), |e| match &e.kind {
1875 external::Kind::Claude => "claude".to_owned(),
1876 external::Kind::Codex => "codex".to_owned(),
1877 external::Kind::Command(_) => "command".to_owned(),
1878 }),
1879 }),
1880 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
1881 };
1882 report.attestation = Some(crate::attest::record_tick(&tick, &report));
1883 }
1884 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
1885 stage(
1886 &*live,
1887 task,
1888 Stage::Commit,
1889 if committed {
1890 StageState::Passed
1891 } else {
1892 StageState::Failed
1893 },
1894 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
1895 Some(began),
1896 );
1897 }
1898
1899 if attempt_status == "over_strict" {
1904 let findings = report
1905 .strength
1906 .as_ref()
1907 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
1908 .unwrap_or_default();
1909 if let Err(e) = crate::strength::hold(&strength_dir, &findings) {
1910 report
1911 .integrity
1912 .push(format!("holding the attempt failed: {e}"));
1913 }
1914 marker_guard.keep();
1915 }
1916 if !passed && attempt_status != "over_strict" {
1919 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
1920 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
1921 let reason = redact(&reason, secret);
1922 let record = attempts::Record {
1923 run,
1924 outcome: &outcome,
1925 reason: &reason,
1926 pending: report.pending.as_ref(),
1927 usage: report.usage.as_ref(),
1928 };
1929 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
1930 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
1931 {
1932 Ok(saved) => report.saved_attempt = Some(saved.reference),
1933 Err(e) => report
1934 .integrity
1935 .push(format!("saving the attempt failed: {}", e.0)),
1936 }
1937 }
1938
1939 let (status, message) = status_and_message(&report, &attempt_status, suspected);
1940 live.event(&finished_event(
1941 task,
1942 &report,
1943 &attempt_status,
1944 suspected,
1945 redact(&message, secret),
1946 ));
1947 CommandResult::new("run", status, &redact(&message, secret))
1948 .with_data(CommandData::Run(Box::new(report)))
1949}
1950
1951#[must_use]
1955pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
1956 use speccoder_core::verify::CheckStatus;
1957 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
1958 return None;
1959 }
1960 if let Some(note) = &c.note {
1961 return Some(note.clone());
1962 }
1963 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
1964 return None;
1965 }
1966 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
1967 let runner = c
1971 .argv
1972 .first()
1973 .map(|a| a.rsplit('/').next().unwrap_or(a))
1974 .is_some_and(|a| {
1975 matches!(
1976 a,
1977 "cargo"
1978 | "pytest"
1979 | "python"
1980 | "python3"
1981 | "uv"
1982 | "npm"
1983 | "npx"
1984 | "pnpm"
1985 | "yarn"
1986 | "bun"
1987 | "go"
1988 | "jest"
1989 | "vitest"
1990 | "mvn"
1991 | "gradle"
1992 | "dotnet"
1993 | "mix"
1994 | "swift"
1995 )
1996 });
1997 let cannot_start = matches!(c.exit_code, Some(126 | 127));
1998 let is_broken = match &summary {
1999 _ if cannot_start => true,
2000 Some(s) if s.build_failed => true,
2001 Some(s) => s.passed + s.failed + s.errors == 0,
2002 None => runner,
2003 };
2004 match is_broken {
2005 false => None,
2006 true => {
2007 let first = c
2008 .stderr_tail
2009 .lines()
2010 .chain(c.stdout_tail.lines())
2011 .map(str::trim)
2012 .find(|l| l.to_lowercase().contains("error"))
2013 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
2014 .unwrap_or("it failed without running a test")
2015 .chars()
2016 .take(160)
2017 .collect::<String>();
2018 Some(first)
2019 }
2020 }
2021}