1use std::{
2 collections::{HashMap, HashSet},
3 fs,
4 path::{Path, PathBuf},
5 thread,
6 time::Instant,
7};
8
9use serde::Deserialize;
10use serde_json::Value;
11
12use murmur_artifact::DEFAULT_EXPORT_MAX_BYTES;
13
14use crate::{
15 bindings::host::murmur::tool::run::{Status as ToolStatus, ToolInput, ToolResult},
16 delegation_plane::{DelegationOrigin, DelegationPlane, DelegationRequest, DelegationStatus},
17 errors::RuntimeError,
18 resource_plane::{self, SymlinkPolicy},
19 sandbox,
20 shell::{execute_shell, split_shell_words},
21 spawn_credential::SpawnCredential,
22 trace::{PlanEndRecord, PlanStepRecord, PlanStepShape, PlanTraceAppender},
23 types::CapabilityPolicy,
24};
25
26pub mod condition;
27
28#[derive(Debug, Deserialize)]
29struct PlanFile {
30 id: String,
31 steps: Vec<StepDef>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35struct StepDef {
36 id: String,
37 tool: Option<String>,
38 shell: Option<String>,
39 capsule: Option<String>,
40 #[serde(default)]
41 input: Value,
42 #[serde(default)]
43 depends_on: Vec<String>,
44 #[serde(rename = "if")]
45 condition: Option<String>,
46 #[serde(default = "default_on_error")]
47 on_error: String,
48 #[serde(default)]
49 retries: u32,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum StepKind {
54 Tool,
55 Shell,
56 Spawn,
57}
58
59impl StepKind {
60 fn as_str(self) -> &'static str {
63 match self {
64 StepKind::Tool => "tool",
65 StepKind::Shell => "shell",
66 StepKind::Spawn => "capsule",
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum StepStatus {
73 Success,
74 Failed,
75 Skipped,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct StepResult {
80 pub step_id: String,
81 pub status: StepStatus,
82 pub output: Option<String>,
83 pub error: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ExecutionReport {
88 pub results: Vec<StepResult>,
89 pub completed: bool,
90 pub failed_step: Option<String>,
91}
92
93pub struct SchedulerContext<'a> {
94 pub accessible_workdir: PathBuf,
97 pub session_workdir: PathBuf,
101 pub capability_policy: CapabilityPolicy,
102 pub installed_tools: HashSet<String>,
103 pub capsule_versions: HashMap<String, String>,
104 pub current_session_id: Option<String>,
105 pub current_context_id: Option<String>,
112 pub registry: std::sync::Arc<dyn murmur_artifact::Registry>,
116 pub spawn_credential: Option<SpawnCredential>,
123 pub trace: Option<&'a PlanTraceAppender>,
134 pub gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
146 pub invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
147}
148
149pub enum PlannedCall<'a> {
155 Tool {
156 name: &'a str,
157 input_json: &'a str,
159 },
160 Shell {
161 binary: &'a str,
163 command: &'a str,
165 argv: &'a [String],
168 },
169}
170
171pub fn execute(plan_path: &Path, ctx: &SchedulerContext<'_>) -> ExecutionReport {
172 let started = Instant::now();
173 match execute_inner(plan_path, ctx, started) {
174 Ok(report) => report,
175 Err((failed_step, error)) => {
178 if let Some(appender) = ctx.trace {
179 appender.write_plan_end(
180 None,
181 PlanEndRecord {
182 plan_id: String::new(),
183 outcome: "failed",
184 failed_step: Some(failed_step.clone()),
185 steps_total: 0,
186 steps_succeeded: 0,
187 steps_failed: 0,
188 steps_skipped: 0,
189 duration_ms: elapsed_ms(started),
190 reason: Some(error.clone()),
191 },
192 );
193 }
194 ExecutionReport {
195 results: vec![StepResult {
196 step_id: failed_step.clone(),
197 status: StepStatus::Failed,
198 output: None,
199 error: Some(error),
200 }],
201 completed: false,
202 failed_step: Some(failed_step),
203 }
204 }
205 }
206}
207
208fn execute_inner(
209 plan_path: &Path,
210 ctx: &SchedulerContext<'_>,
211 started: Instant,
212) -> Result<ExecutionReport, (String, String)> {
213 let raw = fs::read_to_string(plan_path).map_err(|error| {
214 (
215 "plan".to_string(),
216 format!("failed to read plan file {}: {error}", plan_path.display()),
217 )
218 })?;
219 let plan: PlanFile = serde_json::from_str(&raw).map_err(|error| {
220 (
221 "plan".to_string(),
222 format!("failed to parse plan JSON: {error}"),
223 )
224 })?;
225
226 let plan_node = ctx
230 .trace
231 .map(|appender| appender.write_plan_start(&plan.id, plan_shape(&plan)));
232
233 if let Err((step_id, error)) = validate_plan(&plan, ctx) {
234 trace_plan_end(
235 ctx,
236 plan_node.as_deref(),
237 PlanEndRecord {
238 plan_id: plan.id.clone(),
239 outcome: "failed",
240 failed_step: Some(step_id.clone()),
241 steps_total: plan.steps.len(),
242 steps_succeeded: 0,
243 steps_failed: 0,
244 steps_skipped: 0,
245 duration_ms: elapsed_ms(started),
246 reason: Some(error.clone()),
247 },
248 );
249 return Ok(ExecutionReport {
250 results: vec![StepResult {
251 step_id: step_id.clone(),
252 status: StepStatus::Failed,
253 output: None,
254 error: Some(error),
255 }],
256 completed: false,
257 failed_step: Some(step_id.clone()),
258 });
259 }
260
261 let cgroup_scope = match crate::cgroup::prepare_scope(
267 crate::cgroup::requires_process_bounding(&ctx.capability_policy, false),
268 &ctx.capability_policy.resources,
269 &plan.id,
270 &ctx.accessible_workdir,
271 ) {
272 Ok(prepared) => prepared.scope,
278 Err(reason) => {
279 let error =
280 crate::errors::RuntimeError::CgroupDelegationUnavailable { reason }.to_string();
281 return Ok(refused_before_any_step(
282 &plan,
283 ctx,
284 plan_node.as_deref(),
285 started,
286 error,
287 ));
288 }
289 };
290 let workdir_guard = Some(crate::resources::WorkdirGuard::spawn(
291 &ctx.accessible_workdir,
292 ctx.capability_policy.resources.workdir_max_bytes,
293 ));
294
295 let shell_enforcement = match sandbox::ShellEnforcement::resolve(
298 &ctx.capability_policy,
299 ctx.capability_policy.containment_floor,
300 sandbox::HostProbe::probe(),
301 ) {
302 Ok(enforcement) => enforcement.with_host_bounding(cgroup_scope, workdir_guard),
303 Err(error) => {
304 let error = format!("failed to resolve shell subprocess sandbox enforcement: {error}");
305 return Ok(refused_before_any_step(
306 &plan,
307 ctx,
308 plan_node.as_deref(),
309 started,
310 error,
311 ));
312 }
313 };
314
315 let mut results = HashMap::<String, StepResult>::new();
316 let mut result_order = Vec::<String>::new();
317 let mut failed_step = None;
318
319 loop {
320 if results.len() == plan.steps.len() || failed_step.is_some() {
321 break;
322 }
323
324 let mut ready = Vec::new();
325 let mut progressed = false;
326
327 for step in &plan.steps {
328 if results.contains_key(&step.id) {
329 continue;
330 }
331 if !step.depends_on.iter().all(|dep| results.contains_key(dep)) {
332 continue;
333 }
334
335 match condition::evaluate(step.condition.as_deref(), &results) {
336 Ok(true) => ready.push(step.clone()),
337 Ok(false) => {
338 trace_undispatched(ctx, plan_node.as_deref(), &plan.id, step, "skipped", None);
341 insert_result(
342 &mut results,
343 &mut result_order,
344 StepResult {
345 step_id: step.id.clone(),
346 status: StepStatus::Skipped,
347 output: None,
348 error: None,
349 },
350 );
351 progressed = true;
352 }
353 Err(error) => {
354 trace_undispatched(
355 ctx,
356 plan_node.as_deref(),
357 &plan.id,
358 step,
359 "failed",
360 Some(error.clone()),
361 );
362 insert_result(
363 &mut results,
364 &mut result_order,
365 StepResult {
366 step_id: step.id.clone(),
367 status: StepStatus::Failed,
368 output: None,
369 error: Some(error),
370 },
371 );
372 failed_step = Some(step.id.clone());
373 progressed = true;
374 break;
375 }
376 }
377 }
378
379 if failed_step.is_some() {
380 break;
381 }
382
383 if ready.is_empty() {
384 if progressed {
385 continue;
386 }
387 const BLOCKED: &str = "plan is blocked by a dependency cycle or missing dependency";
388 let blocked_step = plan
389 .steps
390 .iter()
391 .find(|step| !results.contains_key(&step.id));
392 let blocked = blocked_step
393 .map(|step| step.id.clone())
394 .unwrap_or_else(|| "plan".to_string());
395 if let Some(step) = blocked_step {
396 trace_undispatched(
397 ctx,
398 plan_node.as_deref(),
399 &plan.id,
400 step,
401 "failed",
402 Some(BLOCKED.to_string()),
403 );
404 }
405 insert_result(
406 &mut results,
407 &mut result_order,
408 StepResult {
409 step_id: blocked.clone(),
410 status: StepStatus::Failed,
411 output: None,
412 error: Some(BLOCKED.to_string()),
413 },
414 );
415 failed_step = Some(blocked);
416 break;
417 }
418
419 for step in &ready {
420 trace_step_start(ctx, plan_node.as_deref(), &plan.id, step);
421 }
422
423 let mut completed = Vec::new();
424 thread::scope(|scope| {
425 let mut handles = Vec::new();
426 for step in ready {
427 let snapshot = results.clone();
428 let shell_enforcement = &shell_enforcement;
429 handles.push(scope.spawn(move || {
430 execute_step_with_retries(&step, ctx, &snapshot, shell_enforcement)
431 }));
432 }
433 for handle in handles {
434 completed.push(handle.join().unwrap_or_else(|_| {
435 StepOutcome::undispatched(failed("unknown", "step dispatch thread panicked"))
436 }));
437 }
438 });
439
440 for outcome in completed {
441 let Some(step) = plan
442 .steps
443 .iter()
444 .find(|step| step.id == outcome.result.step_id)
445 else {
446 trace_step(
448 ctx,
449 plan_node.as_deref(),
450 PlanStepRecord {
451 plan_id: plan.id.clone(),
452 step_id: outcome.result.step_id.clone(),
453 kind: "unknown",
454 status: outcome.result.status.as_str(),
455 attempts: outcome.attempts,
456 duration_ms: outcome.duration_ms,
457 error: outcome.result.error.clone(),
458 input: None,
459 state_effect: None,
460 resource_id: None,
461 },
462 );
463 insert_result(&mut results, &mut result_order, outcome.result);
464 failed_step = Some("unknown".to_string());
465 break;
466 };
467
468 let kind = step_kind_name(step);
469 let StepOutcome {
470 result,
471 attempts,
472 duration_ms,
473 input,
474 state_effect,
475 resource_id,
476 } = outcome;
477
478 let (settled, stops) = if result.status == StepStatus::Failed {
481 match step.on_error.as_str() {
482 "fail" => (result, true),
483 "skip" => (
484 StepResult {
485 step_id: result.step_id,
486 status: StepStatus::Skipped,
487 output: None,
488 error: result.error,
489 },
490 false,
491 ),
492 "continue" => (result, false),
493 policy => (
494 StepResult {
495 error: Some(format!("invalid on_error policy '{policy}'")),
496 ..result
497 },
498 true,
499 ),
500 }
501 } else {
502 (result, false)
503 };
504
505 trace_step(
506 ctx,
507 plan_node.as_deref(),
508 PlanStepRecord {
509 plan_id: plan.id.clone(),
510 step_id: settled.step_id.clone(),
511 kind,
512 status: settled.status.as_str(),
513 attempts,
514 duration_ms,
515 error: settled.error.clone(),
516 input,
517 state_effect,
518 resource_id,
519 },
520 );
521
522 let id = settled.step_id.clone();
523 insert_result(&mut results, &mut result_order, settled);
524 if stops {
525 failed_step = Some(id);
526 break;
527 }
528 }
529 }
530
531 let ordered_results = result_order
532 .into_iter()
533 .filter_map(|id| results.remove(&id))
534 .collect::<Vec<_>>();
535
536 trace_plan_end(
537 ctx,
538 plan_node.as_deref(),
539 PlanEndRecord {
540 plan_id: plan.id.clone(),
541 outcome: if failed_step.is_none() {
542 "completed"
543 } else {
544 "failed"
545 },
546 failed_step: failed_step.clone(),
547 steps_total: plan.steps.len(),
548 steps_succeeded: count_status(&ordered_results, StepStatus::Success),
549 steps_failed: count_status(&ordered_results, StepStatus::Failed),
550 steps_skipped: count_status(&ordered_results, StepStatus::Skipped),
551 duration_ms: elapsed_ms(started),
552 reason: None,
553 },
554 );
555
556 Ok(ExecutionReport {
557 results: ordered_results,
558 completed: failed_step.is_none(),
559 failed_step,
560 })
561}
562
563fn step_kind_name(step: &StepDef) -> &'static str {
569 infer_kind(step).map(StepKind::as_str).unwrap_or("unknown")
570}
571
572fn plan_shape(plan: &PlanFile) -> Vec<PlanStepShape> {
574 plan.steps
575 .iter()
576 .map(|step| PlanStepShape {
577 step_id: step.id.clone(),
578 kind: step_kind_name(step),
579 depends_on: step.depends_on.clone(),
580 has_condition: step.condition.is_some(),
581 })
582 .collect()
583}
584
585fn trace_step_start(
586 ctx: &SchedulerContext<'_>,
587 plan_node: Option<&str>,
588 plan_id: &str,
589 step: &StepDef,
590) {
591 if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
592 appender.write_plan_step_start(
593 node,
594 plan_id,
595 &step.id,
596 step_kind_name(step),
597 step.depends_on.clone(),
598 );
599 }
600}
601
602fn trace_step(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanStepRecord) {
603 if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
604 appender.write_plan_step(node, record);
605 }
606}
607
608fn trace_undispatched(
612 ctx: &SchedulerContext<'_>,
613 plan_node: Option<&str>,
614 plan_id: &str,
615 step: &StepDef,
616 status: &'static str,
617 error: Option<String>,
618) {
619 trace_step(
620 ctx,
621 plan_node,
622 PlanStepRecord {
623 plan_id: plan_id.to_string(),
624 step_id: step.id.clone(),
625 kind: step_kind_name(step),
626 status,
627 attempts: 0,
628 duration_ms: 0,
629 error,
630 input: None,
631 state_effect: None,
632 resource_id: None,
633 },
634 );
635}
636
637fn trace_plan_end(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanEndRecord) {
638 if let Some(appender) = ctx.trace {
639 appender.write_plan_end(plan_node, record);
640 }
641}
642
643fn refused_before_any_step(
647 plan: &PlanFile,
648 ctx: &SchedulerContext<'_>,
649 plan_node: Option<&str>,
650 started: Instant,
651 error: String,
652) -> ExecutionReport {
653 trace_plan_end(
654 ctx,
655 plan_node,
656 PlanEndRecord {
657 plan_id: plan.id.clone(),
658 outcome: "failed",
659 failed_step: Some("plan".to_string()),
660 steps_total: plan.steps.len(),
661 steps_succeeded: 0,
662 steps_failed: 0,
663 steps_skipped: 0,
664 duration_ms: elapsed_ms(started),
665 reason: Some(error.clone()),
666 },
667 );
668 ExecutionReport {
669 results: vec![StepResult {
670 step_id: "plan".to_string(),
671 status: StepStatus::Failed,
672 output: None,
673 error: Some(error),
674 }],
675 completed: false,
676 failed_step: Some("plan".to_string()),
677 }
678}
679
680fn count_status(results: &[StepResult], status: StepStatus) -> usize {
681 results
682 .iter()
683 .filter(|result| result.status == status)
684 .count()
685}
686
687fn elapsed_ms(started: Instant) -> u64 {
688 started.elapsed().as_millis() as u64
689}
690
691fn validate_plan(plan: &PlanFile, ctx: &SchedulerContext<'_>) -> Result<(), (String, String)> {
692 let mut ids = HashSet::new();
693 for step in &plan.steps {
694 if !ids.insert(step.id.clone()) {
695 return Err((step.id.clone(), format!("duplicate step id '{}'", step.id)));
696 }
697
698 match infer_kind(step) {
699 Ok(StepKind::Tool) => {
700 let tool = step.tool.as_ref().unwrap();
701 if tool == crate::runtime::SUBMIT_PLAN_TOOL {
705 return Err((
706 step.id.clone(),
707 format!(
708 "a plan step cannot submit another plan: '{tool}' is not callable \
709 from inside a plan"
710 ),
711 ));
712 }
713 if !ctx.installed_tools.contains(tool) {
714 return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
715 }
716 }
717 Ok(StepKind::Shell) => {
718 let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
719 return Err((step.id.clone(), "shell command is empty".to_string()));
720 };
721 if !ctx.capability_policy.shell_allow.contains(&binary) {
722 return Err((
723 step.id.clone(),
724 format!("binary '{binary}' is not in capabilities.shell.allow"),
725 ));
726 }
727 }
728 Ok(StepKind::Spawn) => {
729 if let Err(error) = capsule_task_text(&step.input) {
736 return Err((step.id.clone(), error));
737 }
738 }
739 Err(error) => return Err((step.id.clone(), error)),
740 }
741
742 if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
743 return Err((
744 step.id.clone(),
745 format!("invalid on_error policy '{}'", step.on_error),
746 ));
747 }
748 }
749
750 for step in &plan.steps {
751 for dep in &step.depends_on {
752 if !ids.contains(dep) {
753 return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
754 }
755 }
756 validate_references(step, &ids)?;
757 }
758
759 Ok(())
760}
761
762fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
763 let mut references = Vec::new();
764 collect_references(&step.input, &mut references);
765 if let Some(condition) = &step.condition {
766 for token in condition.split_whitespace() {
767 if token.starts_with('$') {
768 references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
769 }
770 }
771 }
772
773 for reference in references {
774 if let Some((step_id, field)) = parse_reference(&reference) {
775 if !ids.contains(step_id) {
776 return Err((step.id.clone(), format!("unknown reference '{reference}'")));
777 }
778 if field != "output" && field != "status" {
779 return Err((
780 step.id.clone(),
781 format!("unknown reference field '{field}'"),
782 ));
783 }
784 }
785 }
786
787 Ok(())
788}
789
790fn collect_references(value: &Value, references: &mut Vec<String>) {
791 match value {
792 Value::String(value) if value.starts_with('$') => references.push(value.clone()),
793 Value::Array(values) => values
794 .iter()
795 .for_each(|value| collect_references(value, references)),
796 Value::Object(map) => map
797 .values()
798 .for_each(|value| collect_references(value, references)),
799 _ => {}
800 }
801}
802
803struct StepOutcome {
809 result: StepResult,
810 attempts: u32,
811 duration_ms: u64,
812 input: Option<Value>,
815 state_effect: Option<String>,
818 resource_id: Option<String>,
819}
820
821impl StepOutcome {
822 fn plain(result: StepResult, started: Instant) -> Self {
825 Self {
826 result,
827 attempts: 1,
828 duration_ms: elapsed_ms(started),
829 input: None,
830 state_effect: None,
831 resource_id: None,
832 }
833 }
834
835 fn undispatched(result: StepResult) -> Self {
837 Self {
838 result,
839 attempts: 0,
840 duration_ms: 0,
841 input: None,
842 state_effect: None,
843 resource_id: None,
844 }
845 }
846}
847
848fn execute_step_with_retries(
849 step: &StepDef,
850 ctx: &SchedulerContext<'_>,
851 results: &HashMap<String, StepResult>,
852 enforcement: &sandbox::ShellEnforcement,
853) -> StepOutcome {
854 let started = Instant::now();
855 let attempts = step.retries.saturating_add(1);
856 let mut last = None;
857 for attempt in 1..=attempts {
858 let mut outcome = execute_step_once(step, ctx, results, enforcement);
859 outcome.attempts = attempt;
860 outcome.duration_ms = elapsed_ms(started);
861 if outcome.result.status != StepStatus::Failed {
862 return outcome;
863 }
864 last = Some(outcome);
865 }
866
867 last.unwrap_or_else(|| StepOutcome::undispatched(failed(&step.id, "step was not attempted")))
868}
869
870fn execute_step_once(
871 step: &StepDef,
872 ctx: &SchedulerContext<'_>,
873 results: &HashMap<String, StepResult>,
874 enforcement: &sandbox::ShellEnforcement,
875) -> StepOutcome {
876 let started = Instant::now();
877 let mut input = step.input.clone();
878 if let Err(error) = interpolate_value(&mut input, results) {
879 return StepOutcome::plain(failed(&step.id, error), started);
880 }
881 let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
882
883 match infer_kind(step) {
884 Ok(StepKind::Tool) => {
885 let (result, state_effect, resource_id) = dispatch_tool_step(step, ctx, input_json);
886 StepOutcome {
887 result,
888 attempts: 1,
889 duration_ms: elapsed_ms(started),
890 input: (!input.is_null()).then_some(input),
893 state_effect,
894 resource_id,
895 }
896 }
897 Ok(StepKind::Shell) => {
898 StepOutcome::plain(dispatch_shell_step(step, ctx, enforcement), started)
899 }
900 Ok(StepKind::Spawn) => StepOutcome::plain(dispatch_capsule_step(step, ctx, input), started),
901 Err(error) => StepOutcome::plain(failed(&step.id, error), started),
902 }
903}
904
905fn dispatch_tool_step(
909 step: &StepDef,
910 ctx: &SchedulerContext<'_>,
911 input_json: String,
912) -> (StepResult, Option<String>, Option<String>) {
913 let name = step.tool.as_deref().unwrap_or_default();
914 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Tool {
915 name,
916 input_json: &input_json,
917 }) {
918 return (failed(&step.id, refusal), None, None);
919 }
920 match (ctx.invoke_tool)(
921 name,
922 ToolInput {
923 data: Some(input_json),
924 log_path: None,
925 },
926 ) {
927 Ok(result) => {
928 let state_effect = crate::agent::extract_state_effect(&result.metadata);
929 let resource_id = crate::agent::extract_resource_id(&result.metadata);
930 let verdict = if matches!(result.status, ToolStatus::Passed) {
931 match tool_step_output(
932 name,
933 &ctx.accessible_workdir,
934 result.data,
935 result.data_path.as_deref(),
936 ) {
937 Ok(output) => StepResult {
938 step_id: step.id.clone(),
939 status: StepStatus::Success,
940 output,
941 error: None,
942 },
943 Err(error) => failed(&step.id, error),
944 }
945 } else {
946 failed(
947 &step.id,
948 result
949 .summary
950 .or(result.data)
951 .unwrap_or_else(|| "tool step failed".to_string()),
952 )
953 };
954 (verdict, state_effect, resource_id)
955 }
956 Err(error) => (failed(&step.id, error), None, None),
957 }
958}
959
960fn dispatch_shell_step(
961 step: &StepDef,
962 ctx: &SchedulerContext<'_>,
963 enforcement: &sandbox::ShellEnforcement,
964) -> StepResult {
965 let command = step.shell.as_deref().unwrap_or_default();
966 let Some((binary, args)) = parse_shell_command(command) else {
967 return failed(&step.id, "shell command is empty");
968 };
969 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Shell {
970 binary: &binary,
971 command,
972 argv: &args,
973 }) {
974 return failed(&step.id, refusal);
975 }
976 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
977
978 match execute_shell(
979 &binary,
980 &arg_refs,
981 &[],
982 &ctx.accessible_workdir,
983 &ctx.session_workdir,
984 &ctx.capability_policy,
985 enforcement,
986 ) {
987 Ok(result) if result.exit_code == 0 => StepResult {
988 step_id: step.id.clone(),
989 status: StepStatus::Success,
990 output: Some(result.stdout),
991 error: None,
992 },
993 Ok(result) => match result.resource_limit_hit {
998 Some(limit) => failed(
999 &step.id,
1000 crate::errors::RuntimeError::ShellResourceLimitExceeded {
1001 binary: result.binary,
1002 limit,
1003 detail: if result.stderr.trim().is_empty() {
1004 format!("exit code {}", result.exit_code)
1005 } else {
1006 result.stderr
1007 },
1008 }
1009 .to_string(),
1010 ),
1011 None if result.stderr.trim().is_empty() => {
1016 failed(&step.id, format!("exit code {}", result.exit_code))
1017 }
1018 None => failed(&step.id, result.stderr),
1019 },
1020 Err(error) => failed(&step.id, error.to_string()),
1025 }
1026}
1027
1028fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1029 let capsule = step.capsule.as_deref().unwrap_or_default();
1030 let task = match capsule_task_text(&input) {
1033 Ok(task) => task,
1034 Err(error) => return failed(&step.id, error),
1035 };
1036 let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1037 Ok(value) if !value.trim().is_empty() => value,
1038 _ => {
1039 return failed(
1040 &step.id,
1041 "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1042 )
1043 }
1044 };
1045 let Some(credential) = ctx.spawn_credential.as_ref() else {
1046 return failed(
1047 &step.id,
1048 "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1049 mints it only for a session whose manifest declares capabilities.spawn.allow",
1050 );
1051 };
1052
1053 let plane = DelegationPlane::new(
1060 roost_url,
1061 credential.clone(),
1062 ctx.accessible_workdir.clone(),
1063 ctx.current_session_id.clone().unwrap_or_default(),
1064 crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1065 std::sync::Arc::clone(&ctx.registry),
1066 ctx.capability_policy.env_allow.clone(),
1067 );
1068 let launched = ctx.trace.map(|trace| {
1072 std::sync::Arc::new(move |notice: crate::delegation_plane::DelegationLaunch| {
1073 trace.write_delegation_start(
1074 ¬ice.delegation_id,
1075 ¬ice.capsule,
1076 ¬ice.version,
1077 ¬ice.child_session_id,
1078 ¬ice.child_workdir,
1079 );
1080 }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1081 });
1082 let started = Instant::now();
1083 let result = plane.delegate(
1084 &DelegationRequest {
1085 capsule: capsule.to_string(),
1086 version: ctx
1089 .capsule_versions
1090 .get(capsule)
1091 .cloned()
1092 .unwrap_or_else(|| "0.1.0".to_string()),
1093 task,
1094 },
1095 &DelegationOrigin {
1096 context_id: ctx.current_context_id.clone().unwrap_or_default(),
1097 launched,
1098 trust: None,
1101 },
1102 );
1103
1104 if let Some(trace) = ctx.trace {
1105 trace.write_delegation(
1106 &result.capsule,
1107 &result.version,
1108 Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1111 Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1112 elapsed_ms(started),
1113 result.status.as_str(),
1114 match result.status {
1115 DelegationStatus::Completed => None,
1116 _ => Some(result.output.clone()),
1117 },
1118 );
1119 }
1120
1121 match result.status {
1122 DelegationStatus::Completed => StepResult {
1123 step_id: step.id.clone(),
1124 status: StepStatus::Success,
1125 output: Some(result.output),
1126 error: None,
1127 },
1128 _ => failed(&step.id, result.output),
1129 }
1130}
1131
1132fn capsule_task_text(input: &Value) -> Result<String, String> {
1151 const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1152 const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1153 receives, given as a string or as { \"objective\": \"<text>\" }";
1154
1155 match input {
1156 Value::String(text) => Ok(text.clone()),
1157 Value::Object(map) => {
1158 let mut unknown = map
1161 .keys()
1162 .filter(|key| key.as_str() != "objective")
1163 .map(String::as_str)
1164 .collect::<Vec<_>>();
1165 unknown.sort_unstable();
1166 if !unknown.is_empty() {
1167 return Err(format!(
1168 "capsule step input has keys the child cannot receive: {}; nothing on the \
1169 receiving side parses a task envelope, so put the whole instruction in \
1170 \"objective\"",
1171 unknown.join(", ")
1172 ));
1173 }
1174
1175 match map.get("objective") {
1176 Some(Value::String(text)) => Ok(text.clone()),
1177 Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1178 None => Err(ACCEPTS.to_string()),
1179 }
1180 }
1181 Value::Null => Err(NO_INPUT.to_string()),
1182 Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1183 Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1184 Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1185 }
1186}
1187
1188fn interpolate_value(
1189 value: &mut Value,
1190 results: &HashMap<String, StepResult>,
1191) -> Result<(), String> {
1192 match value {
1193 Value::String(text) if text.starts_with('$') => {
1194 *text = condition::resolve_reference(text, results)?;
1195 }
1196 Value::Array(values) => {
1197 for value in values {
1198 interpolate_value(value, results)?;
1199 }
1200 }
1201 Value::Object(map) => {
1202 for value in map.values_mut() {
1203 interpolate_value(value, results)?;
1204 }
1205 }
1206 _ => {}
1207 }
1208
1209 Ok(())
1210}
1211
1212fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1213 let count =
1214 step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1215 match count {
1216 1 if step.tool.is_some() => Ok(StepKind::Tool),
1217 1 if step.shell.is_some() => Ok(StepKind::Shell),
1218 1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1219 0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1220 _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1221 }
1222}
1223
1224fn default_on_error() -> String {
1225 "fail".to_string()
1226}
1227
1228fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1229 StepResult {
1230 step_id: step_id.to_string(),
1231 status: StepStatus::Failed,
1232 output: None,
1233 error: Some(error.into()),
1234 }
1235}
1236
1237fn insert_result(
1238 results: &mut HashMap<String, StepResult>,
1239 order: &mut Vec<String>,
1240 result: StepResult,
1241) {
1242 order.push(result.step_id.clone());
1243 results.insert(result.step_id.clone(), result);
1244}
1245
1246fn shell_binary(command: &str) -> Option<String> {
1247 parse_shell_command(command).map(|(binary, _)| binary)
1248}
1249
1250fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1251 let parts = split_shell_words(command);
1252 let (binary, args) = parts.split_first()?;
1253 Some((binary.clone(), args.to_vec()))
1254}
1255
1256fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1257 reference.strip_prefix('$')?.rsplit_once('.')
1258}
1259
1260fn tool_step_output(
1277 tool: &str,
1278 workdir: &Path,
1279 data: Option<String>,
1280 data_path: Option<&str>,
1281) -> Result<Option<String>, String> {
1282 if let Some(data) = data {
1283 return Ok(Some(data));
1284 }
1285 let Some(relpath) = data_path else {
1286 return Ok(None);
1287 };
1288 match resource_plane::read_file_beneath_root(
1289 workdir,
1290 relpath,
1291 DEFAULT_EXPORT_MAX_BYTES,
1292 SymlinkPolicy::Refuse,
1293 ) {
1294 Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1295 Err(error) => Err(RuntimeError::ToolDataPathRefused {
1296 tool: tool.to_string(),
1297 path: relpath.to_string(),
1298 code: error.code().to_string(),
1299 detail: error.message(),
1300 }
1301 .to_string()),
1302 }
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307 use std::{
1308 sync::{
1309 atomic::{AtomicUsize, Ordering},
1310 Arc,
1311 },
1312 time::{Duration, Instant},
1313 };
1314
1315 use serde_json::json;
1316 use tempfile::tempdir;
1317
1318 use super::*;
1319
1320 fn test_ctx<'a>(
1324 workdir: PathBuf,
1325 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1326 ) -> SchedulerContext<'a> {
1327 test_ctx_gated(workdir, invoke_tool, &UNGATED)
1328 }
1329
1330 const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1333
1334 fn test_ctx_gated<'a>(
1335 workdir: PathBuf,
1336 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1337 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1338 ) -> SchedulerContext<'a> {
1339 SchedulerContext {
1340 accessible_workdir: workdir.clone(),
1341 session_workdir: workdir,
1342 capability_policy: CapabilityPolicy {
1343 shell_allow: vec!["bash".to_string()],
1344 spawn_allow: vec!["worker".to_string()],
1345 ..CapabilityPolicy::default()
1346 },
1347 installed_tools: HashSet::from([
1348 "ok".to_string(),
1349 "fail".to_string(),
1350 "slow".to_string(),
1351 "a".to_string(),
1352 "b".to_string(),
1353 ]),
1354 capsule_versions: HashMap::new(),
1355 current_session_id: None,
1356 current_context_id: None,
1357 registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1359 tempdir().unwrap().keep(),
1360 )),
1361 spawn_credential: None,
1362 trace: None,
1363 gate_step,
1364 invoke_tool,
1365 }
1366 }
1367
1368 fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1369 ToolResult {
1370 status,
1371 summary: None,
1372 data,
1373 data_path: None,
1374 truncated: false,
1375 metadata: Vec::new(),
1376 }
1377 }
1378
1379 fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1380 let path = workdir.join("plan.json");
1381 fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1382 path
1383 }
1384
1385 #[test]
1386 fn capsule_task_text_delivers_a_bare_string_verbatim() {
1387 assert_eq!(
1388 capsule_task_text(&json!("do the thing")).unwrap(),
1389 "do the thing"
1390 );
1391 }
1392
1393 #[test]
1394 fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1395 assert_eq!(
1396 capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1397 "do the thing"
1398 );
1399 }
1400
1401 #[test]
1404 fn capsule_task_text_accepts_empty_task_text() {
1405 assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1406 assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1407 }
1408
1409 #[test]
1410 fn capsule_task_text_refuses_a_task_envelope() {
1411 let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1412 assert!(
1413 error.contains("keys the child cannot receive: task"),
1414 "{error}"
1415 );
1416 assert!(error.contains("\"objective\""), "{error}");
1417 }
1418
1419 #[test]
1420 fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1421 let error =
1422 capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1423 .unwrap_err();
1424 assert!(
1425 error.contains("keys the child cannot receive: instructions, render_as"),
1426 "{error}"
1427 );
1428 }
1429
1430 #[test]
1431 fn capsule_task_text_refuses_a_non_string_objective() {
1432 for input in [json!({"objective": 7}), json!({"objective": null})] {
1433 assert_eq!(
1434 capsule_task_text(&input).unwrap_err(),
1435 "capsule step input \"objective\" must be a string"
1436 );
1437 }
1438 }
1439
1440 #[test]
1441 fn capsule_task_text_refuses_an_object_without_an_objective() {
1442 assert_eq!(
1443 capsule_task_text(&json!({})).unwrap_err(),
1444 "capsule step input must be a string or { \"objective\": \"<text>\" }"
1445 );
1446 }
1447
1448 #[test]
1449 fn capsule_task_text_names_the_type_it_was_handed() {
1450 assert!(capsule_task_text(&json!(7))
1451 .unwrap_err()
1452 .ends_with("not a number"));
1453 assert!(capsule_task_text(&json!(true))
1454 .unwrap_err()
1455 .ends_with("not a boolean"));
1456 assert!(capsule_task_text(&json!(["a"]))
1457 .unwrap_err()
1458 .ends_with("not an array"));
1459 }
1460
1461 #[test]
1464 fn capsule_task_text_refuses_an_absent_input() {
1465 let error = capsule_task_text(&Value::Null).unwrap_err();
1466 assert_eq!(
1467 error,
1468 "capsule step has no input; its input is the task text the child receives, given as \
1469 a string or as { \"objective\": \"<text>\" }"
1470 );
1471 }
1472
1473 #[test]
1476 fn capsule_task_text_accepts_an_uninterpolated_reference() {
1477 assert_eq!(
1478 capsule_task_text(&json!("$analyse.output")).unwrap(),
1479 "$analyse.output"
1480 );
1481 assert_eq!(
1482 capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1483 "$analyse.output"
1484 );
1485 }
1486
1487 fn test_ctx_unbounded<'a>(
1490 workdir: PathBuf,
1491 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1492 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1493 ) -> SchedulerContext<'a> {
1494 SchedulerContext {
1495 capability_policy: CapabilityPolicy::default(),
1496 ..test_ctx_gated(workdir, invoke_tool, gate_step)
1497 }
1498 }
1499
1500 #[test]
1505 fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1506 let dir = tempdir().unwrap();
1507 let dispatched = Arc::new(AtomicUsize::new(0));
1508 let counted = Arc::clone(&dispatched);
1509 let invoke = move |_name: &str, _input: ToolInput| {
1510 counted.fetch_add(1, Ordering::SeqCst);
1511 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1512 };
1513 let gate = |call: &PlannedCall<'_>| match call {
1514 PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1515 _ => None,
1516 };
1517 let plan = write_plan(
1518 dir.path(),
1519 json!({
1520 "id": "p",
1521 "steps": [
1522 {"id": "refused", "tool": "a", "on_error": "continue"},
1523 {"id": "allowed", "tool": "b"}
1524 ]
1525 }),
1526 );
1527
1528 let report = execute(
1529 &plan,
1530 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1531 );
1532 let refused = report
1533 .results
1534 .iter()
1535 .find(|result| result.step_id == "refused")
1536 .expect("the refused step settled");
1537 assert_eq!(refused.status, StepStatus::Failed);
1538 assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1539 assert_eq!(refused.output, None);
1540 assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1542 }
1543
1544 #[test]
1547 fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1548 if crate::cgroup::skip_without_host_support(
1549 "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1550 ) {
1551 return;
1552 }
1553 let dir = tempdir().unwrap();
1554 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1555 let plan = write_plan(
1556 dir.path(),
1557 json!({
1558 "id": "p",
1559 "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1560 }),
1561 );
1562
1563 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1564 assert!(!report.completed);
1565 assert_eq!(report.results[0].status, StepStatus::Failed);
1566 assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1567 }
1568
1569 #[test]
1571 fn a_refused_shell_step_starts_no_subprocess() {
1572 if crate::cgroup::skip_without_host_support(
1573 "plan::tests::a_refused_shell_step_starts_no_subprocess",
1574 ) {
1575 return;
1576 }
1577 let dir = tempdir().unwrap();
1578 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1579 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1580 let recorded = Arc::clone(&seen);
1581 let gate = move |call: &PlannedCall<'_>| match call {
1582 PlannedCall::Shell { binary, argv, .. } => {
1583 recorded
1584 .lock()
1585 .unwrap()
1586 .push((binary.to_string(), argv.to_vec()));
1587 Some("refused by the manifest".to_string())
1588 }
1589 _ => None,
1590 };
1591 let marker = dir.path().join("written-by-a-refused-step");
1592 let plan = write_plan(
1593 dir.path(),
1594 json!({
1595 "id": "p",
1596 "steps": [{
1597 "id": "w",
1598 "shell": format!("bash -c 'printf x > {}'", marker.display()),
1599 }]
1600 }),
1601 );
1602
1603 let report = execute(
1604 &plan,
1605 &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
1606 );
1607 assert!(!report.completed);
1608 assert_eq!(report.failed_step.as_deref(), Some("w"));
1609 assert_eq!(
1610 report.results[0].error.as_deref(),
1611 Some("refused by the manifest")
1612 );
1613 assert!(!marker.exists(), "a refused step must not have run");
1614 let seen = seen.lock().unwrap();
1616 assert_eq!(seen.len(), 1);
1617 assert_eq!(seen[0].0, "bash");
1618 assert_eq!(seen[0].1[0], "-c");
1619 }
1620
1621 #[test]
1622 fn dependent_step_receives_upstream_output() {
1623 if crate::cgroup::skip_without_host_support(
1624 "plan::tests::dependent_step_receives_upstream_output",
1625 ) {
1626 return;
1627 }
1628 let dir = tempdir().unwrap();
1629 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1630 let seen_tool = Arc::clone(&seen);
1631 let invoke = move |_name: &str, input: ToolInput| {
1632 seen_tool
1633 .lock()
1634 .unwrap()
1635 .push(input.data.unwrap_or_default());
1636 Ok(tool_result(
1637 ToolStatus::Passed,
1638 Some("upstream".to_string()),
1639 ))
1640 };
1641 let plan = write_plan(
1642 dir.path(),
1643 json!({
1644 "id": "p",
1645 "steps": [
1646 {"id": "a", "tool": "ok"},
1647 {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
1648 ]
1649 }),
1650 );
1651
1652 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1653 assert!(report.completed);
1654 assert_eq!(report.results.len(), 2);
1655 assert!(seen.lock().unwrap()[1].contains("upstream"));
1656 }
1657
1658 #[test]
1659 fn parallel_tool_steps_run_concurrently() {
1660 if crate::cgroup::skip_without_host_support(
1661 "plan::tests::parallel_tool_steps_run_concurrently",
1662 ) {
1663 return;
1664 }
1665 let dir = tempdir().unwrap();
1666 let active = Arc::new(AtomicUsize::new(0));
1667 let max_active = Arc::new(AtomicUsize::new(0));
1668 let invoke_active = Arc::clone(&active);
1669 let invoke_max = Arc::clone(&max_active);
1670 let invoke = move |_name: &str, _input: ToolInput| {
1671 let now = invoke_active.fetch_add(1, Ordering::SeqCst) + 1;
1672 invoke_max.fetch_max(now, Ordering::SeqCst);
1673 thread::sleep(Duration::from_millis(150));
1674 invoke_active.fetch_sub(1, Ordering::SeqCst);
1675 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1676 };
1677 let plan = write_plan(
1678 dir.path(),
1679 json!({
1680 "id": "p",
1681 "steps": [
1682 {"id": "a", "tool": "slow"},
1683 {"id": "b", "tool": "slow"}
1684 ]
1685 }),
1686 );
1687
1688 let started = Instant::now();
1689 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1690 assert!(report.completed);
1691 assert!(started.elapsed() < Duration::from_millis(280));
1692 assert_eq!(max_active.load(Ordering::SeqCst), 2);
1693 }
1694
1695 #[test]
1696 fn on_error_and_retries_are_applied() {
1697 if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
1698 {
1699 return;
1700 }
1701 let dir = tempdir().unwrap();
1702 let calls = Arc::new(AtomicUsize::new(0));
1703 let invoke_calls = Arc::clone(&calls);
1704 let invoke = move |name: &str, _input: ToolInput| {
1705 invoke_calls.fetch_add(1, Ordering::SeqCst);
1706 if name == "a" {
1707 Ok(tool_result(ToolStatus::Failed, None))
1708 } else {
1709 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1710 }
1711 };
1712 let plan = write_plan(
1713 dir.path(),
1714 json!({
1715 "id": "p",
1716 "steps": [
1717 {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
1718 {"id": "b", "tool": "b", "depends_on": ["a"]}
1719 ]
1720 }),
1721 );
1722
1723 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1724 assert!(report.completed);
1725 assert_eq!(calls.load(Ordering::SeqCst), 4);
1726 }
1727
1728 #[test]
1729 fn condition_false_skips_step() {
1730 if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
1731 return;
1732 }
1733 let dir = tempdir().unwrap();
1734 let invoke = |_name: &str, _input: ToolInput| {
1735 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1736 };
1737 let plan = write_plan(
1738 dir.path(),
1739 json!({
1740 "id": "p",
1741 "steps": [
1742 {"id": "a", "tool": "ok"},
1743 {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
1744 ]
1745 }),
1746 );
1747
1748 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1749 assert!(report.completed);
1750 assert_eq!(report.results[1].status, StepStatus::Skipped);
1751 }
1752}