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 session_workdir: WorkdirRoot,
140 pub runtime_dir: crate::runtime_dir::RuntimeDirRoots,
144 pub capability_policy: CapabilityPolicy,
145 pub installed_tools: HashSet<String>,
146 pub capsule_versions: HashMap<String, String>,
147 pub current_session_id: Option<String>,
148 pub current_context_id: Option<String>,
155 pub registry: std::sync::Arc<dyn murmur_artifact::Registry>,
159 pub spawn_credential: Option<SpawnCredential>,
166 pub formation_id: Option<crate::formation::FormationId>,
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.records.as_ref(),
388 ctx.capability_policy.resources.workdir_max_bytes,
389 ));
390
391 let shell_enforcement = match sandbox::ShellEnforcement::resolve(
394 &ctx.capability_policy,
395 ctx.capability_policy.containment_floor,
396 sandbox::HostProbe::probe(),
397 ) {
398 Ok(enforcement) => enforcement
399 .with_host_bounding(cgroup_scope, workdir_guard)
400 .with_runtime_dir(ctx.runtime_dir.clone(), None),
401 Err(error) => {
402 let error = format!("failed to resolve shell subprocess sandbox enforcement: {error}");
403 return Ok(refused_before_any_step(
404 &plan,
405 ctx,
406 plan_node.as_deref(),
407 started,
408 error,
409 ));
410 }
411 };
412 let owns_netns = session_netns.is_none();
413 let shell_enforcement = match session_netns {
414 Some(session_netns) => shell_enforcement.with_session_netns(session_netns),
415 None => shell_enforcement,
416 };
417 let _plan_end = PlanEnd {
419 enforcement: &shell_enforcement,
420 owns_netns,
421 };
422
423 let mut results = HashMap::<String, StepResult>::new();
424 let mut result_order = Vec::<String>::new();
425 let mut failed_step = None;
426 let mut canceled = false;
427
428 loop {
429 if canceled {
430 for step in &plan.steps {
433 if results.contains_key(&step.id) {
434 continue;
435 }
436 trace_undispatched(
437 ctx,
438 plan_node.as_deref(),
439 &plan.id,
440 step,
441 "skipped",
442 Some(CANCELED_BEFORE_STEP_ERROR.to_string()),
443 );
444 insert_result(
445 &mut results,
446 &mut result_order,
447 StepResult {
448 step_id: step.id.clone(),
449 status: StepStatus::Skipped,
450 output: None,
451 error: Some(CANCELED_BEFORE_STEP_ERROR.to_string()),
452 },
453 );
454 }
455 break;
456 }
457 if results.len() == plan.steps.len() || failed_step.is_some() {
458 break;
459 }
460 if task_canceled(ctx) {
461 canceled = true;
462 continue;
463 }
464
465 let mut ready = Vec::new();
466 let mut progressed = false;
467
468 for step in &plan.steps {
469 if results.contains_key(&step.id) {
470 continue;
471 }
472 if !step.depends_on.iter().all(|dep| results.contains_key(dep)) {
473 continue;
474 }
475
476 match condition::evaluate(step.condition.as_deref(), &results) {
477 Ok(true) => ready.push(step.clone()),
478 Ok(false) => {
479 trace_undispatched(ctx, plan_node.as_deref(), &plan.id, step, "skipped", None);
482 insert_result(
483 &mut results,
484 &mut result_order,
485 StepResult {
486 step_id: step.id.clone(),
487 status: StepStatus::Skipped,
488 output: None,
489 error: None,
490 },
491 );
492 progressed = true;
493 }
494 Err(error) => {
495 trace_undispatched(
496 ctx,
497 plan_node.as_deref(),
498 &plan.id,
499 step,
500 "failed",
501 Some(error.clone()),
502 );
503 insert_result(
504 &mut results,
505 &mut result_order,
506 StepResult {
507 step_id: step.id.clone(),
508 status: StepStatus::Failed,
509 output: None,
510 error: Some(error),
511 },
512 );
513 failed_step = Some(step.id.clone());
514 progressed = true;
515 break;
516 }
517 }
518 }
519
520 if failed_step.is_some() {
521 break;
522 }
523
524 if ready.is_empty() {
525 if progressed {
526 continue;
527 }
528 const BLOCKED: &str = "plan is blocked by a dependency cycle or missing dependency";
529 let blocked_step = plan
530 .steps
531 .iter()
532 .find(|step| !results.contains_key(&step.id));
533 let blocked = blocked_step
534 .map(|step| step.id.clone())
535 .unwrap_or_else(|| "plan".to_string());
536 if let Some(step) = blocked_step {
537 trace_undispatched(
538 ctx,
539 plan_node.as_deref(),
540 &plan.id,
541 step,
542 "failed",
543 Some(BLOCKED.to_string()),
544 );
545 }
546 insert_result(
547 &mut results,
548 &mut result_order,
549 StepResult {
550 step_id: blocked.clone(),
551 status: StepStatus::Failed,
552 output: None,
553 error: Some(BLOCKED.to_string()),
554 },
555 );
556 failed_step = Some(blocked);
557 break;
558 }
559
560 for step in &ready {
561 trace_step_start(ctx, plan_node.as_deref(), &plan.id, step);
562 }
563
564 let mut completed = Vec::new();
565 thread::scope(|scope| {
566 let mut handles = Vec::new();
567 for step in ready {
568 let snapshot = results.clone();
569 let shell_enforcement = &shell_enforcement;
570 handles.push(scope.spawn(move || {
571 execute_step_with_retries(&step, ctx, &snapshot, shell_enforcement)
572 }));
573 }
574 for handle in handles {
575 completed.push(handle.join().unwrap_or_else(|_| {
576 StepOutcome::undispatched(failed("unknown", "step dispatch thread panicked"))
577 }));
578 }
579 });
580 canceled = task_canceled(ctx);
584
585 for outcome in completed {
586 let Some(step) = plan
587 .steps
588 .iter()
589 .find(|step| step.id == outcome.result.step_id)
590 else {
591 trace_step(
593 ctx,
594 plan_node.as_deref(),
595 PlanStepRecord {
596 plan_id: plan.id.clone(),
597 step_id: outcome.result.step_id.clone(),
598 kind: "unknown",
599 status: outcome.result.status.as_str(),
600 attempts: outcome.attempts,
601 duration_ms: outcome.duration_ms,
602 error: outcome.result.error.clone(),
603 input: None,
604 state_effect: None,
605 resource_id: None,
606 },
607 );
608 insert_result(&mut results, &mut result_order, outcome.result);
609 if !canceled {
610 failed_step = Some("unknown".to_string());
611 break;
612 }
613 continue;
614 };
615
616 let kind = step_kind_name(step);
617 let StepOutcome {
618 result,
619 attempts,
620 duration_ms,
621 input,
622 state_effect,
623 resource_id,
624 } = outcome;
625
626 let (settled, stops) = if result.status == StepStatus::Failed && !canceled {
629 match step.on_error.as_str() {
630 "fail" => (result, true),
631 "skip" => (
632 StepResult {
633 step_id: result.step_id,
634 status: StepStatus::Skipped,
635 output: None,
636 error: result.error,
637 },
638 false,
639 ),
640 "continue" => (result, false),
641 policy => (
642 StepResult {
643 error: Some(format!("invalid on_error policy '{policy}'")),
644 ..result
645 },
646 true,
647 ),
648 }
649 } else {
650 (result, false)
651 };
652
653 trace_step(
654 ctx,
655 plan_node.as_deref(),
656 PlanStepRecord {
657 plan_id: plan.id.clone(),
658 step_id: settled.step_id.clone(),
659 kind,
660 status: settled.status.as_str(),
661 attempts,
662 duration_ms,
663 error: settled.error.clone(),
664 input,
665 state_effect,
666 resource_id,
667 },
668 );
669
670 let id = settled.step_id.clone();
671 insert_result(&mut results, &mut result_order, settled);
672 if stops {
673 failed_step = Some(id);
674 break;
675 }
676 }
677 }
678
679 let ordered_results = result_order
680 .into_iter()
681 .filter_map(|id| results.remove(&id))
682 .collect::<Vec<_>>();
683
684 if canceled {
685 failed_step = None;
686 }
687 trace_plan_end(
688 ctx,
689 plan_node.as_deref(),
690 PlanEndRecord {
691 plan_id: plan.id.clone(),
692 outcome: if canceled {
693 "canceled"
694 } else if failed_step.is_none() {
695 "completed"
696 } else {
697 "failed"
698 },
699 failed_step: failed_step.clone(),
700 steps_total: plan.steps.len(),
701 steps_succeeded: count_status(&ordered_results, StepStatus::Success),
702 steps_failed: count_status(&ordered_results, StepStatus::Failed),
703 steps_skipped: count_status(&ordered_results, StepStatus::Skipped),
704 duration_ms: elapsed_ms(started),
705 reason: canceled.then(|| PLAN_CANCELED_REASON.to_string()),
706 },
707 );
708
709 Ok(ExecutionReport {
710 results: ordered_results,
711 completed: !canceled && failed_step.is_none(),
712 failed_step,
713 canceled,
714 })
715}
716
717struct PlanEnd<'a> {
723 enforcement: &'a sandbox::ShellEnforcement,
724 owns_netns: bool,
725}
726
727impl Drop for PlanEnd<'_> {
728 fn drop(&mut self) {
729 self.enforcement.kill_session_processes();
730 if self.owns_netns {
731 self.enforcement.session_netns.end();
732 }
733 }
734}
735
736fn step_kind_name(step: &StepDef) -> &'static str {
742 infer_kind(step).map(StepKind::as_str).unwrap_or("unknown")
743}
744
745fn plan_shape(plan: &PlanFile) -> Vec<PlanStepShape> {
747 plan.steps
748 .iter()
749 .map(|step| PlanStepShape {
750 step_id: step.id.clone(),
751 kind: step_kind_name(step),
752 depends_on: step.depends_on.clone(),
753 has_condition: step.condition.is_some(),
754 })
755 .collect()
756}
757
758fn trace_step_start(
759 ctx: &SchedulerContext<'_>,
760 plan_node: Option<&str>,
761 plan_id: &str,
762 step: &StepDef,
763) {
764 if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
765 appender.write_plan_step_start(
766 node,
767 plan_id,
768 &step.id,
769 step_kind_name(step),
770 step.depends_on.clone(),
771 );
772 }
773}
774
775fn trace_step(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanStepRecord) {
776 if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
777 appender.write_plan_step(node, record);
778 }
779}
780
781fn trace_undispatched(
785 ctx: &SchedulerContext<'_>,
786 plan_node: Option<&str>,
787 plan_id: &str,
788 step: &StepDef,
789 status: &'static str,
790 error: Option<String>,
791) {
792 trace_step(
793 ctx,
794 plan_node,
795 PlanStepRecord {
796 plan_id: plan_id.to_string(),
797 step_id: step.id.clone(),
798 kind: step_kind_name(step),
799 status,
800 attempts: 0,
801 duration_ms: 0,
802 error,
803 input: None,
804 state_effect: None,
805 resource_id: None,
806 },
807 );
808}
809
810fn trace_plan_end(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanEndRecord) {
811 if let Some(appender) = ctx.trace {
812 appender.write_plan_end(plan_node, record);
813 }
814}
815
816fn refused_before_any_step(
820 plan: &PlanFile,
821 ctx: &SchedulerContext<'_>,
822 plan_node: Option<&str>,
823 started: Instant,
824 error: String,
825) -> ExecutionReport {
826 trace_plan_end(
827 ctx,
828 plan_node,
829 PlanEndRecord {
830 plan_id: plan.id.clone(),
831 outcome: "failed",
832 failed_step: Some("plan".to_string()),
833 steps_total: plan.steps.len(),
834 steps_succeeded: 0,
835 steps_failed: 0,
836 steps_skipped: 0,
837 duration_ms: elapsed_ms(started),
838 reason: Some(error.clone()),
839 },
840 );
841 ExecutionReport {
842 results: vec![StepResult {
843 step_id: "plan".to_string(),
844 status: StepStatus::Failed,
845 output: None,
846 error: Some(error),
847 }],
848 completed: false,
849 failed_step: Some("plan".to_string()),
850 canceled: false,
851 }
852}
853
854fn task_canceled(ctx: &SchedulerContext<'_>) -> bool {
856 ctx.task.is_some_and(PlanTask::is_canceled)
857}
858
859fn count_status(results: &[StepResult], status: StepStatus) -> usize {
860 results
861 .iter()
862 .filter(|result| result.status == status)
863 .count()
864}
865
866fn elapsed_ms(started: Instant) -> u64 {
867 started.elapsed().as_millis() as u64
868}
869
870fn validate_plan(plan: &PlanFile, ctx: &SchedulerContext<'_>) -> Result<(), (String, String)> {
871 let mut ids = HashSet::new();
872 for step in &plan.steps {
873 if !ids.insert(step.id.clone()) {
874 return Err((step.id.clone(), format!("duplicate step id '{}'", step.id)));
875 }
876
877 match infer_kind(step) {
878 Ok(StepKind::Tool) => {
879 let tool = step.tool.as_ref().unwrap();
880 if tool == crate::runtime::SUBMIT_PLAN_TOOL {
884 return Err((
885 step.id.clone(),
886 format!(
887 "a plan step cannot submit another plan: '{tool}' is not callable \
888 from inside a plan"
889 ),
890 ));
891 }
892 if tool == crate::runtime::SWITCH_DRIVER_TOOL
897 || tool == crate::runtime::END_WITHOUT_ANSWER_TOOL
898 || tool == crate::runtime::PULL_ARTIFACT_TOOL
899 || tool == crate::runtime::REMOVE_ARTIFACT_TOOL
900 {
901 return Err((
902 step.id.clone(),
903 format!("'{tool}' is not callable from inside a plan"),
904 ));
905 }
906 if !ctx.installed_tools.contains(tool) {
907 return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
908 }
909 }
910 Ok(StepKind::Shell) => {
911 let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
912 return Err((step.id.clone(), "shell command is empty".to_string()));
913 };
914 if !ctx.capability_policy.shell_allow.contains(&binary) {
915 return Err((
916 step.id.clone(),
917 format!("binary '{binary}' is not in capabilities.shell.allow"),
918 ));
919 }
920 }
921 Ok(StepKind::Spawn) => {
922 if let Err(error) = capsule_task_text(&step.input) {
929 return Err((step.id.clone(), error));
930 }
931 }
932 Err(error) => return Err((step.id.clone(), error)),
933 }
934
935 if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
936 return Err((
937 step.id.clone(),
938 format!("invalid on_error policy '{}'", step.on_error),
939 ));
940 }
941 }
942
943 for step in &plan.steps {
944 for dep in &step.depends_on {
945 if !ids.contains(dep) {
946 return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
947 }
948 }
949 validate_references(step, &ids)?;
950 }
951
952 Ok(())
953}
954
955fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
956 let mut references = Vec::new();
957 collect_references(&step.input, &mut references);
958 if let Some(condition) = &step.condition {
959 for token in condition.split_whitespace() {
960 if token.starts_with('$') {
961 references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
962 }
963 }
964 }
965
966 for reference in references {
967 if let Some((step_id, field)) = parse_reference(&reference) {
968 if !ids.contains(step_id) {
969 return Err((step.id.clone(), format!("unknown reference '{reference}'")));
970 }
971 if field != "output" && field != "status" {
972 return Err((
973 step.id.clone(),
974 format!("unknown reference field '{field}'"),
975 ));
976 }
977 }
978 }
979
980 Ok(())
981}
982
983fn collect_references(value: &Value, references: &mut Vec<String>) {
984 match value {
985 Value::String(value) if value.starts_with('$') => references.push(value.clone()),
986 Value::Array(values) => values
987 .iter()
988 .for_each(|value| collect_references(value, references)),
989 Value::Object(map) => map
990 .values()
991 .for_each(|value| collect_references(value, references)),
992 _ => {}
993 }
994}
995
996struct StepOutcome {
1002 result: StepResult,
1003 attempts: u32,
1004 duration_ms: u64,
1005 input: Option<Value>,
1008 state_effect: Option<String>,
1011 resource_id: Option<String>,
1012}
1013
1014impl StepOutcome {
1015 fn plain(result: StepResult, started: Instant) -> Self {
1018 Self {
1019 result,
1020 attempts: 1,
1021 duration_ms: elapsed_ms(started),
1022 input: None,
1023 state_effect: None,
1024 resource_id: None,
1025 }
1026 }
1027
1028 fn undispatched(result: StepResult) -> Self {
1030 Self {
1031 result,
1032 attempts: 0,
1033 duration_ms: 0,
1034 input: None,
1035 state_effect: None,
1036 resource_id: None,
1037 }
1038 }
1039}
1040
1041fn execute_step_with_retries(
1042 step: &StepDef,
1043 ctx: &SchedulerContext<'_>,
1044 results: &HashMap<String, StepResult>,
1045 enforcement: &sandbox::ShellEnforcement,
1046) -> StepOutcome {
1047 let started = Instant::now();
1048 let attempts = step.retries.saturating_add(1);
1049 let mut last = None;
1050 for attempt in 1..=attempts {
1051 let mut outcome = execute_step_once(step, ctx, results, enforcement);
1052 outcome.attempts = attempt;
1053 outcome.duration_ms = elapsed_ms(started);
1054 if outcome.result.status != StepStatus::Failed || task_canceled(ctx) {
1056 return outcome;
1057 }
1058 last = Some(outcome);
1059 }
1060
1061 last.unwrap_or_else(|| StepOutcome::undispatched(failed(&step.id, "step was not attempted")))
1062}
1063
1064fn execute_step_once(
1065 step: &StepDef,
1066 ctx: &SchedulerContext<'_>,
1067 results: &HashMap<String, StepResult>,
1068 enforcement: &sandbox::ShellEnforcement,
1069) -> StepOutcome {
1070 let started = Instant::now();
1071 let mut input = step.input.clone();
1072 if let Err(error) = interpolate_value(&mut input, results) {
1073 return StepOutcome::plain(failed(&step.id, error), started);
1074 }
1075 let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
1076
1077 match infer_kind(step) {
1078 Ok(StepKind::Tool) => {
1079 let (result, state_effect, resource_id) = dispatch_tool_step(step, ctx, input_json);
1080 StepOutcome {
1081 result,
1082 attempts: 1,
1083 duration_ms: elapsed_ms(started),
1084 input: (!input.is_null()).then_some(input),
1087 state_effect,
1088 resource_id,
1089 }
1090 }
1091 Ok(StepKind::Shell) => {
1092 StepOutcome::plain(dispatch_shell_step(step, ctx, enforcement), started)
1093 }
1094 Ok(StepKind::Spawn) => StepOutcome::plain(dispatch_capsule_step(step, ctx, input), started),
1095 Err(error) => StepOutcome::plain(failed(&step.id, error), started),
1096 }
1097}
1098
1099fn dispatch_tool_step(
1103 step: &StepDef,
1104 ctx: &SchedulerContext<'_>,
1105 input_json: String,
1106) -> (StepResult, Option<String>, Option<String>) {
1107 let name = step.tool.as_deref().unwrap_or_default();
1108 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Tool {
1109 name,
1110 input_json: &input_json,
1111 }) {
1112 return (failed(&step.id, refusal), None, None);
1113 }
1114 match (ctx.invoke_tool)(
1115 name,
1116 ToolInput {
1117 data: Some(input_json),
1118 log_path: None,
1119 },
1120 ) {
1121 Ok(result) => {
1122 let state_effect = crate::agent::extract_state_effect(&result.metadata);
1123 let resource_id = crate::agent::extract_resource_id(&result.metadata);
1124 let verdict = if matches!(result.status, ToolStatus::Passed) {
1125 match tool_step_output(
1126 name,
1127 ctx.accessible_workdir.path(),
1128 result.data,
1129 result.data_path.as_deref(),
1130 ) {
1131 Ok(output) => StepResult {
1132 step_id: step.id.clone(),
1133 status: StepStatus::Success,
1134 output,
1135 error: None,
1136 },
1137 Err(error) => failed(&step.id, error),
1138 }
1139 } else {
1140 failed(
1141 &step.id,
1142 result
1143 .summary
1144 .or(result.data)
1145 .unwrap_or_else(|| "tool step failed".to_string()),
1146 )
1147 };
1148 (verdict, state_effect, resource_id)
1149 }
1150 Err(error) => (failed(&step.id, error), None, None),
1151 }
1152}
1153
1154fn dispatch_shell_step(
1155 step: &StepDef,
1156 ctx: &SchedulerContext<'_>,
1157 enforcement: &sandbox::ShellEnforcement,
1158) -> StepResult {
1159 let command = step.shell.as_deref().unwrap_or_default();
1160 let Some((binary, args)) = parse_shell_command(command) else {
1161 return failed(&step.id, "shell command is empty");
1162 };
1163 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Shell {
1164 binary: &binary,
1165 command,
1166 argv: &args,
1167 }) {
1168 return failed(&step.id, refusal);
1169 }
1170 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
1171
1172 match execute_shell(
1173 &binary,
1174 &arg_refs,
1175 &[],
1176 &ctx.accessible_workdir,
1177 &ctx.session_workdir,
1178 &ctx.capability_policy,
1179 enforcement,
1180 ) {
1181 Ok(result) if result.exit_code == 0 => StepResult {
1182 step_id: step.id.clone(),
1183 status: StepStatus::Success,
1184 output: Some(result.stdout),
1185 error: None,
1186 },
1187 Ok(result) => match result.resource_limit_hit {
1192 Some(limit) => failed(
1193 &step.id,
1194 crate::errors::RuntimeError::ShellResourceLimitExceeded {
1195 binary: result.binary,
1196 limit,
1197 detail: if result.stderr.trim().is_empty() {
1198 format!("exit code {}", result.exit_code)
1199 } else {
1200 result.stderr
1201 },
1202 }
1203 .to_string(),
1204 ),
1205 None if result.stderr.trim().is_empty() => {
1210 failed(&step.id, format!("exit code {}", result.exit_code))
1211 }
1212 None => failed(&step.id, result.stderr),
1213 },
1214 Err(error) => failed(&step.id, error.to_string()),
1219 }
1220}
1221
1222fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1223 let named = step.capsule.as_deref().unwrap_or_default();
1224 let full = crate::tool_names::spawn_target(&ctx.capability_policy.spawn_allow, named);
1227 let capsule = full.as_str();
1228 let task = match capsule_task_text(&input) {
1231 Ok(task) => task,
1232 Err(error) => return failed(&step.id, error),
1233 };
1234 let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1235 Ok(value) if !value.trim().is_empty() => value,
1236 _ => {
1237 return failed(
1238 &step.id,
1239 "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1240 )
1241 }
1242 };
1243 let Some(credential) = ctx.spawn_credential.as_ref() else {
1244 return failed(
1245 &step.id,
1246 "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1247 mints it only for a session whose manifest declares capabilities.spawn.allow",
1248 );
1249 };
1250
1251 let plane = DelegationPlane::new(
1258 roost_url,
1259 credential.clone(),
1260 ctx.accessible_workdir.clone(),
1261 ctx.current_session_id.clone().unwrap_or_default(),
1262 crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1263 std::sync::Arc::clone(&ctx.registry),
1264 ctx.capability_policy.env_allow.clone(),
1265 ctx.formation_id.clone(),
1266 );
1267 let (trace, plan_task) = (ctx.trace, ctx.task);
1273 let launched = (trace.is_some() || plan_task.is_some()).then(|| {
1274 std::sync::Arc::new(move |notice: DelegationLaunch| {
1275 if let Some(trace) = trace {
1276 trace.write_delegation_start(
1277 ¬ice.delegation_id,
1278 ¬ice.capsule,
1279 ¬ice.version,
1280 ¬ice.child_session_id,
1281 ¬ice.child_workdir,
1282 ¬ice.child_records_dir,
1283 );
1284 }
1285 if let Some(plan_task) = plan_task {
1286 plan_task.delegation_started(¬ice);
1287 }
1288 }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1289 });
1290 let stop = plan_task.map(|plan_task| {
1291 std::sync::Arc::new(move || plan_task.is_canceled())
1292 as std::sync::Arc<dyn Fn() -> bool + Send + Sync>
1293 });
1294 let started = Instant::now();
1295 let result = plane.delegate(
1296 &DelegationRequest {
1297 capsule: capsule.to_string(),
1298 version: ctx
1301 .capsule_versions
1302 .get(named)
1303 .cloned()
1304 .unwrap_or_else(|| "0.1.0".to_string()),
1305 task,
1306 },
1307 &DelegationOrigin {
1308 context_id: ctx.current_context_id.clone().unwrap_or_default(),
1309 launched,
1310 stop,
1311 },
1312 );
1313
1314 if result.status == DelegationStatus::Canceled {
1317 return failed(&step.id, CANCELED_STEP_ERROR);
1318 }
1319 let closes_row = match plan_task {
1322 Some(plan_task) if !result.delegation_id.is_empty() => {
1323 plan_task.delegation_settled(&result.delegation_id)
1324 }
1325 _ => true,
1326 };
1327 if let Some(trace) = ctx.trace.filter(|_| closes_row) {
1328 trace.write_delegation(
1329 &result.capsule,
1330 &result.version,
1331 Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1334 Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1335 elapsed_ms(started),
1336 result.status.as_str(),
1337 match result.status {
1338 DelegationStatus::Completed => None,
1339 _ => Some(result.output.clone()),
1340 },
1341 );
1342 }
1343
1344 match result.status {
1345 DelegationStatus::Completed => StepResult {
1346 step_id: step.id.clone(),
1347 status: StepStatus::Success,
1348 output: Some(result.output),
1349 error: None,
1350 },
1351 _ => failed(&step.id, result.output),
1352 }
1353}
1354
1355fn capsule_task_text(input: &Value) -> Result<String, String> {
1374 const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1375 const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1376 receives, given as a string or as { \"objective\": \"<text>\" }";
1377
1378 match input {
1379 Value::String(text) => Ok(text.clone()),
1380 Value::Object(map) => {
1381 let mut unknown = map
1384 .keys()
1385 .filter(|key| key.as_str() != "objective")
1386 .map(String::as_str)
1387 .collect::<Vec<_>>();
1388 unknown.sort_unstable();
1389 if !unknown.is_empty() {
1390 return Err(format!(
1391 "capsule step input has keys the child cannot receive: {}; nothing on the \
1392 receiving side parses a task envelope, so put the whole instruction in \
1393 \"objective\"",
1394 unknown.join(", ")
1395 ));
1396 }
1397
1398 match map.get("objective") {
1399 Some(Value::String(text)) => Ok(text.clone()),
1400 Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1401 None => Err(ACCEPTS.to_string()),
1402 }
1403 }
1404 Value::Null => Err(NO_INPUT.to_string()),
1405 Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1406 Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1407 Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1408 }
1409}
1410
1411fn interpolate_value(
1412 value: &mut Value,
1413 results: &HashMap<String, StepResult>,
1414) -> Result<(), String> {
1415 match value {
1416 Value::String(text) if text.starts_with('$') => {
1417 *text = condition::resolve_reference(text, results)?;
1418 }
1419 Value::Array(values) => {
1420 for value in values {
1421 interpolate_value(value, results)?;
1422 }
1423 }
1424 Value::Object(map) => {
1425 for value in map.values_mut() {
1426 interpolate_value(value, results)?;
1427 }
1428 }
1429 _ => {}
1430 }
1431
1432 Ok(())
1433}
1434
1435fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1436 let count =
1437 step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1438 match count {
1439 1 if step.tool.is_some() => Ok(StepKind::Tool),
1440 1 if step.shell.is_some() => Ok(StepKind::Shell),
1441 1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1442 0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1443 _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1444 }
1445}
1446
1447fn default_on_error() -> String {
1448 "fail".to_string()
1449}
1450
1451fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1452 StepResult {
1453 step_id: step_id.to_string(),
1454 status: StepStatus::Failed,
1455 output: None,
1456 error: Some(error.into()),
1457 }
1458}
1459
1460fn insert_result(
1461 results: &mut HashMap<String, StepResult>,
1462 order: &mut Vec<String>,
1463 result: StepResult,
1464) {
1465 order.push(result.step_id.clone());
1466 results.insert(result.step_id.clone(), result);
1467}
1468
1469fn shell_binary(command: &str) -> Option<String> {
1470 parse_shell_command(command).map(|(binary, _)| binary)
1471}
1472
1473fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1474 let parts = split_shell_words(command);
1475 let (binary, args) = parts.split_first()?;
1476 Some((binary.clone(), args.to_vec()))
1477}
1478
1479fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1480 reference.strip_prefix('$')?.rsplit_once('.')
1481}
1482
1483fn tool_step_output(
1500 tool: &str,
1501 workdir: &Path,
1502 data: Option<String>,
1503 data_path: Option<&str>,
1504) -> Result<Option<String>, String> {
1505 if let Some(data) = data {
1506 return Ok(Some(data));
1507 }
1508 let Some(relpath) = data_path else {
1509 return Ok(None);
1510 };
1511 match resource_plane::read_file_beneath_root(
1512 workdir,
1513 relpath,
1514 DEFAULT_EXPORT_MAX_BYTES,
1515 SymlinkPolicy::Refuse,
1516 ) {
1517 Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1518 Err(error) => Err(RuntimeError::ToolDataPathRefused {
1519 tool: tool.to_string(),
1520 path: relpath.to_string(),
1521 code: error.code().to_string(),
1522 detail: error.message(),
1523 }
1524 .to_string()),
1525 }
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530 use std::{fs, path::PathBuf};
1531 use std::{
1532 sync::{
1533 atomic::{AtomicUsize, Ordering},
1534 Arc, Condvar, Mutex,
1535 },
1536 time::Duration,
1537 };
1538
1539 use serde_json::json;
1540 use tempfile::tempdir;
1541
1542 use super::*;
1543
1544 fn test_ctx<'a>(
1548 workdir: PathBuf,
1549 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1550 ) -> SchedulerContext<'a> {
1551 test_ctx_gated(workdir, invoke_tool, &UNGATED)
1552 }
1553
1554 const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1557
1558 fn test_ctx_gated<'a>(
1559 workdir: PathBuf,
1560 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1561 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1562 ) -> SchedulerContext<'a> {
1563 SchedulerContext {
1564 accessible_workdir: WorkdirRoot::open(&workdir).unwrap(),
1565 session_workdir: WorkdirRoot::open(&workdir).unwrap(),
1566 runtime_dir: crate::runtime_dir::RuntimeDirRoots::new(
1567 WorkdirRoot::open(&workdir).unwrap(),
1568 WorkdirRoot::open(&workdir).unwrap(),
1569 WorkdirRoot::open(&workdir).unwrap(),
1570 ),
1571 capability_policy: CapabilityPolicy {
1572 shell_allow: vec!["bash".to_string()],
1573 spawn_allow: vec!["worker".to_string()],
1574 ..CapabilityPolicy::default()
1575 },
1576 installed_tools: HashSet::from([
1577 "ok".to_string(),
1578 "fail".to_string(),
1579 "slow".to_string(),
1580 "a".to_string(),
1581 "b".to_string(),
1582 ]),
1583 capsule_versions: HashMap::new(),
1584 current_session_id: None,
1585 current_context_id: None,
1586 registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1588 tempdir().unwrap().keep(),
1589 )),
1590 spawn_credential: None,
1591 formation_id: None,
1592 trace: None,
1593 gate_step,
1594 invoke_tool,
1595 task: None,
1596 }
1597 }
1598
1599 fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1600 ToolResult {
1601 status,
1602 summary: None,
1603 data,
1604 data_path: None,
1605 truncated: false,
1606 metadata: Vec::new(),
1607 }
1608 }
1609
1610 fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1611 let path = workdir.join("plan.json");
1612 fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1613 path
1614 }
1615
1616 #[test]
1620 fn a_turn_s_own_decisions_are_not_callable_from_a_plan() {
1621 let dir = tempdir().unwrap();
1622 let invoke = |_: &str, _: ToolInput| -> Result<ToolResult, String> {
1623 panic!("a refused plan ran a step")
1624 };
1625 let ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1626 for tool in [
1627 crate::runtime::SWITCH_DRIVER_TOOL,
1628 crate::runtime::END_WITHOUT_ANSWER_TOOL,
1629 crate::runtime::PULL_ARTIFACT_TOOL,
1630 crate::runtime::REMOVE_ARTIFACT_TOOL,
1631 ] {
1632 let plan: PlanFile = serde_json::from_value(json!({
1633 "id": "p", "steps": [{"id": "s1", "tool": tool, "input": {"reason": "none"}}],
1634 }))
1635 .unwrap();
1636 assert_eq!(
1637 validate_plan(&plan, &ctx).unwrap_err(),
1638 (
1639 "s1".to_string(),
1640 format!("'{tool}' is not callable from inside a plan")
1641 )
1642 );
1643 }
1644 }
1645
1646 #[test]
1647 fn capsule_task_text_delivers_a_bare_string_verbatim() {
1648 assert_eq!(
1649 capsule_task_text(&json!("do the thing")).unwrap(),
1650 "do the thing"
1651 );
1652 }
1653
1654 #[test]
1655 fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1656 assert_eq!(
1657 capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1658 "do the thing"
1659 );
1660 }
1661
1662 #[test]
1665 fn capsule_task_text_accepts_empty_task_text() {
1666 assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1667 assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1668 }
1669
1670 #[test]
1671 fn capsule_task_text_refuses_a_task_envelope() {
1672 let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1673 assert!(
1674 error.contains("keys the child cannot receive: task"),
1675 "{error}"
1676 );
1677 assert!(error.contains("\"objective\""), "{error}");
1678 }
1679
1680 #[test]
1681 fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1682 let error =
1683 capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1684 .unwrap_err();
1685 assert!(
1686 error.contains("keys the child cannot receive: instructions, render_as"),
1687 "{error}"
1688 );
1689 }
1690
1691 #[test]
1692 fn capsule_task_text_refuses_a_non_string_objective() {
1693 for input in [json!({"objective": 7}), json!({"objective": null})] {
1694 assert_eq!(
1695 capsule_task_text(&input).unwrap_err(),
1696 "capsule step input \"objective\" must be a string"
1697 );
1698 }
1699 }
1700
1701 #[test]
1702 fn capsule_task_text_refuses_an_object_without_an_objective() {
1703 assert_eq!(
1704 capsule_task_text(&json!({})).unwrap_err(),
1705 "capsule step input must be a string or { \"objective\": \"<text>\" }"
1706 );
1707 }
1708
1709 #[test]
1710 fn capsule_task_text_names_the_type_it_was_handed() {
1711 assert!(capsule_task_text(&json!(7))
1712 .unwrap_err()
1713 .ends_with("not a number"));
1714 assert!(capsule_task_text(&json!(true))
1715 .unwrap_err()
1716 .ends_with("not a boolean"));
1717 assert!(capsule_task_text(&json!(["a"]))
1718 .unwrap_err()
1719 .ends_with("not an array"));
1720 }
1721
1722 #[test]
1725 fn capsule_task_text_refuses_an_absent_input() {
1726 let error = capsule_task_text(&Value::Null).unwrap_err();
1727 assert_eq!(
1728 error,
1729 "capsule step has no input; its input is the task text the child receives, given as \
1730 a string or as { \"objective\": \"<text>\" }"
1731 );
1732 }
1733
1734 #[test]
1737 fn capsule_task_text_accepts_an_uninterpolated_reference() {
1738 assert_eq!(
1739 capsule_task_text(&json!("$analyse.output")).unwrap(),
1740 "$analyse.output"
1741 );
1742 assert_eq!(
1743 capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1744 "$analyse.output"
1745 );
1746 }
1747
1748 fn test_ctx_unbounded<'a>(
1751 workdir: PathBuf,
1752 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1753 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1754 ) -> SchedulerContext<'a> {
1755 SchedulerContext {
1756 capability_policy: CapabilityPolicy::default(),
1757 ..test_ctx_gated(workdir, invoke_tool, gate_step)
1758 }
1759 }
1760
1761 #[test]
1766 fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1767 let dir = tempdir().unwrap();
1768 let dispatched = Arc::new(AtomicUsize::new(0));
1769 let counted = Arc::clone(&dispatched);
1770 let invoke = move |_name: &str, _input: ToolInput| {
1771 counted.fetch_add(1, Ordering::SeqCst);
1772 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1773 };
1774 let gate = |call: &PlannedCall<'_>| match call {
1775 PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1776 _ => None,
1777 };
1778 let plan = write_plan(
1779 dir.path(),
1780 json!({
1781 "id": "p",
1782 "steps": [
1783 {"id": "refused", "tool": "a", "on_error": "continue"},
1784 {"id": "allowed", "tool": "b"}
1785 ]
1786 }),
1787 );
1788
1789 let report = execute(
1790 &plan,
1791 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1792 );
1793 let refused = report
1794 .results
1795 .iter()
1796 .find(|result| result.step_id == "refused")
1797 .expect("the refused step settled");
1798 assert_eq!(refused.status, StepStatus::Failed);
1799 assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1800 assert_eq!(refused.output, None);
1801 assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1803 }
1804
1805 #[test]
1808 fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1809 if crate::cgroup::skip_without_host_support(
1810 "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1811 ) {
1812 return;
1813 }
1814 let dir = tempdir().unwrap();
1815 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1816 let plan = write_plan(
1817 dir.path(),
1818 json!({
1819 "id": "p",
1820 "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1821 }),
1822 );
1823
1824 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1825 assert!(!report.completed);
1826 assert_eq!(report.results[0].status, StepStatus::Failed);
1827 assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1828 }
1829
1830 #[cfg(target_os = "linux")]
1834 #[test]
1835 #[allow(clippy::print_stdout)]
1836 fn a_plan_shell_step_joins_the_session_namespace() {
1837 if crate::cgroup::skip_without_host_support(
1838 "plan::tests::a_plan_shell_step_joins_the_session_namespace",
1839 ) {
1840 return;
1841 }
1842 let dir = tempdir().unwrap();
1843 let policy = CapabilityPolicy {
1844 shell_allow: vec!["bash".to_string(), "nc".to_string(), "sleep".to_string()],
1845 ..CapabilityPolicy::default()
1846 };
1847 let session = sandbox::ShellEnforcement::resolve(
1848 &policy,
1849 murmur_artifact::ContainmentClass::Scoped,
1850 sandbox::HostProbe::probe(),
1851 )
1852 .unwrap();
1853 let server = execute_shell(
1854 "bash",
1855 &[
1856 "-c",
1857 "(printf \"pong\\n\" | nc -N -l 127.0.0.1 8080) >/dev/null 2>&1 & echo $! > server.pid",
1858 ],
1859 &[],
1860 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1861 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1862 &policy,
1863 &session,
1864 )
1865 .expect("the session's server command runs");
1866 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
1867
1868 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1869 let mut ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1870 ctx.capability_policy.shell_allow = policy.shell_allow.clone();
1871 let plan = write_plan(
1872 dir.path(),
1873 json!({
1874 "id": "p",
1875 "steps": [{
1876 "id": "client",
1877 "shell": "bash -c 'for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 \
1878 2>/dev/null && break; sleep 0.1; done; read -r reply <&3; \
1879 echo \"$reply\"'",
1880 }]
1881 }),
1882 );
1883
1884 let report = execute_in_session(&plan, &ctx, Arc::clone(&session.session_netns));
1885 println!(
1886 "plan completed: {}; step {} {:?} output {:?} error {:?}; session namespaces \
1887 created: {}",
1888 report.completed,
1889 report.results[0].step_id,
1890 report.results[0].status,
1891 report.results[0].output.as_deref().map(str::trim),
1892 report.results[0].error,
1893 session.session_netns.creations()
1894 );
1895 assert!(report.completed, "{:?}", report.results);
1896 assert_eq!(report.results[0].status, StepStatus::Success);
1897 assert_eq!(
1898 report.results[0].output.as_deref().map(str::trim),
1899 Some("pong")
1900 );
1901 assert_eq!(session.session_netns.creations(), 1);
1902
1903 let after = execute_shell(
1905 "bash",
1906 &["-c", "true"],
1907 &[],
1908 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1909 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1910 &policy,
1911 &session,
1912 )
1913 .expect("the session outlives the plan it submitted");
1914 assert_eq!(after.exit_code, 0);
1915 session.end_session();
1916 }
1917
1918 #[test]
1920 fn a_refused_shell_step_starts_no_subprocess() {
1921 if crate::cgroup::skip_without_host_support(
1922 "plan::tests::a_refused_shell_step_starts_no_subprocess",
1923 ) {
1924 return;
1925 }
1926 let dir = tempdir().unwrap();
1927 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1928 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1929 let recorded = Arc::clone(&seen);
1930 let gate = move |call: &PlannedCall<'_>| match call {
1931 PlannedCall::Shell { binary, argv, .. } => {
1932 recorded
1933 .lock()
1934 .unwrap()
1935 .push((binary.to_string(), argv.to_vec()));
1936 Some("refused by the manifest".to_string())
1937 }
1938 _ => None,
1939 };
1940 let marker = dir.path().join("written-by-a-refused-step");
1941 let plan = write_plan(
1942 dir.path(),
1943 json!({
1944 "id": "p",
1945 "steps": [{
1946 "id": "w",
1947 "shell": format!("bash -c 'printf x > {}'", marker.display()),
1948 }]
1949 }),
1950 );
1951
1952 let report = execute(
1953 &plan,
1954 &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
1955 );
1956 assert!(!report.completed);
1957 assert_eq!(report.failed_step.as_deref(), Some("w"));
1958 assert_eq!(
1959 report.results[0].error.as_deref(),
1960 Some("refused by the manifest")
1961 );
1962 assert!(!marker.exists(), "a refused step must not have run");
1963 let seen = seen.lock().unwrap();
1965 assert_eq!(seen.len(), 1);
1966 assert_eq!(seen[0].0, "bash");
1967 assert_eq!(seen[0].1[0], "-c");
1968 }
1969
1970 #[test]
1971 fn dependent_step_receives_upstream_output() {
1972 if crate::cgroup::skip_without_host_support(
1973 "plan::tests::dependent_step_receives_upstream_output",
1974 ) {
1975 return;
1976 }
1977 let dir = tempdir().unwrap();
1978 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1979 let seen_tool = Arc::clone(&seen);
1980 let invoke = move |_name: &str, input: ToolInput| {
1981 seen_tool
1982 .lock()
1983 .unwrap()
1984 .push(input.data.unwrap_or_default());
1985 Ok(tool_result(
1986 ToolStatus::Passed,
1987 Some("upstream".to_string()),
1988 ))
1989 };
1990 let plan = write_plan(
1991 dir.path(),
1992 json!({
1993 "id": "p",
1994 "steps": [
1995 {"id": "a", "tool": "ok"},
1996 {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
1997 ]
1998 }),
1999 );
2000
2001 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2002 assert!(report.completed);
2003 assert_eq!(report.results.len(), 2);
2004 assert!(seen.lock().unwrap()[1].contains("upstream"));
2005 }
2006
2007 #[test]
2008 fn parallel_tool_steps_run_concurrently() {
2009 if crate::cgroup::skip_without_host_support(
2010 "plan::tests::parallel_tool_steps_run_concurrently",
2011 ) {
2012 return;
2013 }
2014 const RENDEZVOUS_GUARD: Duration = Duration::from_secs(30);
2017
2018 #[derive(Default)]
2021 struct Rendezvous {
2022 in_flight: usize,
2023 met: bool,
2024 }
2025
2026 let dir = tempdir().unwrap();
2027 let rendezvous = Arc::new((Mutex::new(Rendezvous::default()), Condvar::new()));
2028 let met_sibling = Arc::new(AtomicUsize::new(0));
2029 let invoke_rendezvous = Arc::clone(&rendezvous);
2030 let invoke_met = Arc::clone(&met_sibling);
2031 let invoke = move |_name: &str, _input: ToolInput| {
2032 let (state, changed) = &*invoke_rendezvous;
2033 let mut state = state.lock().unwrap();
2034 state.in_flight += 1;
2035 if state.in_flight == 2 {
2036 state.met = true;
2037 }
2038 changed.notify_all();
2039 let (mut state, _) = changed
2040 .wait_timeout_while(state, RENDEZVOUS_GUARD, |state| !state.met)
2041 .unwrap();
2042 if state.met {
2043 invoke_met.fetch_add(1, Ordering::SeqCst);
2044 }
2045 state.in_flight -= 1;
2046 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2047 };
2048 let plan = write_plan(
2049 dir.path(),
2050 json!({
2051 "id": "p",
2052 "steps": [
2053 {"id": "a", "tool": "slow"},
2054 {"id": "b", "tool": "slow"}
2055 ]
2056 }),
2057 );
2058
2059 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2060 assert!(report.completed);
2061 assert_eq!(
2062 met_sibling.load(Ordering::SeqCst),
2063 2,
2064 "the two independent steps never ran at the same time"
2065 );
2066 }
2067
2068 #[test]
2069 fn on_error_and_retries_are_applied() {
2070 if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
2071 {
2072 return;
2073 }
2074 let dir = tempdir().unwrap();
2075 let calls = Arc::new(AtomicUsize::new(0));
2076 let invoke_calls = Arc::clone(&calls);
2077 let invoke = move |name: &str, _input: ToolInput| {
2078 invoke_calls.fetch_add(1, Ordering::SeqCst);
2079 if name == "a" {
2080 Ok(tool_result(ToolStatus::Failed, None))
2081 } else {
2082 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2083 }
2084 };
2085 let plan = write_plan(
2086 dir.path(),
2087 json!({
2088 "id": "p",
2089 "steps": [
2090 {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
2091 {"id": "b", "tool": "b", "depends_on": ["a"]}
2092 ]
2093 }),
2094 );
2095
2096 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2097 assert!(report.completed);
2098 assert_eq!(calls.load(Ordering::SeqCst), 4);
2099 }
2100
2101 #[test]
2102 fn condition_false_skips_step() {
2103 if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
2104 return;
2105 }
2106 let dir = tempdir().unwrap();
2107 let invoke = |_name: &str, _input: ToolInput| {
2108 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2109 };
2110 let plan = write_plan(
2111 dir.path(),
2112 json!({
2113 "id": "p",
2114 "steps": [
2115 {"id": "a", "tool": "ok"},
2116 {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
2117 ]
2118 }),
2119 );
2120
2121 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2122 assert!(report.completed);
2123 assert_eq!(report.results[1].status, StepStatus::Skipped);
2124 }
2125
2126 #[derive(Default)]
2129 struct FlaggedTask(std::sync::atomic::AtomicBool);
2130
2131 impl PlanTask for FlaggedTask {
2132 fn is_canceled(&self) -> bool {
2133 self.0.load(Ordering::SeqCst)
2134 }
2135 fn delegation_started(&self, _launch: &DelegationLaunch) {}
2136 fn delegation_settled(&self, _delegation_id: &str) -> bool {
2137 true
2138 }
2139 }
2140
2141 #[test]
2144 fn a_cancelled_task_tries_no_step_again() {
2145 let dir = tempdir().unwrap();
2146 let task = FlaggedTask::default();
2147 let calls = AtomicUsize::new(0);
2148 let invoke = |_name: &str, _input: ToolInput| {
2149 calls.fetch_add(1, Ordering::SeqCst);
2150 task.0.store(true, Ordering::SeqCst);
2151 Ok(tool_result(ToolStatus::Failed, None))
2152 };
2153 let plan = write_plan(
2154 dir.path(),
2155 json!({
2156 "id": "p",
2157 "steps": [
2158 {"id": "a", "tool": "a", "retries": 2, "on_error": "fail"},
2159 {"id": "b", "tool": "b", "depends_on": ["a"]}
2160 ]
2161 }),
2162 );
2163
2164 let ctx = SchedulerContext {
2165 task: Some(&task),
2166 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2167 };
2168 let report = execute(&plan, &ctx);
2169
2170 assert_eq!(calls.load(Ordering::SeqCst), 1, "{report:?}");
2171 assert!(report.canceled, "{report:?}");
2172 assert!(!report.completed);
2173 assert_eq!(report.failed_step, None);
2174 assert_eq!(report.results[0].status, StepStatus::Failed);
2175 assert_eq!(report.results[1].status, StepStatus::Skipped);
2176 assert_eq!(
2177 report.results[1].error.as_deref(),
2178 Some(CANCELED_BEFORE_STEP_ERROR)
2179 );
2180 }
2181
2182 #[test]
2184 fn a_task_cancelled_before_the_plan_ran_dispatches_nothing() {
2185 let dir = tempdir().unwrap();
2186 let task = FlaggedTask::default();
2187 task.0.store(true, Ordering::SeqCst);
2188 let calls = AtomicUsize::new(0);
2189 let invoke = |_name: &str, _input: ToolInput| {
2190 calls.fetch_add(1, Ordering::SeqCst);
2191 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2192 };
2193 let plan = write_plan(
2194 dir.path(),
2195 json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2196 );
2197
2198 let ctx = SchedulerContext {
2199 task: Some(&task),
2200 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2201 };
2202 let report = execute(&plan, &ctx);
2203
2204 assert_eq!(calls.load(Ordering::SeqCst), 0);
2205 assert!(report.canceled && !report.completed, "{report:?}");
2206 assert!(report
2207 .results
2208 .iter()
2209 .all(|result| result.status == StepStatus::Skipped));
2210 }
2211
2212 #[test]
2214 fn a_task_nobody_cancels_changes_nothing() {
2215 let dir = tempdir().unwrap();
2216 let task = FlaggedTask::default();
2217 let invoke = |_name: &str, _input: ToolInput| {
2218 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2219 };
2220 let plan = write_plan(
2221 dir.path(),
2222 json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2223 );
2224
2225 let without = execute(
2226 &plan,
2227 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED),
2228 );
2229 let with = execute(
2230 &plan,
2231 &SchedulerContext {
2232 task: Some(&task),
2233 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2234 },
2235 );
2236 assert_eq!(with, without);
2237 assert!(with.completed && !with.canceled);
2238 }
2239}