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_policy_scope(
360 &ctx.capability_policy,
361 &plan.id,
362 ctx.accessible_workdir.path(),
363 ) {
364 Ok(prepared) => prepared.scope,
370 Err(reason) => {
371 let error =
372 crate::errors::RuntimeError::CgroupDelegationUnavailable { reason }.to_string();
373 return Ok(refused_before_any_step(
374 &plan,
375 ctx,
376 plan_node.as_deref(),
377 started,
378 error,
379 ));
380 }
381 };
382 let workdir_guard =
383 crate::resources::workdir_guard_max_bytes(&ctx.capability_policy).map(|max_bytes| {
384 crate::resources::WorkdirGuard::spawn(
385 &ctx.accessible_workdir,
386 ctx.runtime_dir.temp.as_ref(),
387 ctx.runtime_dir.home.as_ref(),
388 ctx.runtime_dir.records.as_ref(),
389 max_bytes,
390 )
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 snapshot_grant(
1244 ctx: &SchedulerContext<'_>,
1245 records_root: &WorkdirRoot,
1246) -> crate::delegation_plane::WorkdirSnapshotGrant {
1247 crate::delegation_plane::WorkdirSnapshotGrant::new(
1248 ctx.capability_policy.spawn_workdir_snapshot.clone(),
1249 ctx.accessible_workdir.clone(),
1250 &[&ctx.runtime_dir.runtime, records_root],
1251 )
1252}
1253
1254fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1255 let named = step.capsule.as_deref().unwrap_or_default();
1256 let full = crate::tool_names::spawn_target(&ctx.capability_policy.spawn_allow, named);
1259 let capsule = full.as_str();
1260 let task = match capsule_task_text(&input) {
1263 Ok(task) => task,
1264 Err(error) => return failed(&step.id, error),
1265 };
1266 let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1267 Ok(value) if !value.trim().is_empty() => value,
1268 _ => {
1269 return failed(
1270 &step.id,
1271 "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1272 )
1273 }
1274 };
1275 let Some(credential) = ctx.spawn_credential.as_ref() else {
1276 return failed(
1277 &step.id,
1278 "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1279 mints it only for a session whose manifest declares capabilities.spawn.allow",
1280 );
1281 };
1282
1283 let Some(records_root) = ctx.runtime_dir.records.clone() else {
1290 return failed(
1291 &step.id,
1292 "this plan runs outside a session's records directory, where a capsule step's child \
1293 work folder is created",
1294 );
1295 };
1296 let snapshot_grant = snapshot_grant(ctx, &records_root);
1297 let plane = DelegationPlane::new(
1298 roost_url,
1299 credential.clone(),
1300 records_root,
1301 ctx.current_session_id.clone().unwrap_or_default(),
1302 crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1303 std::sync::Arc::clone(&ctx.registry),
1304 ctx.capability_policy.env_allow.clone(),
1305 ctx.formation_id.clone(),
1306 )
1307 .bounded_by(ctx.deadline)
1308 .snapshotting_workdir(snapshot_grant);
1309 let (trace, plan_task) = (ctx.trace, ctx.task);
1315 let launched = (trace.is_some() || plan_task.is_some()).then(|| {
1316 std::sync::Arc::new(move |notice: DelegationLaunch| {
1317 if let Some(trace) = trace {
1318 trace.write_delegation_start(
1319 ¬ice.delegation_id,
1320 ¬ice.capsule,
1321 ¬ice.version,
1322 ¬ice.child_session_id,
1323 ¬ice.child_workdir,
1324 ¬ice.child_records_dir,
1325 notice.workdir_snapshot.as_ref(),
1326 );
1327 }
1328 if let Some(plan_task) = plan_task {
1329 plan_task.delegation_started(¬ice);
1330 }
1331 }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1332 });
1333 let stop = plan_task.map(|plan_task| {
1334 std::sync::Arc::new(move || plan_task.is_canceled())
1335 as std::sync::Arc<dyn Fn() -> bool + Send + Sync>
1336 });
1337 let started = Instant::now();
1338 let result = plane.delegate(
1339 &DelegationRequest {
1340 capsule: capsule.to_string(),
1341 version: ctx
1344 .capsule_versions
1345 .get(named)
1346 .cloned()
1347 .unwrap_or_else(|| "0.1.0".to_string()),
1348 task,
1349 },
1350 &DelegationOrigin {
1351 context_id: ctx.current_context_id.clone().unwrap_or_default(),
1352 launched,
1353 stop,
1354 },
1355 &ctx.runtime_dir.runtime,
1356 );
1357
1358 if result.status == DelegationStatus::Canceled {
1361 return failed(&step.id, CANCELED_STEP_ERROR);
1362 }
1363 let closes_row = match plan_task {
1366 Some(plan_task) if !result.delegation_id.is_empty() => {
1367 plan_task.delegation_settled(&result.delegation_id)
1368 }
1369 _ => true,
1370 };
1371 if let Some(trace) = ctx.trace.filter(|_| closes_row) {
1372 trace.write_delegation(
1373 &result.capsule,
1374 &result.version,
1375 Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1378 Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1379 elapsed_ms(started),
1380 result.status.as_str(),
1381 match result.status {
1382 DelegationStatus::Completed => None,
1383 _ => Some(result.output.clone()),
1384 },
1385 );
1386 }
1387
1388 match result.status {
1389 DelegationStatus::Completed => StepResult {
1390 step_id: step.id.clone(),
1391 status: StepStatus::Success,
1392 output: Some(result.output),
1393 error: None,
1394 },
1395 _ => failed(&step.id, result.output),
1396 }
1397}
1398
1399fn capsule_task_text(input: &Value) -> Result<String, String> {
1418 const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1419 const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1420 receives, given as a string or as { \"objective\": \"<text>\" }";
1421
1422 match input {
1423 Value::String(text) => Ok(text.clone()),
1424 Value::Object(map) => {
1425 let mut unknown = map
1428 .keys()
1429 .filter(|key| key.as_str() != "objective")
1430 .map(String::as_str)
1431 .collect::<Vec<_>>();
1432 unknown.sort_unstable();
1433 if !unknown.is_empty() {
1434 return Err(format!(
1435 "capsule step input has keys the child cannot receive: {}; nothing on the \
1436 receiving side parses a task envelope, so put the whole instruction in \
1437 \"objective\"",
1438 unknown.join(", ")
1439 ));
1440 }
1441
1442 match map.get("objective") {
1443 Some(Value::String(text)) => Ok(text.clone()),
1444 Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1445 None => Err(ACCEPTS.to_string()),
1446 }
1447 }
1448 Value::Null => Err(NO_INPUT.to_string()),
1449 Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1450 Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1451 Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1452 }
1453}
1454
1455fn interpolate_value(
1456 value: &mut Value,
1457 results: &HashMap<String, StepResult>,
1458) -> Result<(), String> {
1459 match value {
1460 Value::String(text) if text.starts_with('$') => {
1461 *text = condition::resolve_reference(text, results)?;
1462 }
1463 Value::Array(values) => {
1464 for value in values {
1465 interpolate_value(value, results)?;
1466 }
1467 }
1468 Value::Object(map) => {
1469 for value in map.values_mut() {
1470 interpolate_value(value, results)?;
1471 }
1472 }
1473 _ => {}
1474 }
1475
1476 Ok(())
1477}
1478
1479fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1480 let count =
1481 step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1482 match count {
1483 1 if step.tool.is_some() => Ok(StepKind::Tool),
1484 1 if step.shell.is_some() => Ok(StepKind::Shell),
1485 1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1486 0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1487 _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1488 }
1489}
1490
1491fn default_on_error() -> String {
1492 "fail".to_string()
1493}
1494
1495fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1496 StepResult {
1497 step_id: step_id.to_string(),
1498 status: StepStatus::Failed,
1499 output: None,
1500 error: Some(error.into()),
1501 }
1502}
1503
1504fn insert_result(
1505 results: &mut HashMap<String, StepResult>,
1506 order: &mut Vec<String>,
1507 result: StepResult,
1508) {
1509 order.push(result.step_id.clone());
1510 results.insert(result.step_id.clone(), result);
1511}
1512
1513fn shell_binary(command: &str) -> Option<String> {
1514 parse_shell_command(command).map(|(binary, _)| binary)
1515}
1516
1517fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1518 let parts = split_shell_words(command);
1519 let (binary, args) = parts.split_first()?;
1520 Some((binary.clone(), args.to_vec()))
1521}
1522
1523fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1524 reference.strip_prefix('$')?.rsplit_once('.')
1525}
1526
1527fn tool_step_output(
1544 tool: &str,
1545 workdir: &Path,
1546 data: Option<String>,
1547 data_path: Option<&str>,
1548) -> Result<Option<String>, String> {
1549 if let Some(data) = data {
1550 return Ok(Some(data));
1551 }
1552 let Some(relpath) = data_path else {
1553 return Ok(None);
1554 };
1555 match resource_plane::read_file_beneath_root(
1556 workdir,
1557 relpath,
1558 DEFAULT_EXPORT_MAX_BYTES,
1559 SymlinkPolicy::Refuse,
1560 ) {
1561 Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1562 Err(error) => Err(RuntimeError::ToolDataPathRefused {
1563 tool: tool.to_string(),
1564 path: relpath.to_string(),
1565 code: error.code().to_string(),
1566 detail: error.message(),
1567 }
1568 .to_string()),
1569 }
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574 use std::{fs, path::PathBuf};
1575 use std::{
1576 sync::{
1577 atomic::{AtomicUsize, Ordering},
1578 Arc, Condvar, Mutex,
1579 },
1580 time::Duration,
1581 };
1582
1583 use serde_json::json;
1584 use tempfile::tempdir;
1585
1586 use super::*;
1587
1588 fn test_ctx<'a>(
1592 workdir: PathBuf,
1593 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1594 ) -> SchedulerContext<'a> {
1595 test_ctx_gated(workdir, invoke_tool, &UNGATED)
1596 }
1597
1598 const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1601
1602 fn test_ctx_gated<'a>(
1603 workdir: PathBuf,
1604 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1605 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1606 ) -> SchedulerContext<'a> {
1607 SchedulerContext {
1608 accessible_workdir: WorkdirRoot::open(&workdir).unwrap(),
1609 runtime_dir: crate::runtime_dir::RuntimeDirRoots::new(
1610 WorkdirRoot::open(&workdir).unwrap(),
1611 WorkdirRoot::open(&workdir).unwrap(),
1612 WorkdirRoot::open(&workdir).unwrap(),
1613 )
1614 .with_home(WorkdirRoot::open(&tempfile::tempdir().unwrap().keep()).unwrap()),
1616 capability_policy: CapabilityPolicy {
1617 shell_allow: vec!["bash".to_string()],
1618 spawn_allow: vec!["worker".to_string()],
1619 ..CapabilityPolicy::default()
1620 },
1621 installed_tools: HashSet::from([
1622 "ok".to_string(),
1623 "fail".to_string(),
1624 "slow".to_string(),
1625 "a".to_string(),
1626 "b".to_string(),
1627 ]),
1628 capsule_versions: HashMap::new(),
1629 current_session_id: None,
1630 current_context_id: None,
1631 registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1633 tempdir().unwrap().keep(),
1634 )),
1635 spawn_credential: None,
1636 formation_id: None,
1637 deadline: None,
1638 trace: None,
1639 gate_step,
1640 invoke_tool,
1641 task: None,
1642 }
1643 }
1644
1645 fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1646 ToolResult {
1647 status,
1648 summary: None,
1649 data,
1650 data_path: None,
1651 truncated: false,
1652 metadata: Vec::new(),
1653 attachments: Vec::new(),
1654 }
1655 }
1656
1657 #[test]
1661 fn plan_capsule_step_snapshot_grant_is_the_sessions() {
1662 let work = tempdir().unwrap();
1663 let records = tempdir().unwrap();
1664 let invoke = |_: &str, _: ToolInput| -> Result<ToolResult, String> { unreachable!() };
1665 let mut ctx = test_ctx(work.path().to_path_buf(), &invoke);
1666 ctx.capability_policy.spawn_workdir_snapshot = vec!["local/worker".to_string()];
1667 let records_root = WorkdirRoot::open(records.path()).unwrap();
1668
1669 let grant = snapshot_grant(&ctx, &records_root);
1670
1671 assert_eq!(grant.capsules, vec!["local/worker".to_string()]);
1672 assert_eq!(grant.source.path(), work.path());
1673 assert_eq!(
1674 grant.excluded,
1675 vec![
1676 ctx.runtime_dir.runtime.identity().unwrap(),
1677 records_root.identity().unwrap()
1678 ]
1679 );
1680 }
1681
1682 fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1683 let path = workdir.join("plan.json");
1684 fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1685 path
1686 }
1687
1688 #[test]
1692 fn a_turn_s_own_decisions_are_not_callable_from_a_plan() {
1693 let dir = tempdir().unwrap();
1694 let invoke = |_: &str, _: ToolInput| -> Result<ToolResult, String> {
1695 panic!("a refused plan ran a step")
1696 };
1697 let ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1698 for tool in [
1699 crate::runtime::SWITCH_DRIVER_TOOL,
1700 crate::runtime::END_WITHOUT_ANSWER_TOOL,
1701 crate::runtime::PULL_ARTIFACT_TOOL,
1702 crate::runtime::REMOVE_ARTIFACT_TOOL,
1703 ] {
1704 let plan: PlanFile = serde_json::from_value(json!({
1705 "id": "p", "steps": [{"id": "s1", "tool": tool, "input": {"reason": "none"}}],
1706 }))
1707 .unwrap();
1708 assert_eq!(
1709 validate_plan(&plan, &ctx).unwrap_err(),
1710 (
1711 "s1".to_string(),
1712 format!("'{tool}' is not callable from inside a plan")
1713 )
1714 );
1715 }
1716 }
1717
1718 #[test]
1719 fn capsule_task_text_delivers_a_bare_string_verbatim() {
1720 assert_eq!(
1721 capsule_task_text(&json!("do the thing")).unwrap(),
1722 "do the thing"
1723 );
1724 }
1725
1726 #[test]
1727 fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1728 assert_eq!(
1729 capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1730 "do the thing"
1731 );
1732 }
1733
1734 #[test]
1737 fn capsule_task_text_accepts_empty_task_text() {
1738 assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1739 assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1740 }
1741
1742 #[test]
1743 fn capsule_task_text_refuses_a_task_envelope() {
1744 let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1745 assert!(
1746 error.contains("keys the child cannot receive: task"),
1747 "{error}"
1748 );
1749 assert!(error.contains("\"objective\""), "{error}");
1750 }
1751
1752 #[test]
1753 fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1754 let error =
1755 capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1756 .unwrap_err();
1757 assert!(
1758 error.contains("keys the child cannot receive: instructions, render_as"),
1759 "{error}"
1760 );
1761 }
1762
1763 #[test]
1764 fn capsule_task_text_refuses_a_non_string_objective() {
1765 for input in [json!({"objective": 7}), json!({"objective": null})] {
1766 assert_eq!(
1767 capsule_task_text(&input).unwrap_err(),
1768 "capsule step input \"objective\" must be a string"
1769 );
1770 }
1771 }
1772
1773 #[test]
1774 fn capsule_task_text_refuses_an_object_without_an_objective() {
1775 assert_eq!(
1776 capsule_task_text(&json!({})).unwrap_err(),
1777 "capsule step input must be a string or { \"objective\": \"<text>\" }"
1778 );
1779 }
1780
1781 #[test]
1782 fn capsule_task_text_names_the_type_it_was_handed() {
1783 assert!(capsule_task_text(&json!(7))
1784 .unwrap_err()
1785 .ends_with("not a number"));
1786 assert!(capsule_task_text(&json!(true))
1787 .unwrap_err()
1788 .ends_with("not a boolean"));
1789 assert!(capsule_task_text(&json!(["a"]))
1790 .unwrap_err()
1791 .ends_with("not an array"));
1792 }
1793
1794 #[test]
1797 fn capsule_task_text_refuses_an_absent_input() {
1798 let error = capsule_task_text(&Value::Null).unwrap_err();
1799 assert_eq!(
1800 error,
1801 "capsule step has no input; its input is the task text the child receives, given as \
1802 a string or as { \"objective\": \"<text>\" }"
1803 );
1804 }
1805
1806 #[test]
1809 fn capsule_task_text_accepts_an_uninterpolated_reference() {
1810 assert_eq!(
1811 capsule_task_text(&json!("$analyse.output")).unwrap(),
1812 "$analyse.output"
1813 );
1814 assert_eq!(
1815 capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1816 "$analyse.output"
1817 );
1818 }
1819
1820 fn test_ctx_unbounded<'a>(
1823 workdir: PathBuf,
1824 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1825 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1826 ) -> SchedulerContext<'a> {
1827 SchedulerContext {
1828 capability_policy: CapabilityPolicy::default(),
1829 ..test_ctx_gated(workdir, invoke_tool, gate_step)
1830 }
1831 }
1832
1833 #[test]
1838 fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1839 let dir = tempdir().unwrap();
1840 let dispatched = Arc::new(AtomicUsize::new(0));
1841 let counted = Arc::clone(&dispatched);
1842 let invoke = move |_name: &str, _input: ToolInput| {
1843 counted.fetch_add(1, Ordering::SeqCst);
1844 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1845 };
1846 let gate = |call: &PlannedCall<'_>| match call {
1847 PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1848 _ => None,
1849 };
1850 let plan = write_plan(
1851 dir.path(),
1852 json!({
1853 "id": "p",
1854 "steps": [
1855 {"id": "refused", "tool": "a", "on_error": "continue"},
1856 {"id": "allowed", "tool": "b"}
1857 ]
1858 }),
1859 );
1860
1861 let report = execute(
1862 &plan,
1863 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1864 );
1865 let refused = report
1866 .results
1867 .iter()
1868 .find(|result| result.step_id == "refused")
1869 .expect("the refused step settled");
1870 assert_eq!(refused.status, StepStatus::Failed);
1871 assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1872 assert_eq!(refused.output, None);
1873 assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1875 }
1876
1877 #[test]
1880 fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1881 if crate::cgroup::skip_without_host_support(
1882 "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1883 ) {
1884 return;
1885 }
1886 let dir = tempdir().unwrap();
1887 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1888 let plan = write_plan(
1889 dir.path(),
1890 json!({
1891 "id": "p",
1892 "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1893 }),
1894 );
1895
1896 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1897 assert!(!report.completed);
1898 assert_eq!(report.results[0].status, StepStatus::Failed);
1899 assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1900 }
1901
1902 #[cfg(target_os = "linux")]
1906 #[test]
1907 #[allow(clippy::print_stdout)]
1908 fn a_plan_shell_step_joins_the_session_namespace() {
1909 if crate::cgroup::skip_without_host_support(
1910 "plan::tests::a_plan_shell_step_joins_the_session_namespace",
1911 ) {
1912 return;
1913 }
1914 let dir = tempdir().unwrap();
1915 let policy = CapabilityPolicy {
1916 shell_allow: vec!["bash".to_string(), "nc".to_string(), "sleep".to_string()],
1917 ..CapabilityPolicy::default()
1918 };
1919 let session = sandbox::ShellEnforcement::resolve_declared(
1920 &policy,
1921 murmur_artifact::ContainmentClass::Scoped,
1922 sandbox::HostProbe::probe(),
1923 )
1924 .unwrap()
1925 .with_test_session_dirs();
1926 let server = execute_shell(
1927 "bash",
1928 &[
1929 "-c",
1930 "(printf \"pong\\n\" | nc -N -l 127.0.0.1 8080) >/dev/null 2>&1 & echo $! > server.pid",
1931 ],
1932 &[],
1933 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1934 &policy,
1935 &session,
1936 )
1937 .expect("the session's server command runs");
1938 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
1939
1940 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1941 let mut ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1942 ctx.capability_policy.shell_allow = policy.shell_allow.clone();
1943 let plan = write_plan(
1944 dir.path(),
1945 json!({
1946 "id": "p",
1947 "steps": [{
1948 "id": "client",
1949 "shell": "bash -c 'for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 \
1950 2>/dev/null && break; sleep 0.1; done; read -r reply <&3; \
1951 echo \"$reply\"'",
1952 }]
1953 }),
1954 );
1955
1956 let report = execute_in_session(&plan, &ctx, Arc::clone(&session.session_netns));
1957 println!(
1958 "plan completed: {}; step {} {:?} output {:?} error {:?}; session namespaces \
1959 created: {}",
1960 report.completed,
1961 report.results[0].step_id,
1962 report.results[0].status,
1963 report.results[0].output.as_deref().map(str::trim),
1964 report.results[0].error,
1965 session.session_netns.creations()
1966 );
1967 assert!(report.completed, "{:?}", report.results);
1968 assert_eq!(report.results[0].status, StepStatus::Success);
1969 assert_eq!(
1970 report.results[0].output.as_deref().map(str::trim),
1971 Some("pong")
1972 );
1973 assert_eq!(session.session_netns.creations(), 1);
1974
1975 let after = execute_shell(
1977 "bash",
1978 &["-c", "true"],
1979 &[],
1980 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1981 &policy,
1982 &session,
1983 )
1984 .expect("the session outlives the plan it submitted");
1985 assert_eq!(after.exit_code, 0);
1986 session.end_session();
1987 }
1988
1989 #[test]
1991 fn a_refused_shell_step_starts_no_subprocess() {
1992 if crate::cgroup::skip_without_host_support(
1993 "plan::tests::a_refused_shell_step_starts_no_subprocess",
1994 ) {
1995 return;
1996 }
1997 let dir = tempdir().unwrap();
1998 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1999 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2000 let recorded = Arc::clone(&seen);
2001 let gate = move |call: &PlannedCall<'_>| match call {
2002 PlannedCall::Shell { binary, argv, .. } => {
2003 recorded
2004 .lock()
2005 .unwrap()
2006 .push((binary.to_string(), argv.to_vec()));
2007 Some("refused by the manifest".to_string())
2008 }
2009 _ => None,
2010 };
2011 let marker = dir.path().join("written-by-a-refused-step");
2012 let plan = write_plan(
2013 dir.path(),
2014 json!({
2015 "id": "p",
2016 "steps": [{
2017 "id": "w",
2018 "shell": format!("bash -c 'printf x > {}'", marker.display()),
2019 }]
2020 }),
2021 );
2022
2023 let report = execute(
2024 &plan,
2025 &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
2026 );
2027 assert!(!report.completed);
2028 assert_eq!(report.failed_step.as_deref(), Some("w"));
2029 assert_eq!(
2030 report.results[0].error.as_deref(),
2031 Some("refused by the manifest")
2032 );
2033 assert!(!marker.exists(), "a refused step must not have run");
2034 let seen = seen.lock().unwrap();
2036 assert_eq!(seen.len(), 1);
2037 assert_eq!(seen[0].0, "bash");
2038 assert_eq!(seen[0].1[0], "-c");
2039 }
2040
2041 #[test]
2042 fn dependent_step_receives_upstream_output() {
2043 if crate::cgroup::skip_without_host_support(
2044 "plan::tests::dependent_step_receives_upstream_output",
2045 ) {
2046 return;
2047 }
2048 let dir = tempdir().unwrap();
2049 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2050 let seen_tool = Arc::clone(&seen);
2051 let invoke = move |_name: &str, input: ToolInput| {
2052 seen_tool
2053 .lock()
2054 .unwrap()
2055 .push(input.data.unwrap_or_default());
2056 Ok(tool_result(
2057 ToolStatus::Passed,
2058 Some("upstream".to_string()),
2059 ))
2060 };
2061 let plan = write_plan(
2062 dir.path(),
2063 json!({
2064 "id": "p",
2065 "steps": [
2066 {"id": "a", "tool": "ok"},
2067 {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
2068 ]
2069 }),
2070 );
2071
2072 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2073 assert!(report.completed);
2074 assert_eq!(report.results.len(), 2);
2075 assert!(seen.lock().unwrap()[1].contains("upstream"));
2076 }
2077
2078 #[test]
2079 fn parallel_tool_steps_run_concurrently() {
2080 if crate::cgroup::skip_without_host_support(
2081 "plan::tests::parallel_tool_steps_run_concurrently",
2082 ) {
2083 return;
2084 }
2085 const RENDEZVOUS_GUARD: Duration = Duration::from_secs(30);
2088
2089 #[derive(Default)]
2092 struct Rendezvous {
2093 in_flight: usize,
2094 met: bool,
2095 }
2096
2097 let dir = tempdir().unwrap();
2098 let rendezvous = Arc::new((Mutex::new(Rendezvous::default()), Condvar::new()));
2099 let met_sibling = Arc::new(AtomicUsize::new(0));
2100 let invoke_rendezvous = Arc::clone(&rendezvous);
2101 let invoke_met = Arc::clone(&met_sibling);
2102 let invoke = move |_name: &str, _input: ToolInput| {
2103 let (state, changed) = &*invoke_rendezvous;
2104 let mut state = state.lock().unwrap();
2105 state.in_flight += 1;
2106 if state.in_flight == 2 {
2107 state.met = true;
2108 }
2109 changed.notify_all();
2110 let (mut state, _) = changed
2111 .wait_timeout_while(state, RENDEZVOUS_GUARD, |state| !state.met)
2112 .unwrap();
2113 if state.met {
2114 invoke_met.fetch_add(1, Ordering::SeqCst);
2115 }
2116 state.in_flight -= 1;
2117 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2118 };
2119 let plan = write_plan(
2120 dir.path(),
2121 json!({
2122 "id": "p",
2123 "steps": [
2124 {"id": "a", "tool": "slow"},
2125 {"id": "b", "tool": "slow"}
2126 ]
2127 }),
2128 );
2129
2130 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2131 assert!(report.completed);
2132 assert_eq!(
2133 met_sibling.load(Ordering::SeqCst),
2134 2,
2135 "the two independent steps never ran at the same time"
2136 );
2137 }
2138
2139 #[test]
2140 fn on_error_and_retries_are_applied() {
2141 if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
2142 {
2143 return;
2144 }
2145 let dir = tempdir().unwrap();
2146 let calls = Arc::new(AtomicUsize::new(0));
2147 let invoke_calls = Arc::clone(&calls);
2148 let invoke = move |name: &str, _input: ToolInput| {
2149 invoke_calls.fetch_add(1, Ordering::SeqCst);
2150 if name == "a" {
2151 Ok(tool_result(ToolStatus::Failed, None))
2152 } else {
2153 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2154 }
2155 };
2156 let plan = write_plan(
2157 dir.path(),
2158 json!({
2159 "id": "p",
2160 "steps": [
2161 {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
2162 {"id": "b", "tool": "b", "depends_on": ["a"]}
2163 ]
2164 }),
2165 );
2166
2167 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2168 assert!(report.completed);
2169 assert_eq!(calls.load(Ordering::SeqCst), 4);
2170 }
2171
2172 #[test]
2173 fn condition_false_skips_step() {
2174 if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
2175 return;
2176 }
2177 let dir = tempdir().unwrap();
2178 let invoke = |_name: &str, _input: ToolInput| {
2179 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2180 };
2181 let plan = write_plan(
2182 dir.path(),
2183 json!({
2184 "id": "p",
2185 "steps": [
2186 {"id": "a", "tool": "ok"},
2187 {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
2188 ]
2189 }),
2190 );
2191
2192 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2193 assert!(report.completed);
2194 assert_eq!(report.results[1].status, StepStatus::Skipped);
2195 }
2196
2197 #[derive(Default)]
2200 struct FlaggedTask(std::sync::atomic::AtomicBool);
2201
2202 impl PlanTask for FlaggedTask {
2203 fn is_canceled(&self) -> bool {
2204 self.0.load(Ordering::SeqCst)
2205 }
2206 fn delegation_started(&self, _launch: &DelegationLaunch) {}
2207 fn delegation_settled(&self, _delegation_id: &str) -> bool {
2208 true
2209 }
2210 }
2211
2212 #[test]
2215 fn a_cancelled_task_tries_no_step_again() {
2216 let dir = tempdir().unwrap();
2217 let task = FlaggedTask::default();
2218 let calls = AtomicUsize::new(0);
2219 let invoke = |_name: &str, _input: ToolInput| {
2220 calls.fetch_add(1, Ordering::SeqCst);
2221 task.0.store(true, Ordering::SeqCst);
2222 Ok(tool_result(ToolStatus::Failed, None))
2223 };
2224 let plan = write_plan(
2225 dir.path(),
2226 json!({
2227 "id": "p",
2228 "steps": [
2229 {"id": "a", "tool": "a", "retries": 2, "on_error": "fail"},
2230 {"id": "b", "tool": "b", "depends_on": ["a"]}
2231 ]
2232 }),
2233 );
2234
2235 let ctx = SchedulerContext {
2236 task: Some(&task),
2237 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2238 };
2239 let report = execute(&plan, &ctx);
2240
2241 assert_eq!(calls.load(Ordering::SeqCst), 1, "{report:?}");
2242 assert!(report.canceled, "{report:?}");
2243 assert!(!report.completed);
2244 assert_eq!(report.failed_step, None);
2245 assert_eq!(report.results[0].status, StepStatus::Failed);
2246 assert_eq!(report.results[1].status, StepStatus::Skipped);
2247 assert_eq!(
2248 report.results[1].error.as_deref(),
2249 Some(CANCELED_BEFORE_STEP_ERROR)
2250 );
2251 }
2252
2253 #[test]
2255 fn a_task_cancelled_before_the_plan_ran_dispatches_nothing() {
2256 let dir = tempdir().unwrap();
2257 let task = FlaggedTask::default();
2258 task.0.store(true, Ordering::SeqCst);
2259 let calls = AtomicUsize::new(0);
2260 let invoke = |_name: &str, _input: ToolInput| {
2261 calls.fetch_add(1, Ordering::SeqCst);
2262 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2263 };
2264 let plan = write_plan(
2265 dir.path(),
2266 json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2267 );
2268
2269 let ctx = SchedulerContext {
2270 task: Some(&task),
2271 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2272 };
2273 let report = execute(&plan, &ctx);
2274
2275 assert_eq!(calls.load(Ordering::SeqCst), 0);
2276 assert!(report.canceled && !report.completed, "{report:?}");
2277 assert!(report
2278 .results
2279 .iter()
2280 .all(|result| result.status == StepStatus::Skipped));
2281 }
2282
2283 #[test]
2285 fn a_task_nobody_cancels_changes_nothing() {
2286 let dir = tempdir().unwrap();
2287 let task = FlaggedTask::default();
2288 let invoke = |_name: &str, _input: ToolInput| {
2289 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2290 };
2291 let plan = write_plan(
2292 dir.path(),
2293 json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2294 );
2295
2296 let without = execute(
2297 &plan,
2298 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED),
2299 );
2300 let with = execute(
2301 &plan,
2302 &SchedulerContext {
2303 task: Some(&task),
2304 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2305 },
2306 );
2307 assert_eq!(with, without);
2308 assert!(with.completed && !with.canceled);
2309 }
2310}