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 tool == crate::runtime::SWITCH_DRIVER_TOOL {
716 return Err((
717 step.id.clone(),
718 format!("'{tool}' is not callable from inside a plan"),
719 ));
720 }
721 if !ctx.installed_tools.contains(tool) {
722 return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
723 }
724 }
725 Ok(StepKind::Shell) => {
726 let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
727 return Err((step.id.clone(), "shell command is empty".to_string()));
728 };
729 if !ctx.capability_policy.shell_allow.contains(&binary) {
730 return Err((
731 step.id.clone(),
732 format!("binary '{binary}' is not in capabilities.shell.allow"),
733 ));
734 }
735 }
736 Ok(StepKind::Spawn) => {
737 if let Err(error) = capsule_task_text(&step.input) {
744 return Err((step.id.clone(), error));
745 }
746 }
747 Err(error) => return Err((step.id.clone(), error)),
748 }
749
750 if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
751 return Err((
752 step.id.clone(),
753 format!("invalid on_error policy '{}'", step.on_error),
754 ));
755 }
756 }
757
758 for step in &plan.steps {
759 for dep in &step.depends_on {
760 if !ids.contains(dep) {
761 return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
762 }
763 }
764 validate_references(step, &ids)?;
765 }
766
767 Ok(())
768}
769
770fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
771 let mut references = Vec::new();
772 collect_references(&step.input, &mut references);
773 if let Some(condition) = &step.condition {
774 for token in condition.split_whitespace() {
775 if token.starts_with('$') {
776 references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
777 }
778 }
779 }
780
781 for reference in references {
782 if let Some((step_id, field)) = parse_reference(&reference) {
783 if !ids.contains(step_id) {
784 return Err((step.id.clone(), format!("unknown reference '{reference}'")));
785 }
786 if field != "output" && field != "status" {
787 return Err((
788 step.id.clone(),
789 format!("unknown reference field '{field}'"),
790 ));
791 }
792 }
793 }
794
795 Ok(())
796}
797
798fn collect_references(value: &Value, references: &mut Vec<String>) {
799 match value {
800 Value::String(value) if value.starts_with('$') => references.push(value.clone()),
801 Value::Array(values) => values
802 .iter()
803 .for_each(|value| collect_references(value, references)),
804 Value::Object(map) => map
805 .values()
806 .for_each(|value| collect_references(value, references)),
807 _ => {}
808 }
809}
810
811struct StepOutcome {
817 result: StepResult,
818 attempts: u32,
819 duration_ms: u64,
820 input: Option<Value>,
823 state_effect: Option<String>,
826 resource_id: Option<String>,
827}
828
829impl StepOutcome {
830 fn plain(result: StepResult, started: Instant) -> Self {
833 Self {
834 result,
835 attempts: 1,
836 duration_ms: elapsed_ms(started),
837 input: None,
838 state_effect: None,
839 resource_id: None,
840 }
841 }
842
843 fn undispatched(result: StepResult) -> Self {
845 Self {
846 result,
847 attempts: 0,
848 duration_ms: 0,
849 input: None,
850 state_effect: None,
851 resource_id: None,
852 }
853 }
854}
855
856fn execute_step_with_retries(
857 step: &StepDef,
858 ctx: &SchedulerContext<'_>,
859 results: &HashMap<String, StepResult>,
860 enforcement: &sandbox::ShellEnforcement,
861) -> StepOutcome {
862 let started = Instant::now();
863 let attempts = step.retries.saturating_add(1);
864 let mut last = None;
865 for attempt in 1..=attempts {
866 let mut outcome = execute_step_once(step, ctx, results, enforcement);
867 outcome.attempts = attempt;
868 outcome.duration_ms = elapsed_ms(started);
869 if outcome.result.status != StepStatus::Failed {
870 return outcome;
871 }
872 last = Some(outcome);
873 }
874
875 last.unwrap_or_else(|| StepOutcome::undispatched(failed(&step.id, "step was not attempted")))
876}
877
878fn execute_step_once(
879 step: &StepDef,
880 ctx: &SchedulerContext<'_>,
881 results: &HashMap<String, StepResult>,
882 enforcement: &sandbox::ShellEnforcement,
883) -> StepOutcome {
884 let started = Instant::now();
885 let mut input = step.input.clone();
886 if let Err(error) = interpolate_value(&mut input, results) {
887 return StepOutcome::plain(failed(&step.id, error), started);
888 }
889 let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
890
891 match infer_kind(step) {
892 Ok(StepKind::Tool) => {
893 let (result, state_effect, resource_id) = dispatch_tool_step(step, ctx, input_json);
894 StepOutcome {
895 result,
896 attempts: 1,
897 duration_ms: elapsed_ms(started),
898 input: (!input.is_null()).then_some(input),
901 state_effect,
902 resource_id,
903 }
904 }
905 Ok(StepKind::Shell) => {
906 StepOutcome::plain(dispatch_shell_step(step, ctx, enforcement), started)
907 }
908 Ok(StepKind::Spawn) => StepOutcome::plain(dispatch_capsule_step(step, ctx, input), started),
909 Err(error) => StepOutcome::plain(failed(&step.id, error), started),
910 }
911}
912
913fn dispatch_tool_step(
917 step: &StepDef,
918 ctx: &SchedulerContext<'_>,
919 input_json: String,
920) -> (StepResult, Option<String>, Option<String>) {
921 let name = step.tool.as_deref().unwrap_or_default();
922 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Tool {
923 name,
924 input_json: &input_json,
925 }) {
926 return (failed(&step.id, refusal), None, None);
927 }
928 match (ctx.invoke_tool)(
929 name,
930 ToolInput {
931 data: Some(input_json),
932 log_path: None,
933 },
934 ) {
935 Ok(result) => {
936 let state_effect = crate::agent::extract_state_effect(&result.metadata);
937 let resource_id = crate::agent::extract_resource_id(&result.metadata);
938 let verdict = if matches!(result.status, ToolStatus::Passed) {
939 match tool_step_output(
940 name,
941 &ctx.accessible_workdir,
942 result.data,
943 result.data_path.as_deref(),
944 ) {
945 Ok(output) => StepResult {
946 step_id: step.id.clone(),
947 status: StepStatus::Success,
948 output,
949 error: None,
950 },
951 Err(error) => failed(&step.id, error),
952 }
953 } else {
954 failed(
955 &step.id,
956 result
957 .summary
958 .or(result.data)
959 .unwrap_or_else(|| "tool step failed".to_string()),
960 )
961 };
962 (verdict, state_effect, resource_id)
963 }
964 Err(error) => (failed(&step.id, error), None, None),
965 }
966}
967
968fn dispatch_shell_step(
969 step: &StepDef,
970 ctx: &SchedulerContext<'_>,
971 enforcement: &sandbox::ShellEnforcement,
972) -> StepResult {
973 let command = step.shell.as_deref().unwrap_or_default();
974 let Some((binary, args)) = parse_shell_command(command) else {
975 return failed(&step.id, "shell command is empty");
976 };
977 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Shell {
978 binary: &binary,
979 command,
980 argv: &args,
981 }) {
982 return failed(&step.id, refusal);
983 }
984 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
985
986 match execute_shell(
987 &binary,
988 &arg_refs,
989 &[],
990 &ctx.accessible_workdir,
991 &ctx.session_workdir,
992 &ctx.capability_policy,
993 enforcement,
994 ) {
995 Ok(result) if result.exit_code == 0 => StepResult {
996 step_id: step.id.clone(),
997 status: StepStatus::Success,
998 output: Some(result.stdout),
999 error: None,
1000 },
1001 Ok(result) => match result.resource_limit_hit {
1006 Some(limit) => failed(
1007 &step.id,
1008 crate::errors::RuntimeError::ShellResourceLimitExceeded {
1009 binary: result.binary,
1010 limit,
1011 detail: if result.stderr.trim().is_empty() {
1012 format!("exit code {}", result.exit_code)
1013 } else {
1014 result.stderr
1015 },
1016 }
1017 .to_string(),
1018 ),
1019 None if result.stderr.trim().is_empty() => {
1024 failed(&step.id, format!("exit code {}", result.exit_code))
1025 }
1026 None => failed(&step.id, result.stderr),
1027 },
1028 Err(error) => failed(&step.id, error.to_string()),
1033 }
1034}
1035
1036fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1037 let capsule = step.capsule.as_deref().unwrap_or_default();
1038 let task = match capsule_task_text(&input) {
1041 Ok(task) => task,
1042 Err(error) => return failed(&step.id, error),
1043 };
1044 let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1045 Ok(value) if !value.trim().is_empty() => value,
1046 _ => {
1047 return failed(
1048 &step.id,
1049 "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1050 )
1051 }
1052 };
1053 let Some(credential) = ctx.spawn_credential.as_ref() else {
1054 return failed(
1055 &step.id,
1056 "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1057 mints it only for a session whose manifest declares capabilities.spawn.allow",
1058 );
1059 };
1060
1061 let plane = DelegationPlane::new(
1068 roost_url,
1069 credential.clone(),
1070 ctx.accessible_workdir.clone(),
1071 ctx.current_session_id.clone().unwrap_or_default(),
1072 crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1073 std::sync::Arc::clone(&ctx.registry),
1074 ctx.capability_policy.env_allow.clone(),
1075 );
1076 let launched = ctx.trace.map(|trace| {
1080 std::sync::Arc::new(move |notice: crate::delegation_plane::DelegationLaunch| {
1081 trace.write_delegation_start(
1082 ¬ice.delegation_id,
1083 ¬ice.capsule,
1084 ¬ice.version,
1085 ¬ice.child_session_id,
1086 ¬ice.child_workdir,
1087 );
1088 }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1089 });
1090 let started = Instant::now();
1091 let result = plane.delegate(
1092 &DelegationRequest {
1093 capsule: capsule.to_string(),
1094 version: ctx
1097 .capsule_versions
1098 .get(capsule)
1099 .cloned()
1100 .unwrap_or_else(|| "0.1.0".to_string()),
1101 task,
1102 },
1103 &DelegationOrigin {
1104 context_id: ctx.current_context_id.clone().unwrap_or_default(),
1105 launched,
1106 trust: None,
1109 },
1110 );
1111
1112 if let Some(trace) = ctx.trace {
1113 trace.write_delegation(
1114 &result.capsule,
1115 &result.version,
1116 Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1119 Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1120 elapsed_ms(started),
1121 result.status.as_str(),
1122 match result.status {
1123 DelegationStatus::Completed => None,
1124 _ => Some(result.output.clone()),
1125 },
1126 );
1127 }
1128
1129 match result.status {
1130 DelegationStatus::Completed => StepResult {
1131 step_id: step.id.clone(),
1132 status: StepStatus::Success,
1133 output: Some(result.output),
1134 error: None,
1135 },
1136 _ => failed(&step.id, result.output),
1137 }
1138}
1139
1140fn capsule_task_text(input: &Value) -> Result<String, String> {
1159 const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1160 const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1161 receives, given as a string or as { \"objective\": \"<text>\" }";
1162
1163 match input {
1164 Value::String(text) => Ok(text.clone()),
1165 Value::Object(map) => {
1166 let mut unknown = map
1169 .keys()
1170 .filter(|key| key.as_str() != "objective")
1171 .map(String::as_str)
1172 .collect::<Vec<_>>();
1173 unknown.sort_unstable();
1174 if !unknown.is_empty() {
1175 return Err(format!(
1176 "capsule step input has keys the child cannot receive: {}; nothing on the \
1177 receiving side parses a task envelope, so put the whole instruction in \
1178 \"objective\"",
1179 unknown.join(", ")
1180 ));
1181 }
1182
1183 match map.get("objective") {
1184 Some(Value::String(text)) => Ok(text.clone()),
1185 Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1186 None => Err(ACCEPTS.to_string()),
1187 }
1188 }
1189 Value::Null => Err(NO_INPUT.to_string()),
1190 Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1191 Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1192 Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1193 }
1194}
1195
1196fn interpolate_value(
1197 value: &mut Value,
1198 results: &HashMap<String, StepResult>,
1199) -> Result<(), String> {
1200 match value {
1201 Value::String(text) if text.starts_with('$') => {
1202 *text = condition::resolve_reference(text, results)?;
1203 }
1204 Value::Array(values) => {
1205 for value in values {
1206 interpolate_value(value, results)?;
1207 }
1208 }
1209 Value::Object(map) => {
1210 for value in map.values_mut() {
1211 interpolate_value(value, results)?;
1212 }
1213 }
1214 _ => {}
1215 }
1216
1217 Ok(())
1218}
1219
1220fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1221 let count =
1222 step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1223 match count {
1224 1 if step.tool.is_some() => Ok(StepKind::Tool),
1225 1 if step.shell.is_some() => Ok(StepKind::Shell),
1226 1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1227 0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1228 _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1229 }
1230}
1231
1232fn default_on_error() -> String {
1233 "fail".to_string()
1234}
1235
1236fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1237 StepResult {
1238 step_id: step_id.to_string(),
1239 status: StepStatus::Failed,
1240 output: None,
1241 error: Some(error.into()),
1242 }
1243}
1244
1245fn insert_result(
1246 results: &mut HashMap<String, StepResult>,
1247 order: &mut Vec<String>,
1248 result: StepResult,
1249) {
1250 order.push(result.step_id.clone());
1251 results.insert(result.step_id.clone(), result);
1252}
1253
1254fn shell_binary(command: &str) -> Option<String> {
1255 parse_shell_command(command).map(|(binary, _)| binary)
1256}
1257
1258fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1259 let parts = split_shell_words(command);
1260 let (binary, args) = parts.split_first()?;
1261 Some((binary.clone(), args.to_vec()))
1262}
1263
1264fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1265 reference.strip_prefix('$')?.rsplit_once('.')
1266}
1267
1268fn tool_step_output(
1285 tool: &str,
1286 workdir: &Path,
1287 data: Option<String>,
1288 data_path: Option<&str>,
1289) -> Result<Option<String>, String> {
1290 if let Some(data) = data {
1291 return Ok(Some(data));
1292 }
1293 let Some(relpath) = data_path else {
1294 return Ok(None);
1295 };
1296 match resource_plane::read_file_beneath_root(
1297 workdir,
1298 relpath,
1299 DEFAULT_EXPORT_MAX_BYTES,
1300 SymlinkPolicy::Refuse,
1301 ) {
1302 Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1303 Err(error) => Err(RuntimeError::ToolDataPathRefused {
1304 tool: tool.to_string(),
1305 path: relpath.to_string(),
1306 code: error.code().to_string(),
1307 detail: error.message(),
1308 }
1309 .to_string()),
1310 }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use std::{
1316 sync::{
1317 atomic::{AtomicUsize, Ordering},
1318 Arc,
1319 },
1320 time::{Duration, Instant},
1321 };
1322
1323 use serde_json::json;
1324 use tempfile::tempdir;
1325
1326 use super::*;
1327
1328 fn test_ctx<'a>(
1332 workdir: PathBuf,
1333 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1334 ) -> SchedulerContext<'a> {
1335 test_ctx_gated(workdir, invoke_tool, &UNGATED)
1336 }
1337
1338 const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1341
1342 fn test_ctx_gated<'a>(
1343 workdir: PathBuf,
1344 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1345 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1346 ) -> SchedulerContext<'a> {
1347 SchedulerContext {
1348 accessible_workdir: workdir.clone(),
1349 session_workdir: workdir,
1350 capability_policy: CapabilityPolicy {
1351 shell_allow: vec!["bash".to_string()],
1352 spawn_allow: vec!["worker".to_string()],
1353 ..CapabilityPolicy::default()
1354 },
1355 installed_tools: HashSet::from([
1356 "ok".to_string(),
1357 "fail".to_string(),
1358 "slow".to_string(),
1359 "a".to_string(),
1360 "b".to_string(),
1361 ]),
1362 capsule_versions: HashMap::new(),
1363 current_session_id: None,
1364 current_context_id: None,
1365 registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1367 tempdir().unwrap().keep(),
1368 )),
1369 spawn_credential: None,
1370 trace: None,
1371 gate_step,
1372 invoke_tool,
1373 }
1374 }
1375
1376 fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1377 ToolResult {
1378 status,
1379 summary: None,
1380 data,
1381 data_path: None,
1382 truncated: false,
1383 metadata: Vec::new(),
1384 }
1385 }
1386
1387 fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1388 let path = workdir.join("plan.json");
1389 fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1390 path
1391 }
1392
1393 #[test]
1394 fn capsule_task_text_delivers_a_bare_string_verbatim() {
1395 assert_eq!(
1396 capsule_task_text(&json!("do the thing")).unwrap(),
1397 "do the thing"
1398 );
1399 }
1400
1401 #[test]
1402 fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1403 assert_eq!(
1404 capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1405 "do the thing"
1406 );
1407 }
1408
1409 #[test]
1412 fn capsule_task_text_accepts_empty_task_text() {
1413 assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1414 assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1415 }
1416
1417 #[test]
1418 fn capsule_task_text_refuses_a_task_envelope() {
1419 let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1420 assert!(
1421 error.contains("keys the child cannot receive: task"),
1422 "{error}"
1423 );
1424 assert!(error.contains("\"objective\""), "{error}");
1425 }
1426
1427 #[test]
1428 fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1429 let error =
1430 capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1431 .unwrap_err();
1432 assert!(
1433 error.contains("keys the child cannot receive: instructions, render_as"),
1434 "{error}"
1435 );
1436 }
1437
1438 #[test]
1439 fn capsule_task_text_refuses_a_non_string_objective() {
1440 for input in [json!({"objective": 7}), json!({"objective": null})] {
1441 assert_eq!(
1442 capsule_task_text(&input).unwrap_err(),
1443 "capsule step input \"objective\" must be a string"
1444 );
1445 }
1446 }
1447
1448 #[test]
1449 fn capsule_task_text_refuses_an_object_without_an_objective() {
1450 assert_eq!(
1451 capsule_task_text(&json!({})).unwrap_err(),
1452 "capsule step input must be a string or { \"objective\": \"<text>\" }"
1453 );
1454 }
1455
1456 #[test]
1457 fn capsule_task_text_names_the_type_it_was_handed() {
1458 assert!(capsule_task_text(&json!(7))
1459 .unwrap_err()
1460 .ends_with("not a number"));
1461 assert!(capsule_task_text(&json!(true))
1462 .unwrap_err()
1463 .ends_with("not a boolean"));
1464 assert!(capsule_task_text(&json!(["a"]))
1465 .unwrap_err()
1466 .ends_with("not an array"));
1467 }
1468
1469 #[test]
1472 fn capsule_task_text_refuses_an_absent_input() {
1473 let error = capsule_task_text(&Value::Null).unwrap_err();
1474 assert_eq!(
1475 error,
1476 "capsule step has no input; its input is the task text the child receives, given as \
1477 a string or as { \"objective\": \"<text>\" }"
1478 );
1479 }
1480
1481 #[test]
1484 fn capsule_task_text_accepts_an_uninterpolated_reference() {
1485 assert_eq!(
1486 capsule_task_text(&json!("$analyse.output")).unwrap(),
1487 "$analyse.output"
1488 );
1489 assert_eq!(
1490 capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1491 "$analyse.output"
1492 );
1493 }
1494
1495 fn test_ctx_unbounded<'a>(
1498 workdir: PathBuf,
1499 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1500 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1501 ) -> SchedulerContext<'a> {
1502 SchedulerContext {
1503 capability_policy: CapabilityPolicy::default(),
1504 ..test_ctx_gated(workdir, invoke_tool, gate_step)
1505 }
1506 }
1507
1508 #[test]
1513 fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1514 let dir = tempdir().unwrap();
1515 let dispatched = Arc::new(AtomicUsize::new(0));
1516 let counted = Arc::clone(&dispatched);
1517 let invoke = move |_name: &str, _input: ToolInput| {
1518 counted.fetch_add(1, Ordering::SeqCst);
1519 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1520 };
1521 let gate = |call: &PlannedCall<'_>| match call {
1522 PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1523 _ => None,
1524 };
1525 let plan = write_plan(
1526 dir.path(),
1527 json!({
1528 "id": "p",
1529 "steps": [
1530 {"id": "refused", "tool": "a", "on_error": "continue"},
1531 {"id": "allowed", "tool": "b"}
1532 ]
1533 }),
1534 );
1535
1536 let report = execute(
1537 &plan,
1538 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1539 );
1540 let refused = report
1541 .results
1542 .iter()
1543 .find(|result| result.step_id == "refused")
1544 .expect("the refused step settled");
1545 assert_eq!(refused.status, StepStatus::Failed);
1546 assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1547 assert_eq!(refused.output, None);
1548 assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1550 }
1551
1552 #[test]
1555 fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1556 if crate::cgroup::skip_without_host_support(
1557 "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1558 ) {
1559 return;
1560 }
1561 let dir = tempdir().unwrap();
1562 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1563 let plan = write_plan(
1564 dir.path(),
1565 json!({
1566 "id": "p",
1567 "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1568 }),
1569 );
1570
1571 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1572 assert!(!report.completed);
1573 assert_eq!(report.results[0].status, StepStatus::Failed);
1574 assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1575 }
1576
1577 #[test]
1579 fn a_refused_shell_step_starts_no_subprocess() {
1580 if crate::cgroup::skip_without_host_support(
1581 "plan::tests::a_refused_shell_step_starts_no_subprocess",
1582 ) {
1583 return;
1584 }
1585 let dir = tempdir().unwrap();
1586 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1587 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1588 let recorded = Arc::clone(&seen);
1589 let gate = move |call: &PlannedCall<'_>| match call {
1590 PlannedCall::Shell { binary, argv, .. } => {
1591 recorded
1592 .lock()
1593 .unwrap()
1594 .push((binary.to_string(), argv.to_vec()));
1595 Some("refused by the manifest".to_string())
1596 }
1597 _ => None,
1598 };
1599 let marker = dir.path().join("written-by-a-refused-step");
1600 let plan = write_plan(
1601 dir.path(),
1602 json!({
1603 "id": "p",
1604 "steps": [{
1605 "id": "w",
1606 "shell": format!("bash -c 'printf x > {}'", marker.display()),
1607 }]
1608 }),
1609 );
1610
1611 let report = execute(
1612 &plan,
1613 &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
1614 );
1615 assert!(!report.completed);
1616 assert_eq!(report.failed_step.as_deref(), Some("w"));
1617 assert_eq!(
1618 report.results[0].error.as_deref(),
1619 Some("refused by the manifest")
1620 );
1621 assert!(!marker.exists(), "a refused step must not have run");
1622 let seen = seen.lock().unwrap();
1624 assert_eq!(seen.len(), 1);
1625 assert_eq!(seen[0].0, "bash");
1626 assert_eq!(seen[0].1[0], "-c");
1627 }
1628
1629 #[test]
1630 fn dependent_step_receives_upstream_output() {
1631 if crate::cgroup::skip_without_host_support(
1632 "plan::tests::dependent_step_receives_upstream_output",
1633 ) {
1634 return;
1635 }
1636 let dir = tempdir().unwrap();
1637 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1638 let seen_tool = Arc::clone(&seen);
1639 let invoke = move |_name: &str, input: ToolInput| {
1640 seen_tool
1641 .lock()
1642 .unwrap()
1643 .push(input.data.unwrap_or_default());
1644 Ok(tool_result(
1645 ToolStatus::Passed,
1646 Some("upstream".to_string()),
1647 ))
1648 };
1649 let plan = write_plan(
1650 dir.path(),
1651 json!({
1652 "id": "p",
1653 "steps": [
1654 {"id": "a", "tool": "ok"},
1655 {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
1656 ]
1657 }),
1658 );
1659
1660 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1661 assert!(report.completed);
1662 assert_eq!(report.results.len(), 2);
1663 assert!(seen.lock().unwrap()[1].contains("upstream"));
1664 }
1665
1666 #[test]
1667 fn parallel_tool_steps_run_concurrently() {
1668 if crate::cgroup::skip_without_host_support(
1669 "plan::tests::parallel_tool_steps_run_concurrently",
1670 ) {
1671 return;
1672 }
1673 let dir = tempdir().unwrap();
1674 let active = Arc::new(AtomicUsize::new(0));
1675 let max_active = Arc::new(AtomicUsize::new(0));
1676 let invoke_active = Arc::clone(&active);
1677 let invoke_max = Arc::clone(&max_active);
1678 let invoke = move |_name: &str, _input: ToolInput| {
1679 let now = invoke_active.fetch_add(1, Ordering::SeqCst) + 1;
1680 invoke_max.fetch_max(now, Ordering::SeqCst);
1681 thread::sleep(Duration::from_millis(150));
1682 invoke_active.fetch_sub(1, Ordering::SeqCst);
1683 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1684 };
1685 let plan = write_plan(
1686 dir.path(),
1687 json!({
1688 "id": "p",
1689 "steps": [
1690 {"id": "a", "tool": "slow"},
1691 {"id": "b", "tool": "slow"}
1692 ]
1693 }),
1694 );
1695
1696 let started = Instant::now();
1697 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1698 assert!(report.completed);
1699 assert!(started.elapsed() < Duration::from_millis(280));
1700 assert_eq!(max_active.load(Ordering::SeqCst), 2);
1701 }
1702
1703 #[test]
1704 fn on_error_and_retries_are_applied() {
1705 if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
1706 {
1707 return;
1708 }
1709 let dir = tempdir().unwrap();
1710 let calls = Arc::new(AtomicUsize::new(0));
1711 let invoke_calls = Arc::clone(&calls);
1712 let invoke = move |name: &str, _input: ToolInput| {
1713 invoke_calls.fetch_add(1, Ordering::SeqCst);
1714 if name == "a" {
1715 Ok(tool_result(ToolStatus::Failed, None))
1716 } else {
1717 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1718 }
1719 };
1720 let plan = write_plan(
1721 dir.path(),
1722 json!({
1723 "id": "p",
1724 "steps": [
1725 {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
1726 {"id": "b", "tool": "b", "depends_on": ["a"]}
1727 ]
1728 }),
1729 );
1730
1731 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1732 assert!(report.completed);
1733 assert_eq!(calls.load(Ordering::SeqCst), 4);
1734 }
1735
1736 #[test]
1737 fn condition_false_skips_step() {
1738 if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
1739 return;
1740 }
1741 let dir = tempdir().unwrap();
1742 let invoke = |_name: &str, _input: ToolInput| {
1743 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1744 };
1745 let plan = write_plan(
1746 dir.path(),
1747 json!({
1748 "id": "p",
1749 "steps": [
1750 {"id": "a", "tool": "ok"},
1751 {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
1752 ]
1753 }),
1754 );
1755
1756 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1757 assert!(report.completed);
1758 assert_eq!(report.results[1].status, StepStatus::Skipped);
1759 }
1760}