1use std::{
2 collections::{HashMap, HashSet},
3 path::Path,
4 sync::Arc,
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::{
17 DelegationLaunch, DelegationOrigin, DelegationPlane, DelegationRequest, DelegationStatus,
18 },
19 errors::RuntimeError,
20 network_namespace::SessionNetns,
21 resource_plane::{self, SymlinkPolicy},
22 sandbox,
23 shell::{execute_shell, split_shell_words},
24 spawn_credential::SpawnCredential,
25 trace::{PlanEndRecord, PlanStepRecord, PlanStepShape, PlanTraceAppender},
26 types::CapabilityPolicy,
27 workdir_fs::WorkdirRoot,
28};
29
30pub mod condition;
31
32#[derive(Debug, Deserialize)]
33struct PlanFile {
34 id: String,
35 steps: Vec<StepDef>,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39struct StepDef {
40 id: String,
41 tool: Option<String>,
42 shell: Option<String>,
43 capsule: Option<String>,
44 #[serde(default)]
45 input: Value,
46 #[serde(default)]
47 depends_on: Vec<String>,
48 #[serde(rename = "if")]
49 condition: Option<String>,
50 #[serde(default = "default_on_error")]
51 on_error: String,
52 #[serde(default)]
53 retries: u32,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum StepKind {
58 Tool,
59 Shell,
60 Spawn,
61}
62
63impl StepKind {
64 fn as_str(self) -> &'static str {
67 match self {
68 StepKind::Tool => "tool",
69 StepKind::Shell => "shell",
70 StepKind::Spawn => "capsule",
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum StepStatus {
77 Success,
78 Failed,
79 Skipped,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct StepResult {
84 pub step_id: String,
85 pub status: StepStatus,
86 pub output: Option<String>,
87 pub error: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ExecutionReport {
92 pub results: Vec<StepResult>,
93 pub completed: bool,
94 pub failed_step: Option<String>,
96 pub canceled: bool,
99}
100
101pub const CANCELED_STEP_ERROR: &str =
104 "the task running this plan was cancelled; this step's sub-capsule was ended with it";
105
106pub const CANCELED_BEFORE_STEP_ERROR: &str =
108 "the task running this plan was cancelled before this step ran";
109
110pub const PLAN_CANCELED_REASON: &str = "the task running this plan was cancelled";
112
113pub trait PlanTask: Sync {
121 fn is_canceled(&self) -> bool;
123 fn delegation_started(&self, launch: &DelegationLaunch);
126 #[must_use]
129 fn delegation_settled(&self, delegation_id: &str) -> bool;
130}
131
132pub struct SchedulerContext<'a> {
133 pub accessible_workdir: WorkdirRoot,
136 pub runtime_dir: crate::runtime_dir::RuntimeDirRoots,
141 pub capability_policy: CapabilityPolicy,
142 pub installed_tools: HashSet<String>,
143 pub capsule_versions: HashMap<String, String>,
144 pub current_session_id: Option<String>,
145 pub current_context_id: Option<String>,
152 pub registry: std::sync::Arc<dyn murmur_artifact::Registry>,
156 pub spawn_credential: Option<SpawnCredential>,
163 pub formation_id: Option<crate::formation::FormationId>,
166 pub deadline: Option<crate::session_status::SessionDeadline>,
169 pub trace: Option<&'a PlanTraceAppender>,
180 pub gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
192 pub invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
193 pub task: Option<&'a dyn PlanTask>,
200}
201
202pub enum PlannedCall<'a> {
208 Tool {
209 name: &'a str,
210 input_json: &'a str,
212 },
213 Shell {
214 binary: &'a str,
216 command: &'a str,
218 argv: &'a [String],
221 },
222}
223
224pub fn execute(plan_path: &Path, ctx: &SchedulerContext<'_>) -> ExecutionReport {
227 execute_with_netns(plan_path, ctx, None)
228}
229
230pub(crate) fn execute_in_session(
234 plan_path: &Path,
235 ctx: &SchedulerContext<'_>,
236 session_netns: Arc<SessionNetns>,
237) -> ExecutionReport {
238 execute_with_netns(plan_path, ctx, Some(session_netns))
239}
240
241fn execute_with_netns(
242 plan_path: &Path,
243 ctx: &SchedulerContext<'_>,
244 session_netns: Option<Arc<SessionNetns>>,
245) -> ExecutionReport {
246 let started = Instant::now();
247 match execute_inner(plan_path, ctx, started, session_netns) {
248 Ok(report) => report,
249 Err((failed_step, error)) => {
252 if let Some(appender) = ctx.trace {
253 appender.write_plan_end(
254 None,
255 PlanEndRecord {
256 plan_id: String::new(),
257 outcome: "failed",
258 failed_step: Some(failed_step.clone()),
259 steps_total: 0,
260 steps_succeeded: 0,
261 steps_failed: 0,
262 steps_skipped: 0,
263 duration_ms: elapsed_ms(started),
264 reason: Some(error.clone()),
265 },
266 );
267 }
268 ExecutionReport {
269 results: vec![StepResult {
270 step_id: failed_step.clone(),
271 status: StepStatus::Failed,
272 output: None,
273 error: Some(error),
274 }],
275 completed: false,
276 failed_step: Some(failed_step),
277 canceled: false,
278 }
279 }
280 }
281}
282
283fn execute_inner(
284 plan_path: &Path,
285 ctx: &SchedulerContext<'_>,
286 started: Instant,
287 session_netns: Option<Arc<SessionNetns>>,
288) -> Result<ExecutionReport, (String, String)> {
289 let runtime_root = &ctx.runtime_dir.runtime;
292 let relative = plan_path
293 .strip_prefix(runtime_root.path())
294 .unwrap_or(plan_path);
295 let raw = match runtime_root.read_to_string(relative) {
296 Ok(Some(raw)) => raw,
297 Ok(None) => {
298 return Err((
299 "plan".to_string(),
300 format!(
301 "failed to read plan file {}: it does not exist",
302 plan_path.display()
303 ),
304 ))
305 }
306 Err(error) => {
307 return Err((
308 "plan".to_string(),
309 format!("failed to read plan file: {error}"),
310 ))
311 }
312 };
313 let plan: PlanFile = serde_json::from_str(&raw).map_err(|error| {
314 (
315 "plan".to_string(),
316 format!("failed to parse plan JSON: {error}"),
317 )
318 })?;
319
320 let plan_node = ctx
324 .trace
325 .map(|appender| appender.write_plan_start(&plan.id, plan_shape(&plan)));
326
327 if let Err((step_id, error)) = validate_plan(&plan, ctx) {
328 trace_plan_end(
329 ctx,
330 plan_node.as_deref(),
331 PlanEndRecord {
332 plan_id: plan.id.clone(),
333 outcome: "failed",
334 failed_step: Some(step_id.clone()),
335 steps_total: plan.steps.len(),
336 steps_succeeded: 0,
337 steps_failed: 0,
338 steps_skipped: 0,
339 duration_ms: elapsed_ms(started),
340 reason: Some(error.clone()),
341 },
342 );
343 return Ok(ExecutionReport {
344 results: vec![StepResult {
345 step_id: step_id.clone(),
346 status: StepStatus::Failed,
347 output: None,
348 error: Some(error),
349 }],
350 completed: false,
351 failed_step: Some(step_id.clone()),
352 canceled: false,
353 });
354 }
355
356 let cgroup_scope = match crate::cgroup::prepare_scope(
362 crate::cgroup::requires_process_bounding(&ctx.capability_policy, false),
363 &ctx.capability_policy.resources,
364 &plan.id,
365 ctx.accessible_workdir.path(),
366 ) {
367 Ok(prepared) => prepared.scope,
373 Err(reason) => {
374 let error =
375 crate::errors::RuntimeError::CgroupDelegationUnavailable { reason }.to_string();
376 return Ok(refused_before_any_step(
377 &plan,
378 ctx,
379 plan_node.as_deref(),
380 started,
381 error,
382 ));
383 }
384 };
385 let workdir_guard = Some(crate::resources::WorkdirGuard::spawn(
386 &ctx.accessible_workdir,
387 ctx.runtime_dir.temp.as_ref(),
388 ctx.runtime_dir.home.as_ref(),
389 ctx.runtime_dir.records.as_ref(),
390 ctx.capability_policy.resources.workdir_max_bytes,
391 ));
392
393 let probe = sandbox::HostProbe::probe();
397 let mount_namespace_usable = probe.mount_namespace_usable();
398 let decision = crate::containment::ContainmentDecision::decide(
401 probe.tier(),
402 ctx.capability_policy.containment_floor,
403 ctx.capability_policy.workdir_exec_allowed,
404 false,
405 );
406 let shell_enforcement =
407 match sandbox::ShellEnforcement::resolve(&ctx.capability_policy, &decision, probe) {
408 Ok(enforcement) => {
409 let temp_mapping = crate::temp_dir::temp_mapping(
410 enforcement.tier,
411 mount_namespace_usable,
412 ctx.accessible_workdir.path(),
413 );
414 enforcement
415 .with_host_bounding(cgroup_scope, workdir_guard)
416 .with_runtime_dir(ctx.runtime_dir.clone(), None)
417 .with_temp_mapping(temp_mapping)
418 }
419 Err(error) => {
420 let error =
421 format!("failed to resolve shell subprocess sandbox enforcement: {error}");
422 return Ok(refused_before_any_step(
423 &plan,
424 ctx,
425 plan_node.as_deref(),
426 started,
427 error,
428 ));
429 }
430 };
431 let owns_netns = session_netns.is_none();
432 let shell_enforcement = match session_netns {
433 Some(session_netns) => shell_enforcement.with_session_netns(session_netns),
434 None => shell_enforcement,
435 };
436 let _plan_end = PlanEnd {
438 enforcement: &shell_enforcement,
439 owns_netns,
440 };
441
442 let mut results = HashMap::<String, StepResult>::new();
443 let mut result_order = Vec::<String>::new();
444 let mut failed_step = None;
445 let mut canceled = false;
446
447 loop {
448 if canceled {
449 for step in &plan.steps {
452 if results.contains_key(&step.id) {
453 continue;
454 }
455 trace_undispatched(
456 ctx,
457 plan_node.as_deref(),
458 &plan.id,
459 step,
460 "skipped",
461 Some(CANCELED_BEFORE_STEP_ERROR.to_string()),
462 );
463 insert_result(
464 &mut results,
465 &mut result_order,
466 StepResult {
467 step_id: step.id.clone(),
468 status: StepStatus::Skipped,
469 output: None,
470 error: Some(CANCELED_BEFORE_STEP_ERROR.to_string()),
471 },
472 );
473 }
474 break;
475 }
476 if results.len() == plan.steps.len() || failed_step.is_some() {
477 break;
478 }
479 if task_canceled(ctx) {
480 canceled = true;
481 continue;
482 }
483
484 let mut ready = Vec::new();
485 let mut progressed = false;
486
487 for step in &plan.steps {
488 if results.contains_key(&step.id) {
489 continue;
490 }
491 if !step.depends_on.iter().all(|dep| results.contains_key(dep)) {
492 continue;
493 }
494
495 match condition::evaluate(step.condition.as_deref(), &results) {
496 Ok(true) => ready.push(step.clone()),
497 Ok(false) => {
498 trace_undispatched(ctx, plan_node.as_deref(), &plan.id, step, "skipped", None);
501 insert_result(
502 &mut results,
503 &mut result_order,
504 StepResult {
505 step_id: step.id.clone(),
506 status: StepStatus::Skipped,
507 output: None,
508 error: None,
509 },
510 );
511 progressed = true;
512 }
513 Err(error) => {
514 trace_undispatched(
515 ctx,
516 plan_node.as_deref(),
517 &plan.id,
518 step,
519 "failed",
520 Some(error.clone()),
521 );
522 insert_result(
523 &mut results,
524 &mut result_order,
525 StepResult {
526 step_id: step.id.clone(),
527 status: StepStatus::Failed,
528 output: None,
529 error: Some(error),
530 },
531 );
532 failed_step = Some(step.id.clone());
533 progressed = true;
534 break;
535 }
536 }
537 }
538
539 if failed_step.is_some() {
540 break;
541 }
542
543 if ready.is_empty() {
544 if progressed {
545 continue;
546 }
547 const BLOCKED: &str = "plan is blocked by a dependency cycle or missing dependency";
548 let blocked_step = plan
549 .steps
550 .iter()
551 .find(|step| !results.contains_key(&step.id));
552 let blocked = blocked_step
553 .map(|step| step.id.clone())
554 .unwrap_or_else(|| "plan".to_string());
555 if let Some(step) = blocked_step {
556 trace_undispatched(
557 ctx,
558 plan_node.as_deref(),
559 &plan.id,
560 step,
561 "failed",
562 Some(BLOCKED.to_string()),
563 );
564 }
565 insert_result(
566 &mut results,
567 &mut result_order,
568 StepResult {
569 step_id: blocked.clone(),
570 status: StepStatus::Failed,
571 output: None,
572 error: Some(BLOCKED.to_string()),
573 },
574 );
575 failed_step = Some(blocked);
576 break;
577 }
578
579 for step in &ready {
580 trace_step_start(ctx, plan_node.as_deref(), &plan.id, step);
581 }
582
583 let mut completed = Vec::new();
584 thread::scope(|scope| {
585 let mut handles = Vec::new();
586 for step in ready {
587 let snapshot = results.clone();
588 let shell_enforcement = &shell_enforcement;
589 handles.push(scope.spawn(move || {
590 execute_step_with_retries(&step, ctx, &snapshot, shell_enforcement)
591 }));
592 }
593 for handle in handles {
594 completed.push(handle.join().unwrap_or_else(|_| {
595 StepOutcome::undispatched(failed("unknown", "step dispatch thread panicked"))
596 }));
597 }
598 });
599 canceled = task_canceled(ctx);
603
604 for outcome in completed {
605 let Some(step) = plan
606 .steps
607 .iter()
608 .find(|step| step.id == outcome.result.step_id)
609 else {
610 trace_step(
612 ctx,
613 plan_node.as_deref(),
614 PlanStepRecord {
615 plan_id: plan.id.clone(),
616 step_id: outcome.result.step_id.clone(),
617 kind: "unknown",
618 status: outcome.result.status.as_str(),
619 attempts: outcome.attempts,
620 duration_ms: outcome.duration_ms,
621 error: outcome.result.error.clone(),
622 input: None,
623 state_effect: None,
624 resource_id: None,
625 },
626 );
627 insert_result(&mut results, &mut result_order, outcome.result);
628 if !canceled {
629 failed_step = Some("unknown".to_string());
630 break;
631 }
632 continue;
633 };
634
635 let kind = step_kind_name(step);
636 let StepOutcome {
637 result,
638 attempts,
639 duration_ms,
640 input,
641 state_effect,
642 resource_id,
643 } = outcome;
644
645 let (settled, stops) = if result.status == StepStatus::Failed && !canceled {
648 match step.on_error.as_str() {
649 "fail" => (result, true),
650 "skip" => (
651 StepResult {
652 step_id: result.step_id,
653 status: StepStatus::Skipped,
654 output: None,
655 error: result.error,
656 },
657 false,
658 ),
659 "continue" => (result, false),
660 policy => (
661 StepResult {
662 error: Some(format!("invalid on_error policy '{policy}'")),
663 ..result
664 },
665 true,
666 ),
667 }
668 } else {
669 (result, false)
670 };
671
672 trace_step(
673 ctx,
674 plan_node.as_deref(),
675 PlanStepRecord {
676 plan_id: plan.id.clone(),
677 step_id: settled.step_id.clone(),
678 kind,
679 status: settled.status.as_str(),
680 attempts,
681 duration_ms,
682 error: settled.error.clone(),
683 input,
684 state_effect,
685 resource_id,
686 },
687 );
688
689 let id = settled.step_id.clone();
690 insert_result(&mut results, &mut result_order, settled);
691 if stops {
692 failed_step = Some(id);
693 break;
694 }
695 }
696 }
697
698 let ordered_results = result_order
699 .into_iter()
700 .filter_map(|id| results.remove(&id))
701 .collect::<Vec<_>>();
702
703 if canceled {
704 failed_step = None;
705 }
706 trace_plan_end(
707 ctx,
708 plan_node.as_deref(),
709 PlanEndRecord {
710 plan_id: plan.id.clone(),
711 outcome: if canceled {
712 "canceled"
713 } else if failed_step.is_none() {
714 "completed"
715 } else {
716 "failed"
717 },
718 failed_step: failed_step.clone(),
719 steps_total: plan.steps.len(),
720 steps_succeeded: count_status(&ordered_results, StepStatus::Success),
721 steps_failed: count_status(&ordered_results, StepStatus::Failed),
722 steps_skipped: count_status(&ordered_results, StepStatus::Skipped),
723 duration_ms: elapsed_ms(started),
724 reason: canceled.then(|| PLAN_CANCELED_REASON.to_string()),
725 },
726 );
727
728 Ok(ExecutionReport {
729 results: ordered_results,
730 completed: !canceled && failed_step.is_none(),
731 failed_step,
732 canceled,
733 })
734}
735
736struct PlanEnd<'a> {
742 enforcement: &'a sandbox::ShellEnforcement,
743 owns_netns: bool,
744}
745
746impl Drop for PlanEnd<'_> {
747 fn drop(&mut self) {
748 self.enforcement.kill_session_processes();
749 if self.owns_netns {
750 self.enforcement.session_netns.end();
751 }
752 }
753}
754
755fn step_kind_name(step: &StepDef) -> &'static str {
761 infer_kind(step).map(StepKind::as_str).unwrap_or("unknown")
762}
763
764fn plan_shape(plan: &PlanFile) -> Vec<PlanStepShape> {
766 plan.steps
767 .iter()
768 .map(|step| PlanStepShape {
769 step_id: step.id.clone(),
770 kind: step_kind_name(step),
771 depends_on: step.depends_on.clone(),
772 has_condition: step.condition.is_some(),
773 })
774 .collect()
775}
776
777fn trace_step_start(
778 ctx: &SchedulerContext<'_>,
779 plan_node: Option<&str>,
780 plan_id: &str,
781 step: &StepDef,
782) {
783 if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
784 appender.write_plan_step_start(
785 node,
786 plan_id,
787 &step.id,
788 step_kind_name(step),
789 step.depends_on.clone(),
790 );
791 }
792}
793
794fn trace_step(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanStepRecord) {
795 if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
796 appender.write_plan_step(node, record);
797 }
798}
799
800fn trace_undispatched(
804 ctx: &SchedulerContext<'_>,
805 plan_node: Option<&str>,
806 plan_id: &str,
807 step: &StepDef,
808 status: &'static str,
809 error: Option<String>,
810) {
811 trace_step(
812 ctx,
813 plan_node,
814 PlanStepRecord {
815 plan_id: plan_id.to_string(),
816 step_id: step.id.clone(),
817 kind: step_kind_name(step),
818 status,
819 attempts: 0,
820 duration_ms: 0,
821 error,
822 input: None,
823 state_effect: None,
824 resource_id: None,
825 },
826 );
827}
828
829fn trace_plan_end(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanEndRecord) {
830 if let Some(appender) = ctx.trace {
831 appender.write_plan_end(plan_node, record);
832 }
833}
834
835fn refused_before_any_step(
839 plan: &PlanFile,
840 ctx: &SchedulerContext<'_>,
841 plan_node: Option<&str>,
842 started: Instant,
843 error: String,
844) -> ExecutionReport {
845 trace_plan_end(
846 ctx,
847 plan_node,
848 PlanEndRecord {
849 plan_id: plan.id.clone(),
850 outcome: "failed",
851 failed_step: Some("plan".to_string()),
852 steps_total: plan.steps.len(),
853 steps_succeeded: 0,
854 steps_failed: 0,
855 steps_skipped: 0,
856 duration_ms: elapsed_ms(started),
857 reason: Some(error.clone()),
858 },
859 );
860 ExecutionReport {
861 results: vec![StepResult {
862 step_id: "plan".to_string(),
863 status: StepStatus::Failed,
864 output: None,
865 error: Some(error),
866 }],
867 completed: false,
868 failed_step: Some("plan".to_string()),
869 canceled: false,
870 }
871}
872
873fn task_canceled(ctx: &SchedulerContext<'_>) -> bool {
875 ctx.task.is_some_and(PlanTask::is_canceled)
876}
877
878fn count_status(results: &[StepResult], status: StepStatus) -> usize {
879 results
880 .iter()
881 .filter(|result| result.status == status)
882 .count()
883}
884
885fn elapsed_ms(started: Instant) -> u64 {
886 started.elapsed().as_millis() as u64
887}
888
889fn validate_plan(plan: &PlanFile, ctx: &SchedulerContext<'_>) -> Result<(), (String, String)> {
890 let mut ids = HashSet::new();
891 for step in &plan.steps {
892 if !ids.insert(step.id.clone()) {
893 return Err((step.id.clone(), format!("duplicate step id '{}'", step.id)));
894 }
895
896 match infer_kind(step) {
897 Ok(StepKind::Tool) => {
898 let tool = step.tool.as_ref().unwrap();
899 if tool == crate::runtime::SUBMIT_PLAN_TOOL {
903 return Err((
904 step.id.clone(),
905 format!(
906 "a plan step cannot submit another plan: '{tool}' is not callable \
907 from inside a plan"
908 ),
909 ));
910 }
911 if tool == crate::runtime::SWITCH_DRIVER_TOOL
916 || tool == crate::runtime::END_WITHOUT_ANSWER_TOOL
917 || tool == crate::runtime::PULL_ARTIFACT_TOOL
918 || tool == crate::runtime::REMOVE_ARTIFACT_TOOL
919 {
920 return Err((
921 step.id.clone(),
922 format!("'{tool}' is not callable from inside a plan"),
923 ));
924 }
925 if !ctx.installed_tools.contains(tool) {
926 return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
927 }
928 }
929 Ok(StepKind::Shell) => {
930 let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
931 return Err((step.id.clone(), "shell command is empty".to_string()));
932 };
933 if !ctx.capability_policy.shell_allow.contains(&binary) {
934 return Err((
935 step.id.clone(),
936 format!("binary '{binary}' is not in capabilities.shell.allow"),
937 ));
938 }
939 }
940 Ok(StepKind::Spawn) => {
941 if let Err(error) = capsule_task_text(&step.input) {
948 return Err((step.id.clone(), error));
949 }
950 }
951 Err(error) => return Err((step.id.clone(), error)),
952 }
953
954 if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
955 return Err((
956 step.id.clone(),
957 format!("invalid on_error policy '{}'", step.on_error),
958 ));
959 }
960 }
961
962 for step in &plan.steps {
963 for dep in &step.depends_on {
964 if !ids.contains(dep) {
965 return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
966 }
967 }
968 validate_references(step, &ids)?;
969 }
970
971 Ok(())
972}
973
974fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
975 let mut references = Vec::new();
976 collect_references(&step.input, &mut references);
977 if let Some(condition) = &step.condition {
978 for token in condition.split_whitespace() {
979 if token.starts_with('$') {
980 references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
981 }
982 }
983 }
984
985 for reference in references {
986 if let Some((step_id, field)) = parse_reference(&reference) {
987 if !ids.contains(step_id) {
988 return Err((step.id.clone(), format!("unknown reference '{reference}'")));
989 }
990 if field != "output" && field != "status" {
991 return Err((
992 step.id.clone(),
993 format!("unknown reference field '{field}'"),
994 ));
995 }
996 }
997 }
998
999 Ok(())
1000}
1001
1002fn collect_references(value: &Value, references: &mut Vec<String>) {
1003 match value {
1004 Value::String(value) if value.starts_with('$') => references.push(value.clone()),
1005 Value::Array(values) => values
1006 .iter()
1007 .for_each(|value| collect_references(value, references)),
1008 Value::Object(map) => map
1009 .values()
1010 .for_each(|value| collect_references(value, references)),
1011 _ => {}
1012 }
1013}
1014
1015struct StepOutcome {
1021 result: StepResult,
1022 attempts: u32,
1023 duration_ms: u64,
1024 input: Option<Value>,
1027 state_effect: Option<String>,
1030 resource_id: Option<String>,
1031}
1032
1033impl StepOutcome {
1034 fn plain(result: StepResult, started: Instant) -> Self {
1037 Self {
1038 result,
1039 attempts: 1,
1040 duration_ms: elapsed_ms(started),
1041 input: None,
1042 state_effect: None,
1043 resource_id: None,
1044 }
1045 }
1046
1047 fn undispatched(result: StepResult) -> Self {
1049 Self {
1050 result,
1051 attempts: 0,
1052 duration_ms: 0,
1053 input: None,
1054 state_effect: None,
1055 resource_id: None,
1056 }
1057 }
1058}
1059
1060fn execute_step_with_retries(
1061 step: &StepDef,
1062 ctx: &SchedulerContext<'_>,
1063 results: &HashMap<String, StepResult>,
1064 enforcement: &sandbox::ShellEnforcement,
1065) -> StepOutcome {
1066 let started = Instant::now();
1067 let attempts = step.retries.saturating_add(1);
1068 let mut last = None;
1069 for attempt in 1..=attempts {
1070 let mut outcome = execute_step_once(step, ctx, results, enforcement);
1071 outcome.attempts = attempt;
1072 outcome.duration_ms = elapsed_ms(started);
1073 if outcome.result.status != StepStatus::Failed || task_canceled(ctx) {
1075 return outcome;
1076 }
1077 last = Some(outcome);
1078 }
1079
1080 last.unwrap_or_else(|| StepOutcome::undispatched(failed(&step.id, "step was not attempted")))
1081}
1082
1083fn execute_step_once(
1084 step: &StepDef,
1085 ctx: &SchedulerContext<'_>,
1086 results: &HashMap<String, StepResult>,
1087 enforcement: &sandbox::ShellEnforcement,
1088) -> StepOutcome {
1089 let started = Instant::now();
1090 let mut input = step.input.clone();
1091 if let Err(error) = interpolate_value(&mut input, results) {
1092 return StepOutcome::plain(failed(&step.id, error), started);
1093 }
1094 let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
1095
1096 match infer_kind(step) {
1097 Ok(StepKind::Tool) => {
1098 let (result, state_effect, resource_id) = dispatch_tool_step(step, ctx, input_json);
1099 StepOutcome {
1100 result,
1101 attempts: 1,
1102 duration_ms: elapsed_ms(started),
1103 input: (!input.is_null()).then_some(input),
1106 state_effect,
1107 resource_id,
1108 }
1109 }
1110 Ok(StepKind::Shell) => {
1111 StepOutcome::plain(dispatch_shell_step(step, ctx, enforcement), started)
1112 }
1113 Ok(StepKind::Spawn) => StepOutcome::plain(dispatch_capsule_step(step, ctx, input), started),
1114 Err(error) => StepOutcome::plain(failed(&step.id, error), started),
1115 }
1116}
1117
1118fn dispatch_tool_step(
1122 step: &StepDef,
1123 ctx: &SchedulerContext<'_>,
1124 input_json: String,
1125) -> (StepResult, Option<String>, Option<String>) {
1126 let name = step.tool.as_deref().unwrap_or_default();
1127 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Tool {
1128 name,
1129 input_json: &input_json,
1130 }) {
1131 return (failed(&step.id, refusal), None, None);
1132 }
1133 match (ctx.invoke_tool)(
1134 name,
1135 ToolInput {
1136 data: Some(input_json),
1137 log_path: None,
1138 },
1139 ) {
1140 Ok(result) => {
1141 let state_effect = crate::agent::extract_state_effect(&result.metadata);
1142 let resource_id = crate::agent::extract_resource_id(&result.metadata);
1143 let verdict = if matches!(result.status, ToolStatus::Passed) {
1144 match tool_step_output(
1145 name,
1146 ctx.accessible_workdir.path(),
1147 result.data,
1148 result.data_path.as_deref(),
1149 ) {
1150 Ok(output) => StepResult {
1151 step_id: step.id.clone(),
1152 status: StepStatus::Success,
1153 output,
1154 error: None,
1155 },
1156 Err(error) => failed(&step.id, error),
1157 }
1158 } else {
1159 failed(
1160 &step.id,
1161 result
1162 .summary
1163 .or(result.data)
1164 .unwrap_or_else(|| "tool step failed".to_string()),
1165 )
1166 };
1167 (verdict, state_effect, resource_id)
1168 }
1169 Err(error) => (failed(&step.id, error), None, None),
1170 }
1171}
1172
1173fn dispatch_shell_step(
1174 step: &StepDef,
1175 ctx: &SchedulerContext<'_>,
1176 enforcement: &sandbox::ShellEnforcement,
1177) -> StepResult {
1178 let command = step.shell.as_deref().unwrap_or_default();
1179 let Some((binary, args)) = parse_shell_command(command) else {
1180 return failed(&step.id, "shell command is empty");
1181 };
1182 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Shell {
1183 binary: &binary,
1184 command,
1185 argv: &args,
1186 }) {
1187 return failed(&step.id, refusal);
1188 }
1189 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
1190
1191 match execute_shell(
1192 &binary,
1193 &arg_refs,
1194 &[],
1195 &ctx.accessible_workdir,
1196 &ctx.capability_policy,
1197 enforcement,
1198 ) {
1199 Ok(result) if result.exit_code == 0 => StepResult {
1200 step_id: step.id.clone(),
1201 status: StepStatus::Success,
1202 output: Some(result.stdout),
1203 error: None,
1204 },
1205 Ok(result) => match result.resource_limit_hit {
1210 Some(limit) => failed(
1211 &step.id,
1212 crate::errors::RuntimeError::ShellResourceLimitExceeded {
1213 binary: result.binary,
1214 limit,
1215 detail: if result.stderr.trim().is_empty() {
1216 format!("exit code {}", result.exit_code)
1217 } else {
1218 result.stderr
1219 },
1220 }
1221 .to_string(),
1222 ),
1223 None if result.stderr.trim().is_empty() => {
1228 failed(&step.id, format!("exit code {}", result.exit_code))
1229 }
1230 None => failed(&step.id, result.stderr),
1231 },
1232 Err(error) => failed(&step.id, error.to_string()),
1237 }
1238}
1239
1240fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1241 let named = step.capsule.as_deref().unwrap_or_default();
1242 let full = crate::tool_names::spawn_target(&ctx.capability_policy.spawn_allow, named);
1245 let capsule = full.as_str();
1246 let task = match capsule_task_text(&input) {
1249 Ok(task) => task,
1250 Err(error) => return failed(&step.id, error),
1251 };
1252 let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1253 Ok(value) if !value.trim().is_empty() => value,
1254 _ => {
1255 return failed(
1256 &step.id,
1257 "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1258 )
1259 }
1260 };
1261 let Some(credential) = ctx.spawn_credential.as_ref() else {
1262 return failed(
1263 &step.id,
1264 "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1265 mints it only for a session whose manifest declares capabilities.spawn.allow",
1266 );
1267 };
1268
1269 let Some(records_root) = ctx.runtime_dir.records.clone() else {
1276 return failed(
1277 &step.id,
1278 "this plan runs outside a session's records directory, where a capsule step's child \
1279 work folder is created",
1280 );
1281 };
1282 let plane = DelegationPlane::new(
1283 roost_url,
1284 credential.clone(),
1285 records_root,
1286 ctx.current_session_id.clone().unwrap_or_default(),
1287 crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1288 std::sync::Arc::clone(&ctx.registry),
1289 ctx.capability_policy.env_allow.clone(),
1290 ctx.formation_id.clone(),
1291 )
1292 .bounded_by(ctx.deadline);
1293 let (trace, plan_task) = (ctx.trace, ctx.task);
1299 let launched = (trace.is_some() || plan_task.is_some()).then(|| {
1300 std::sync::Arc::new(move |notice: DelegationLaunch| {
1301 if let Some(trace) = trace {
1302 trace.write_delegation_start(
1303 ¬ice.delegation_id,
1304 ¬ice.capsule,
1305 ¬ice.version,
1306 ¬ice.child_session_id,
1307 ¬ice.child_workdir,
1308 ¬ice.child_records_dir,
1309 );
1310 }
1311 if let Some(plan_task) = plan_task {
1312 plan_task.delegation_started(¬ice);
1313 }
1314 }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1315 });
1316 let stop = plan_task.map(|plan_task| {
1317 std::sync::Arc::new(move || plan_task.is_canceled())
1318 as std::sync::Arc<dyn Fn() -> bool + Send + Sync>
1319 });
1320 let started = Instant::now();
1321 let result = plane.delegate(
1322 &DelegationRequest {
1323 capsule: capsule.to_string(),
1324 version: ctx
1327 .capsule_versions
1328 .get(named)
1329 .cloned()
1330 .unwrap_or_else(|| "0.1.0".to_string()),
1331 task,
1332 },
1333 &DelegationOrigin {
1334 context_id: ctx.current_context_id.clone().unwrap_or_default(),
1335 launched,
1336 stop,
1337 },
1338 &ctx.runtime_dir.runtime,
1339 );
1340
1341 if result.status == DelegationStatus::Canceled {
1344 return failed(&step.id, CANCELED_STEP_ERROR);
1345 }
1346 let closes_row = match plan_task {
1349 Some(plan_task) if !result.delegation_id.is_empty() => {
1350 plan_task.delegation_settled(&result.delegation_id)
1351 }
1352 _ => true,
1353 };
1354 if let Some(trace) = ctx.trace.filter(|_| closes_row) {
1355 trace.write_delegation(
1356 &result.capsule,
1357 &result.version,
1358 Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1361 Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1362 elapsed_ms(started),
1363 result.status.as_str(),
1364 match result.status {
1365 DelegationStatus::Completed => None,
1366 _ => Some(result.output.clone()),
1367 },
1368 );
1369 }
1370
1371 match result.status {
1372 DelegationStatus::Completed => StepResult {
1373 step_id: step.id.clone(),
1374 status: StepStatus::Success,
1375 output: Some(result.output),
1376 error: None,
1377 },
1378 _ => failed(&step.id, result.output),
1379 }
1380}
1381
1382fn capsule_task_text(input: &Value) -> Result<String, String> {
1401 const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1402 const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1403 receives, given as a string or as { \"objective\": \"<text>\" }";
1404
1405 match input {
1406 Value::String(text) => Ok(text.clone()),
1407 Value::Object(map) => {
1408 let mut unknown = map
1411 .keys()
1412 .filter(|key| key.as_str() != "objective")
1413 .map(String::as_str)
1414 .collect::<Vec<_>>();
1415 unknown.sort_unstable();
1416 if !unknown.is_empty() {
1417 return Err(format!(
1418 "capsule step input has keys the child cannot receive: {}; nothing on the \
1419 receiving side parses a task envelope, so put the whole instruction in \
1420 \"objective\"",
1421 unknown.join(", ")
1422 ));
1423 }
1424
1425 match map.get("objective") {
1426 Some(Value::String(text)) => Ok(text.clone()),
1427 Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1428 None => Err(ACCEPTS.to_string()),
1429 }
1430 }
1431 Value::Null => Err(NO_INPUT.to_string()),
1432 Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1433 Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1434 Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1435 }
1436}
1437
1438fn interpolate_value(
1439 value: &mut Value,
1440 results: &HashMap<String, StepResult>,
1441) -> Result<(), String> {
1442 match value {
1443 Value::String(text) if text.starts_with('$') => {
1444 *text = condition::resolve_reference(text, results)?;
1445 }
1446 Value::Array(values) => {
1447 for value in values {
1448 interpolate_value(value, results)?;
1449 }
1450 }
1451 Value::Object(map) => {
1452 for value in map.values_mut() {
1453 interpolate_value(value, results)?;
1454 }
1455 }
1456 _ => {}
1457 }
1458
1459 Ok(())
1460}
1461
1462fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1463 let count =
1464 step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1465 match count {
1466 1 if step.tool.is_some() => Ok(StepKind::Tool),
1467 1 if step.shell.is_some() => Ok(StepKind::Shell),
1468 1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1469 0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1470 _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1471 }
1472}
1473
1474fn default_on_error() -> String {
1475 "fail".to_string()
1476}
1477
1478fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1479 StepResult {
1480 step_id: step_id.to_string(),
1481 status: StepStatus::Failed,
1482 output: None,
1483 error: Some(error.into()),
1484 }
1485}
1486
1487fn insert_result(
1488 results: &mut HashMap<String, StepResult>,
1489 order: &mut Vec<String>,
1490 result: StepResult,
1491) {
1492 order.push(result.step_id.clone());
1493 results.insert(result.step_id.clone(), result);
1494}
1495
1496fn shell_binary(command: &str) -> Option<String> {
1497 parse_shell_command(command).map(|(binary, _)| binary)
1498}
1499
1500fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1501 let parts = split_shell_words(command);
1502 let (binary, args) = parts.split_first()?;
1503 Some((binary.clone(), args.to_vec()))
1504}
1505
1506fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1507 reference.strip_prefix('$')?.rsplit_once('.')
1508}
1509
1510fn tool_step_output(
1527 tool: &str,
1528 workdir: &Path,
1529 data: Option<String>,
1530 data_path: Option<&str>,
1531) -> Result<Option<String>, String> {
1532 if let Some(data) = data {
1533 return Ok(Some(data));
1534 }
1535 let Some(relpath) = data_path else {
1536 return Ok(None);
1537 };
1538 match resource_plane::read_file_beneath_root(
1539 workdir,
1540 relpath,
1541 DEFAULT_EXPORT_MAX_BYTES,
1542 SymlinkPolicy::Refuse,
1543 ) {
1544 Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1545 Err(error) => Err(RuntimeError::ToolDataPathRefused {
1546 tool: tool.to_string(),
1547 path: relpath.to_string(),
1548 code: error.code().to_string(),
1549 detail: error.message(),
1550 }
1551 .to_string()),
1552 }
1553}
1554
1555#[cfg(test)]
1556mod tests {
1557 use std::{fs, path::PathBuf};
1558 use std::{
1559 sync::{
1560 atomic::{AtomicUsize, Ordering},
1561 Arc, Condvar, Mutex,
1562 },
1563 time::Duration,
1564 };
1565
1566 use serde_json::json;
1567 use tempfile::tempdir;
1568
1569 use super::*;
1570
1571 fn test_ctx<'a>(
1575 workdir: PathBuf,
1576 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1577 ) -> SchedulerContext<'a> {
1578 test_ctx_gated(workdir, invoke_tool, &UNGATED)
1579 }
1580
1581 const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1584
1585 fn test_ctx_gated<'a>(
1586 workdir: PathBuf,
1587 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1588 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1589 ) -> SchedulerContext<'a> {
1590 SchedulerContext {
1591 accessible_workdir: WorkdirRoot::open(&workdir).unwrap(),
1592 runtime_dir: crate::runtime_dir::RuntimeDirRoots::new(
1593 WorkdirRoot::open(&workdir).unwrap(),
1594 WorkdirRoot::open(&workdir).unwrap(),
1595 WorkdirRoot::open(&workdir).unwrap(),
1596 )
1597 .with_home(WorkdirRoot::open(&tempfile::tempdir().unwrap().keep()).unwrap()),
1599 capability_policy: CapabilityPolicy {
1600 shell_allow: vec!["bash".to_string()],
1601 spawn_allow: vec!["worker".to_string()],
1602 ..CapabilityPolicy::default()
1603 },
1604 installed_tools: HashSet::from([
1605 "ok".to_string(),
1606 "fail".to_string(),
1607 "slow".to_string(),
1608 "a".to_string(),
1609 "b".to_string(),
1610 ]),
1611 capsule_versions: HashMap::new(),
1612 current_session_id: None,
1613 current_context_id: None,
1614 registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1616 tempdir().unwrap().keep(),
1617 )),
1618 spawn_credential: None,
1619 formation_id: None,
1620 deadline: None,
1621 trace: None,
1622 gate_step,
1623 invoke_tool,
1624 task: None,
1625 }
1626 }
1627
1628 fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1629 ToolResult {
1630 status,
1631 summary: None,
1632 data,
1633 data_path: None,
1634 truncated: false,
1635 metadata: Vec::new(),
1636 }
1637 }
1638
1639 fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1640 let path = workdir.join("plan.json");
1641 fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1642 path
1643 }
1644
1645 #[test]
1649 fn a_turn_s_own_decisions_are_not_callable_from_a_plan() {
1650 let dir = tempdir().unwrap();
1651 let invoke = |_: &str, _: ToolInput| -> Result<ToolResult, String> {
1652 panic!("a refused plan ran a step")
1653 };
1654 let ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1655 for tool in [
1656 crate::runtime::SWITCH_DRIVER_TOOL,
1657 crate::runtime::END_WITHOUT_ANSWER_TOOL,
1658 crate::runtime::PULL_ARTIFACT_TOOL,
1659 crate::runtime::REMOVE_ARTIFACT_TOOL,
1660 ] {
1661 let plan: PlanFile = serde_json::from_value(json!({
1662 "id": "p", "steps": [{"id": "s1", "tool": tool, "input": {"reason": "none"}}],
1663 }))
1664 .unwrap();
1665 assert_eq!(
1666 validate_plan(&plan, &ctx).unwrap_err(),
1667 (
1668 "s1".to_string(),
1669 format!("'{tool}' is not callable from inside a plan")
1670 )
1671 );
1672 }
1673 }
1674
1675 #[test]
1676 fn capsule_task_text_delivers_a_bare_string_verbatim() {
1677 assert_eq!(
1678 capsule_task_text(&json!("do the thing")).unwrap(),
1679 "do the thing"
1680 );
1681 }
1682
1683 #[test]
1684 fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1685 assert_eq!(
1686 capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1687 "do the thing"
1688 );
1689 }
1690
1691 #[test]
1694 fn capsule_task_text_accepts_empty_task_text() {
1695 assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1696 assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1697 }
1698
1699 #[test]
1700 fn capsule_task_text_refuses_a_task_envelope() {
1701 let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1702 assert!(
1703 error.contains("keys the child cannot receive: task"),
1704 "{error}"
1705 );
1706 assert!(error.contains("\"objective\""), "{error}");
1707 }
1708
1709 #[test]
1710 fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1711 let error =
1712 capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1713 .unwrap_err();
1714 assert!(
1715 error.contains("keys the child cannot receive: instructions, render_as"),
1716 "{error}"
1717 );
1718 }
1719
1720 #[test]
1721 fn capsule_task_text_refuses_a_non_string_objective() {
1722 for input in [json!({"objective": 7}), json!({"objective": null})] {
1723 assert_eq!(
1724 capsule_task_text(&input).unwrap_err(),
1725 "capsule step input \"objective\" must be a string"
1726 );
1727 }
1728 }
1729
1730 #[test]
1731 fn capsule_task_text_refuses_an_object_without_an_objective() {
1732 assert_eq!(
1733 capsule_task_text(&json!({})).unwrap_err(),
1734 "capsule step input must be a string or { \"objective\": \"<text>\" }"
1735 );
1736 }
1737
1738 #[test]
1739 fn capsule_task_text_names_the_type_it_was_handed() {
1740 assert!(capsule_task_text(&json!(7))
1741 .unwrap_err()
1742 .ends_with("not a number"));
1743 assert!(capsule_task_text(&json!(true))
1744 .unwrap_err()
1745 .ends_with("not a boolean"));
1746 assert!(capsule_task_text(&json!(["a"]))
1747 .unwrap_err()
1748 .ends_with("not an array"));
1749 }
1750
1751 #[test]
1754 fn capsule_task_text_refuses_an_absent_input() {
1755 let error = capsule_task_text(&Value::Null).unwrap_err();
1756 assert_eq!(
1757 error,
1758 "capsule step has no input; its input is the task text the child receives, given as \
1759 a string or as { \"objective\": \"<text>\" }"
1760 );
1761 }
1762
1763 #[test]
1766 fn capsule_task_text_accepts_an_uninterpolated_reference() {
1767 assert_eq!(
1768 capsule_task_text(&json!("$analyse.output")).unwrap(),
1769 "$analyse.output"
1770 );
1771 assert_eq!(
1772 capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1773 "$analyse.output"
1774 );
1775 }
1776
1777 fn test_ctx_unbounded<'a>(
1780 workdir: PathBuf,
1781 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1782 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1783 ) -> SchedulerContext<'a> {
1784 SchedulerContext {
1785 capability_policy: CapabilityPolicy::default(),
1786 ..test_ctx_gated(workdir, invoke_tool, gate_step)
1787 }
1788 }
1789
1790 #[test]
1795 fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1796 let dir = tempdir().unwrap();
1797 let dispatched = Arc::new(AtomicUsize::new(0));
1798 let counted = Arc::clone(&dispatched);
1799 let invoke = move |_name: &str, _input: ToolInput| {
1800 counted.fetch_add(1, Ordering::SeqCst);
1801 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1802 };
1803 let gate = |call: &PlannedCall<'_>| match call {
1804 PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1805 _ => None,
1806 };
1807 let plan = write_plan(
1808 dir.path(),
1809 json!({
1810 "id": "p",
1811 "steps": [
1812 {"id": "refused", "tool": "a", "on_error": "continue"},
1813 {"id": "allowed", "tool": "b"}
1814 ]
1815 }),
1816 );
1817
1818 let report = execute(
1819 &plan,
1820 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1821 );
1822 let refused = report
1823 .results
1824 .iter()
1825 .find(|result| result.step_id == "refused")
1826 .expect("the refused step settled");
1827 assert_eq!(refused.status, StepStatus::Failed);
1828 assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1829 assert_eq!(refused.output, None);
1830 assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1832 }
1833
1834 #[test]
1837 fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1838 if crate::cgroup::skip_without_host_support(
1839 "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1840 ) {
1841 return;
1842 }
1843 let dir = tempdir().unwrap();
1844 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1845 let plan = write_plan(
1846 dir.path(),
1847 json!({
1848 "id": "p",
1849 "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1850 }),
1851 );
1852
1853 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1854 assert!(!report.completed);
1855 assert_eq!(report.results[0].status, StepStatus::Failed);
1856 assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1857 }
1858
1859 #[cfg(target_os = "linux")]
1863 #[test]
1864 #[allow(clippy::print_stdout)]
1865 fn a_plan_shell_step_joins_the_session_namespace() {
1866 if crate::cgroup::skip_without_host_support(
1867 "plan::tests::a_plan_shell_step_joins_the_session_namespace",
1868 ) {
1869 return;
1870 }
1871 let dir = tempdir().unwrap();
1872 let policy = CapabilityPolicy {
1873 shell_allow: vec!["bash".to_string(), "nc".to_string(), "sleep".to_string()],
1874 ..CapabilityPolicy::default()
1875 };
1876 let session = sandbox::ShellEnforcement::resolve_declared(
1877 &policy,
1878 murmur_artifact::ContainmentClass::Scoped,
1879 sandbox::HostProbe::probe(),
1880 )
1881 .unwrap()
1882 .with_test_session_dirs();
1883 let server = execute_shell(
1884 "bash",
1885 &[
1886 "-c",
1887 "(printf \"pong\\n\" | nc -N -l 127.0.0.1 8080) >/dev/null 2>&1 & echo $! > server.pid",
1888 ],
1889 &[],
1890 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1891 &policy,
1892 &session,
1893 )
1894 .expect("the session's server command runs");
1895 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
1896
1897 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1898 let mut ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1899 ctx.capability_policy.shell_allow = policy.shell_allow.clone();
1900 let plan = write_plan(
1901 dir.path(),
1902 json!({
1903 "id": "p",
1904 "steps": [{
1905 "id": "client",
1906 "shell": "bash -c 'for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 \
1907 2>/dev/null && break; sleep 0.1; done; read -r reply <&3; \
1908 echo \"$reply\"'",
1909 }]
1910 }),
1911 );
1912
1913 let report = execute_in_session(&plan, &ctx, Arc::clone(&session.session_netns));
1914 println!(
1915 "plan completed: {}; step {} {:?} output {:?} error {:?}; session namespaces \
1916 created: {}",
1917 report.completed,
1918 report.results[0].step_id,
1919 report.results[0].status,
1920 report.results[0].output.as_deref().map(str::trim),
1921 report.results[0].error,
1922 session.session_netns.creations()
1923 );
1924 assert!(report.completed, "{:?}", report.results);
1925 assert_eq!(report.results[0].status, StepStatus::Success);
1926 assert_eq!(
1927 report.results[0].output.as_deref().map(str::trim),
1928 Some("pong")
1929 );
1930 assert_eq!(session.session_netns.creations(), 1);
1931
1932 let after = execute_shell(
1934 "bash",
1935 &["-c", "true"],
1936 &[],
1937 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1938 &policy,
1939 &session,
1940 )
1941 .expect("the session outlives the plan it submitted");
1942 assert_eq!(after.exit_code, 0);
1943 session.end_session();
1944 }
1945
1946 #[test]
1948 fn a_refused_shell_step_starts_no_subprocess() {
1949 if crate::cgroup::skip_without_host_support(
1950 "plan::tests::a_refused_shell_step_starts_no_subprocess",
1951 ) {
1952 return;
1953 }
1954 let dir = tempdir().unwrap();
1955 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1956 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1957 let recorded = Arc::clone(&seen);
1958 let gate = move |call: &PlannedCall<'_>| match call {
1959 PlannedCall::Shell { binary, argv, .. } => {
1960 recorded
1961 .lock()
1962 .unwrap()
1963 .push((binary.to_string(), argv.to_vec()));
1964 Some("refused by the manifest".to_string())
1965 }
1966 _ => None,
1967 };
1968 let marker = dir.path().join("written-by-a-refused-step");
1969 let plan = write_plan(
1970 dir.path(),
1971 json!({
1972 "id": "p",
1973 "steps": [{
1974 "id": "w",
1975 "shell": format!("bash -c 'printf x > {}'", marker.display()),
1976 }]
1977 }),
1978 );
1979
1980 let report = execute(
1981 &plan,
1982 &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
1983 );
1984 assert!(!report.completed);
1985 assert_eq!(report.failed_step.as_deref(), Some("w"));
1986 assert_eq!(
1987 report.results[0].error.as_deref(),
1988 Some("refused by the manifest")
1989 );
1990 assert!(!marker.exists(), "a refused step must not have run");
1991 let seen = seen.lock().unwrap();
1993 assert_eq!(seen.len(), 1);
1994 assert_eq!(seen[0].0, "bash");
1995 assert_eq!(seen[0].1[0], "-c");
1996 }
1997
1998 #[test]
1999 fn dependent_step_receives_upstream_output() {
2000 if crate::cgroup::skip_without_host_support(
2001 "plan::tests::dependent_step_receives_upstream_output",
2002 ) {
2003 return;
2004 }
2005 let dir = tempdir().unwrap();
2006 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2007 let seen_tool = Arc::clone(&seen);
2008 let invoke = move |_name: &str, input: ToolInput| {
2009 seen_tool
2010 .lock()
2011 .unwrap()
2012 .push(input.data.unwrap_or_default());
2013 Ok(tool_result(
2014 ToolStatus::Passed,
2015 Some("upstream".to_string()),
2016 ))
2017 };
2018 let plan = write_plan(
2019 dir.path(),
2020 json!({
2021 "id": "p",
2022 "steps": [
2023 {"id": "a", "tool": "ok"},
2024 {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
2025 ]
2026 }),
2027 );
2028
2029 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2030 assert!(report.completed);
2031 assert_eq!(report.results.len(), 2);
2032 assert!(seen.lock().unwrap()[1].contains("upstream"));
2033 }
2034
2035 #[test]
2036 fn parallel_tool_steps_run_concurrently() {
2037 if crate::cgroup::skip_without_host_support(
2038 "plan::tests::parallel_tool_steps_run_concurrently",
2039 ) {
2040 return;
2041 }
2042 const RENDEZVOUS_GUARD: Duration = Duration::from_secs(30);
2045
2046 #[derive(Default)]
2049 struct Rendezvous {
2050 in_flight: usize,
2051 met: bool,
2052 }
2053
2054 let dir = tempdir().unwrap();
2055 let rendezvous = Arc::new((Mutex::new(Rendezvous::default()), Condvar::new()));
2056 let met_sibling = Arc::new(AtomicUsize::new(0));
2057 let invoke_rendezvous = Arc::clone(&rendezvous);
2058 let invoke_met = Arc::clone(&met_sibling);
2059 let invoke = move |_name: &str, _input: ToolInput| {
2060 let (state, changed) = &*invoke_rendezvous;
2061 let mut state = state.lock().unwrap();
2062 state.in_flight += 1;
2063 if state.in_flight == 2 {
2064 state.met = true;
2065 }
2066 changed.notify_all();
2067 let (mut state, _) = changed
2068 .wait_timeout_while(state, RENDEZVOUS_GUARD, |state| !state.met)
2069 .unwrap();
2070 if state.met {
2071 invoke_met.fetch_add(1, Ordering::SeqCst);
2072 }
2073 state.in_flight -= 1;
2074 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2075 };
2076 let plan = write_plan(
2077 dir.path(),
2078 json!({
2079 "id": "p",
2080 "steps": [
2081 {"id": "a", "tool": "slow"},
2082 {"id": "b", "tool": "slow"}
2083 ]
2084 }),
2085 );
2086
2087 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2088 assert!(report.completed);
2089 assert_eq!(
2090 met_sibling.load(Ordering::SeqCst),
2091 2,
2092 "the two independent steps never ran at the same time"
2093 );
2094 }
2095
2096 #[test]
2097 fn on_error_and_retries_are_applied() {
2098 if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
2099 {
2100 return;
2101 }
2102 let dir = tempdir().unwrap();
2103 let calls = Arc::new(AtomicUsize::new(0));
2104 let invoke_calls = Arc::clone(&calls);
2105 let invoke = move |name: &str, _input: ToolInput| {
2106 invoke_calls.fetch_add(1, Ordering::SeqCst);
2107 if name == "a" {
2108 Ok(tool_result(ToolStatus::Failed, None))
2109 } else {
2110 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2111 }
2112 };
2113 let plan = write_plan(
2114 dir.path(),
2115 json!({
2116 "id": "p",
2117 "steps": [
2118 {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
2119 {"id": "b", "tool": "b", "depends_on": ["a"]}
2120 ]
2121 }),
2122 );
2123
2124 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2125 assert!(report.completed);
2126 assert_eq!(calls.load(Ordering::SeqCst), 4);
2127 }
2128
2129 #[test]
2130 fn condition_false_skips_step() {
2131 if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
2132 return;
2133 }
2134 let dir = tempdir().unwrap();
2135 let invoke = |_name: &str, _input: ToolInput| {
2136 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2137 };
2138 let plan = write_plan(
2139 dir.path(),
2140 json!({
2141 "id": "p",
2142 "steps": [
2143 {"id": "a", "tool": "ok"},
2144 {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
2145 ]
2146 }),
2147 );
2148
2149 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2150 assert!(report.completed);
2151 assert_eq!(report.results[1].status, StepStatus::Skipped);
2152 }
2153
2154 #[derive(Default)]
2157 struct FlaggedTask(std::sync::atomic::AtomicBool);
2158
2159 impl PlanTask for FlaggedTask {
2160 fn is_canceled(&self) -> bool {
2161 self.0.load(Ordering::SeqCst)
2162 }
2163 fn delegation_started(&self, _launch: &DelegationLaunch) {}
2164 fn delegation_settled(&self, _delegation_id: &str) -> bool {
2165 true
2166 }
2167 }
2168
2169 #[test]
2172 fn a_cancelled_task_tries_no_step_again() {
2173 let dir = tempdir().unwrap();
2174 let task = FlaggedTask::default();
2175 let calls = AtomicUsize::new(0);
2176 let invoke = |_name: &str, _input: ToolInput| {
2177 calls.fetch_add(1, Ordering::SeqCst);
2178 task.0.store(true, Ordering::SeqCst);
2179 Ok(tool_result(ToolStatus::Failed, None))
2180 };
2181 let plan = write_plan(
2182 dir.path(),
2183 json!({
2184 "id": "p",
2185 "steps": [
2186 {"id": "a", "tool": "a", "retries": 2, "on_error": "fail"},
2187 {"id": "b", "tool": "b", "depends_on": ["a"]}
2188 ]
2189 }),
2190 );
2191
2192 let ctx = SchedulerContext {
2193 task: Some(&task),
2194 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2195 };
2196 let report = execute(&plan, &ctx);
2197
2198 assert_eq!(calls.load(Ordering::SeqCst), 1, "{report:?}");
2199 assert!(report.canceled, "{report:?}");
2200 assert!(!report.completed);
2201 assert_eq!(report.failed_step, None);
2202 assert_eq!(report.results[0].status, StepStatus::Failed);
2203 assert_eq!(report.results[1].status, StepStatus::Skipped);
2204 assert_eq!(
2205 report.results[1].error.as_deref(),
2206 Some(CANCELED_BEFORE_STEP_ERROR)
2207 );
2208 }
2209
2210 #[test]
2212 fn a_task_cancelled_before_the_plan_ran_dispatches_nothing() {
2213 let dir = tempdir().unwrap();
2214 let task = FlaggedTask::default();
2215 task.0.store(true, Ordering::SeqCst);
2216 let calls = AtomicUsize::new(0);
2217 let invoke = |_name: &str, _input: ToolInput| {
2218 calls.fetch_add(1, Ordering::SeqCst);
2219 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2220 };
2221 let plan = write_plan(
2222 dir.path(),
2223 json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2224 );
2225
2226 let ctx = SchedulerContext {
2227 task: Some(&task),
2228 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2229 };
2230 let report = execute(&plan, &ctx);
2231
2232 assert_eq!(calls.load(Ordering::SeqCst), 0);
2233 assert!(report.canceled && !report.completed, "{report:?}");
2234 assert!(report
2235 .results
2236 .iter()
2237 .all(|result| result.status == StepStatus::Skipped));
2238 }
2239
2240 #[test]
2242 fn a_task_nobody_cancels_changes_nothing() {
2243 let dir = tempdir().unwrap();
2244 let task = FlaggedTask::default();
2245 let invoke = |_name: &str, _input: ToolInput| {
2246 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2247 };
2248 let plan = write_plan(
2249 dir.path(),
2250 json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2251 );
2252
2253 let without = execute(
2254 &plan,
2255 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED),
2256 );
2257 let with = execute(
2258 &plan,
2259 &SchedulerContext {
2260 task: Some(&task),
2261 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2262 },
2263 );
2264 assert_eq!(with, without);
2265 assert!(with.completed && !with.canceled);
2266 }
2267}