1use std::{
2 collections::{HashMap, HashSet},
3 fs,
4 path::{Path, PathBuf},
5 thread,
6 time::Instant,
7};
8
9use serde::Deserialize;
10use serde_json::Value;
11
12use murmur_artifact::DEFAULT_EXPORT_MAX_BYTES;
13
14use crate::{
15 bindings::host::murmur::tool::run::{Status as ToolStatus, ToolInput, ToolResult},
16 delegation_plane::{
17 DelegationLaunch, DelegationOrigin, DelegationPlane, DelegationRequest, DelegationStatus,
18 },
19 errors::RuntimeError,
20 resource_plane::{self, SymlinkPolicy},
21 sandbox,
22 shell::{execute_shell, split_shell_words},
23 spawn_credential::SpawnCredential,
24 trace::{PlanEndRecord, PlanStepRecord, PlanStepShape, PlanTraceAppender},
25 types::CapabilityPolicy,
26};
27
28pub mod condition;
29
30#[derive(Debug, Deserialize)]
31struct PlanFile {
32 id: String,
33 steps: Vec<StepDef>,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37struct StepDef {
38 id: String,
39 tool: Option<String>,
40 shell: Option<String>,
41 capsule: Option<String>,
42 #[serde(default)]
43 input: Value,
44 #[serde(default)]
45 depends_on: Vec<String>,
46 #[serde(rename = "if")]
47 condition: Option<String>,
48 #[serde(default = "default_on_error")]
49 on_error: String,
50 #[serde(default)]
51 retries: u32,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum StepKind {
56 Tool,
57 Shell,
58 Spawn,
59}
60
61impl StepKind {
62 fn as_str(self) -> &'static str {
65 match self {
66 StepKind::Tool => "tool",
67 StepKind::Shell => "shell",
68 StepKind::Spawn => "capsule",
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum StepStatus {
75 Success,
76 Failed,
77 Skipped,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct StepResult {
82 pub step_id: String,
83 pub status: StepStatus,
84 pub output: Option<String>,
85 pub error: Option<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ExecutionReport {
90 pub results: Vec<StepResult>,
91 pub completed: bool,
92 pub failed_step: Option<String>,
94 pub canceled: bool,
97}
98
99pub const CANCELED_STEP_ERROR: &str =
102 "the task running this plan was cancelled; this step's sub-capsule was ended with it";
103
104pub const CANCELED_BEFORE_STEP_ERROR: &str =
106 "the task running this plan was cancelled before this step ran";
107
108pub const PLAN_CANCELED_REASON: &str = "the task running this plan was cancelled";
110
111pub trait PlanTask: Sync {
119 fn is_canceled(&self) -> bool;
121 fn delegation_started(&self, launch: &DelegationLaunch);
124 #[must_use]
127 fn delegation_settled(&self, delegation_id: &str) -> bool;
128}
129
130pub struct SchedulerContext<'a> {
131 pub accessible_workdir: PathBuf,
134 pub session_workdir: PathBuf,
138 pub capability_policy: CapabilityPolicy,
139 pub installed_tools: HashSet<String>,
140 pub capsule_versions: HashMap<String, String>,
141 pub current_session_id: Option<String>,
142 pub current_context_id: Option<String>,
149 pub registry: std::sync::Arc<dyn murmur_artifact::Registry>,
153 pub spawn_credential: Option<SpawnCredential>,
160 pub formation_id: Option<crate::formation::FormationId>,
163 pub trace: Option<&'a PlanTraceAppender>,
174 pub gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
186 pub invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
187 pub task: Option<&'a dyn PlanTask>,
194}
195
196pub enum PlannedCall<'a> {
202 Tool {
203 name: &'a str,
204 input_json: &'a str,
206 },
207 Shell {
208 binary: &'a str,
210 command: &'a str,
212 argv: &'a [String],
215 },
216}
217
218pub fn execute(plan_path: &Path, ctx: &SchedulerContext<'_>) -> ExecutionReport {
219 let started = Instant::now();
220 match execute_inner(plan_path, ctx, started) {
221 Ok(report) => report,
222 Err((failed_step, error)) => {
225 if let Some(appender) = ctx.trace {
226 appender.write_plan_end(
227 None,
228 PlanEndRecord {
229 plan_id: String::new(),
230 outcome: "failed",
231 failed_step: Some(failed_step.clone()),
232 steps_total: 0,
233 steps_succeeded: 0,
234 steps_failed: 0,
235 steps_skipped: 0,
236 duration_ms: elapsed_ms(started),
237 reason: Some(error.clone()),
238 },
239 );
240 }
241 ExecutionReport {
242 results: vec![StepResult {
243 step_id: failed_step.clone(),
244 status: StepStatus::Failed,
245 output: None,
246 error: Some(error),
247 }],
248 completed: false,
249 failed_step: Some(failed_step),
250 canceled: false,
251 }
252 }
253 }
254}
255
256fn execute_inner(
257 plan_path: &Path,
258 ctx: &SchedulerContext<'_>,
259 started: Instant,
260) -> Result<ExecutionReport, (String, String)> {
261 let raw = fs::read_to_string(plan_path).map_err(|error| {
262 (
263 "plan".to_string(),
264 format!("failed to read plan file {}: {error}", plan_path.display()),
265 )
266 })?;
267 let plan: PlanFile = serde_json::from_str(&raw).map_err(|error| {
268 (
269 "plan".to_string(),
270 format!("failed to parse plan JSON: {error}"),
271 )
272 })?;
273
274 let plan_node = ctx
278 .trace
279 .map(|appender| appender.write_plan_start(&plan.id, plan_shape(&plan)));
280
281 if let Err((step_id, error)) = validate_plan(&plan, ctx) {
282 trace_plan_end(
283 ctx,
284 plan_node.as_deref(),
285 PlanEndRecord {
286 plan_id: plan.id.clone(),
287 outcome: "failed",
288 failed_step: Some(step_id.clone()),
289 steps_total: plan.steps.len(),
290 steps_succeeded: 0,
291 steps_failed: 0,
292 steps_skipped: 0,
293 duration_ms: elapsed_ms(started),
294 reason: Some(error.clone()),
295 },
296 );
297 return Ok(ExecutionReport {
298 results: vec![StepResult {
299 step_id: step_id.clone(),
300 status: StepStatus::Failed,
301 output: None,
302 error: Some(error),
303 }],
304 completed: false,
305 failed_step: Some(step_id.clone()),
306 canceled: false,
307 });
308 }
309
310 let cgroup_scope = match crate::cgroup::prepare_scope(
316 crate::cgroup::requires_process_bounding(&ctx.capability_policy, false),
317 &ctx.capability_policy.resources,
318 &plan.id,
319 &ctx.accessible_workdir,
320 ) {
321 Ok(prepared) => prepared.scope,
327 Err(reason) => {
328 let error =
329 crate::errors::RuntimeError::CgroupDelegationUnavailable { reason }.to_string();
330 return Ok(refused_before_any_step(
331 &plan,
332 ctx,
333 plan_node.as_deref(),
334 started,
335 error,
336 ));
337 }
338 };
339 let workdir_guard = Some(crate::resources::WorkdirGuard::spawn(
340 &ctx.accessible_workdir,
341 ctx.capability_policy.resources.workdir_max_bytes,
342 ));
343
344 let shell_enforcement = match sandbox::ShellEnforcement::resolve(
347 &ctx.capability_policy,
348 ctx.capability_policy.containment_floor,
349 sandbox::HostProbe::probe(),
350 ) {
351 Ok(enforcement) => enforcement.with_host_bounding(cgroup_scope, workdir_guard),
352 Err(error) => {
353 let error = format!("failed to resolve shell subprocess sandbox enforcement: {error}");
354 return Ok(refused_before_any_step(
355 &plan,
356 ctx,
357 plan_node.as_deref(),
358 started,
359 error,
360 ));
361 }
362 };
363
364 let mut results = HashMap::<String, StepResult>::new();
365 let mut result_order = Vec::<String>::new();
366 let mut failed_step = None;
367 let mut canceled = false;
368
369 loop {
370 if canceled {
371 for step in &plan.steps {
374 if results.contains_key(&step.id) {
375 continue;
376 }
377 trace_undispatched(
378 ctx,
379 plan_node.as_deref(),
380 &plan.id,
381 step,
382 "skipped",
383 Some(CANCELED_BEFORE_STEP_ERROR.to_string()),
384 );
385 insert_result(
386 &mut results,
387 &mut result_order,
388 StepResult {
389 step_id: step.id.clone(),
390 status: StepStatus::Skipped,
391 output: None,
392 error: Some(CANCELED_BEFORE_STEP_ERROR.to_string()),
393 },
394 );
395 }
396 break;
397 }
398 if results.len() == plan.steps.len() || failed_step.is_some() {
399 break;
400 }
401 if task_canceled(ctx) {
402 canceled = true;
403 continue;
404 }
405
406 let mut ready = Vec::new();
407 let mut progressed = false;
408
409 for step in &plan.steps {
410 if results.contains_key(&step.id) {
411 continue;
412 }
413 if !step.depends_on.iter().all(|dep| results.contains_key(dep)) {
414 continue;
415 }
416
417 match condition::evaluate(step.condition.as_deref(), &results) {
418 Ok(true) => ready.push(step.clone()),
419 Ok(false) => {
420 trace_undispatched(ctx, plan_node.as_deref(), &plan.id, step, "skipped", None);
423 insert_result(
424 &mut results,
425 &mut result_order,
426 StepResult {
427 step_id: step.id.clone(),
428 status: StepStatus::Skipped,
429 output: None,
430 error: None,
431 },
432 );
433 progressed = true;
434 }
435 Err(error) => {
436 trace_undispatched(
437 ctx,
438 plan_node.as_deref(),
439 &plan.id,
440 step,
441 "failed",
442 Some(error.clone()),
443 );
444 insert_result(
445 &mut results,
446 &mut result_order,
447 StepResult {
448 step_id: step.id.clone(),
449 status: StepStatus::Failed,
450 output: None,
451 error: Some(error),
452 },
453 );
454 failed_step = Some(step.id.clone());
455 progressed = true;
456 break;
457 }
458 }
459 }
460
461 if failed_step.is_some() {
462 break;
463 }
464
465 if ready.is_empty() {
466 if progressed {
467 continue;
468 }
469 const BLOCKED: &str = "plan is blocked by a dependency cycle or missing dependency";
470 let blocked_step = plan
471 .steps
472 .iter()
473 .find(|step| !results.contains_key(&step.id));
474 let blocked = blocked_step
475 .map(|step| step.id.clone())
476 .unwrap_or_else(|| "plan".to_string());
477 if let Some(step) = blocked_step {
478 trace_undispatched(
479 ctx,
480 plan_node.as_deref(),
481 &plan.id,
482 step,
483 "failed",
484 Some(BLOCKED.to_string()),
485 );
486 }
487 insert_result(
488 &mut results,
489 &mut result_order,
490 StepResult {
491 step_id: blocked.clone(),
492 status: StepStatus::Failed,
493 output: None,
494 error: Some(BLOCKED.to_string()),
495 },
496 );
497 failed_step = Some(blocked);
498 break;
499 }
500
501 for step in &ready {
502 trace_step_start(ctx, plan_node.as_deref(), &plan.id, step);
503 }
504
505 let mut completed = Vec::new();
506 thread::scope(|scope| {
507 let mut handles = Vec::new();
508 for step in ready {
509 let snapshot = results.clone();
510 let shell_enforcement = &shell_enforcement;
511 handles.push(scope.spawn(move || {
512 execute_step_with_retries(&step, ctx, &snapshot, shell_enforcement)
513 }));
514 }
515 for handle in handles {
516 completed.push(handle.join().unwrap_or_else(|_| {
517 StepOutcome::undispatched(failed("unknown", "step dispatch thread panicked"))
518 }));
519 }
520 });
521 canceled = task_canceled(ctx);
525
526 for outcome in completed {
527 let Some(step) = plan
528 .steps
529 .iter()
530 .find(|step| step.id == outcome.result.step_id)
531 else {
532 trace_step(
534 ctx,
535 plan_node.as_deref(),
536 PlanStepRecord {
537 plan_id: plan.id.clone(),
538 step_id: outcome.result.step_id.clone(),
539 kind: "unknown",
540 status: outcome.result.status.as_str(),
541 attempts: outcome.attempts,
542 duration_ms: outcome.duration_ms,
543 error: outcome.result.error.clone(),
544 input: None,
545 state_effect: None,
546 resource_id: None,
547 },
548 );
549 insert_result(&mut results, &mut result_order, outcome.result);
550 if !canceled {
551 failed_step = Some("unknown".to_string());
552 break;
553 }
554 continue;
555 };
556
557 let kind = step_kind_name(step);
558 let StepOutcome {
559 result,
560 attempts,
561 duration_ms,
562 input,
563 state_effect,
564 resource_id,
565 } = outcome;
566
567 let (settled, stops) = if result.status == StepStatus::Failed && !canceled {
570 match step.on_error.as_str() {
571 "fail" => (result, true),
572 "skip" => (
573 StepResult {
574 step_id: result.step_id,
575 status: StepStatus::Skipped,
576 output: None,
577 error: result.error,
578 },
579 false,
580 ),
581 "continue" => (result, false),
582 policy => (
583 StepResult {
584 error: Some(format!("invalid on_error policy '{policy}'")),
585 ..result
586 },
587 true,
588 ),
589 }
590 } else {
591 (result, false)
592 };
593
594 trace_step(
595 ctx,
596 plan_node.as_deref(),
597 PlanStepRecord {
598 plan_id: plan.id.clone(),
599 step_id: settled.step_id.clone(),
600 kind,
601 status: settled.status.as_str(),
602 attempts,
603 duration_ms,
604 error: settled.error.clone(),
605 input,
606 state_effect,
607 resource_id,
608 },
609 );
610
611 let id = settled.step_id.clone();
612 insert_result(&mut results, &mut result_order, settled);
613 if stops {
614 failed_step = Some(id);
615 break;
616 }
617 }
618 }
619
620 let ordered_results = result_order
621 .into_iter()
622 .filter_map(|id| results.remove(&id))
623 .collect::<Vec<_>>();
624
625 if canceled {
626 failed_step = None;
627 }
628 trace_plan_end(
629 ctx,
630 plan_node.as_deref(),
631 PlanEndRecord {
632 plan_id: plan.id.clone(),
633 outcome: if canceled {
634 "canceled"
635 } else if failed_step.is_none() {
636 "completed"
637 } else {
638 "failed"
639 },
640 failed_step: failed_step.clone(),
641 steps_total: plan.steps.len(),
642 steps_succeeded: count_status(&ordered_results, StepStatus::Success),
643 steps_failed: count_status(&ordered_results, StepStatus::Failed),
644 steps_skipped: count_status(&ordered_results, StepStatus::Skipped),
645 duration_ms: elapsed_ms(started),
646 reason: canceled.then(|| PLAN_CANCELED_REASON.to_string()),
647 },
648 );
649
650 Ok(ExecutionReport {
651 results: ordered_results,
652 completed: !canceled && failed_step.is_none(),
653 failed_step,
654 canceled,
655 })
656}
657
658fn step_kind_name(step: &StepDef) -> &'static str {
664 infer_kind(step).map(StepKind::as_str).unwrap_or("unknown")
665}
666
667fn plan_shape(plan: &PlanFile) -> Vec<PlanStepShape> {
669 plan.steps
670 .iter()
671 .map(|step| PlanStepShape {
672 step_id: step.id.clone(),
673 kind: step_kind_name(step),
674 depends_on: step.depends_on.clone(),
675 has_condition: step.condition.is_some(),
676 })
677 .collect()
678}
679
680fn trace_step_start(
681 ctx: &SchedulerContext<'_>,
682 plan_node: Option<&str>,
683 plan_id: &str,
684 step: &StepDef,
685) {
686 if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
687 appender.write_plan_step_start(
688 node,
689 plan_id,
690 &step.id,
691 step_kind_name(step),
692 step.depends_on.clone(),
693 );
694 }
695}
696
697fn trace_step(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanStepRecord) {
698 if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
699 appender.write_plan_step(node, record);
700 }
701}
702
703fn trace_undispatched(
707 ctx: &SchedulerContext<'_>,
708 plan_node: Option<&str>,
709 plan_id: &str,
710 step: &StepDef,
711 status: &'static str,
712 error: Option<String>,
713) {
714 trace_step(
715 ctx,
716 plan_node,
717 PlanStepRecord {
718 plan_id: plan_id.to_string(),
719 step_id: step.id.clone(),
720 kind: step_kind_name(step),
721 status,
722 attempts: 0,
723 duration_ms: 0,
724 error,
725 input: None,
726 state_effect: None,
727 resource_id: None,
728 },
729 );
730}
731
732fn trace_plan_end(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanEndRecord) {
733 if let Some(appender) = ctx.trace {
734 appender.write_plan_end(plan_node, record);
735 }
736}
737
738fn refused_before_any_step(
742 plan: &PlanFile,
743 ctx: &SchedulerContext<'_>,
744 plan_node: Option<&str>,
745 started: Instant,
746 error: String,
747) -> ExecutionReport {
748 trace_plan_end(
749 ctx,
750 plan_node,
751 PlanEndRecord {
752 plan_id: plan.id.clone(),
753 outcome: "failed",
754 failed_step: Some("plan".to_string()),
755 steps_total: plan.steps.len(),
756 steps_succeeded: 0,
757 steps_failed: 0,
758 steps_skipped: 0,
759 duration_ms: elapsed_ms(started),
760 reason: Some(error.clone()),
761 },
762 );
763 ExecutionReport {
764 results: vec![StepResult {
765 step_id: "plan".to_string(),
766 status: StepStatus::Failed,
767 output: None,
768 error: Some(error),
769 }],
770 completed: false,
771 failed_step: Some("plan".to_string()),
772 canceled: false,
773 }
774}
775
776fn task_canceled(ctx: &SchedulerContext<'_>) -> bool {
778 ctx.task.is_some_and(PlanTask::is_canceled)
779}
780
781fn count_status(results: &[StepResult], status: StepStatus) -> usize {
782 results
783 .iter()
784 .filter(|result| result.status == status)
785 .count()
786}
787
788fn elapsed_ms(started: Instant) -> u64 {
789 started.elapsed().as_millis() as u64
790}
791
792fn validate_plan(plan: &PlanFile, ctx: &SchedulerContext<'_>) -> Result<(), (String, String)> {
793 let mut ids = HashSet::new();
794 for step in &plan.steps {
795 if !ids.insert(step.id.clone()) {
796 return Err((step.id.clone(), format!("duplicate step id '{}'", step.id)));
797 }
798
799 match infer_kind(step) {
800 Ok(StepKind::Tool) => {
801 let tool = step.tool.as_ref().unwrap();
802 if tool == crate::runtime::SUBMIT_PLAN_TOOL {
806 return Err((
807 step.id.clone(),
808 format!(
809 "a plan step cannot submit another plan: '{tool}' is not callable \
810 from inside a plan"
811 ),
812 ));
813 }
814 if tool == crate::runtime::SWITCH_DRIVER_TOOL
818 || tool == crate::runtime::END_WITHOUT_ANSWER_TOOL
819 {
820 return Err((
821 step.id.clone(),
822 format!("'{tool}' is not callable from inside a plan"),
823 ));
824 }
825 if !ctx.installed_tools.contains(tool) {
826 return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
827 }
828 }
829 Ok(StepKind::Shell) => {
830 let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
831 return Err((step.id.clone(), "shell command is empty".to_string()));
832 };
833 if !ctx.capability_policy.shell_allow.contains(&binary) {
834 return Err((
835 step.id.clone(),
836 format!("binary '{binary}' is not in capabilities.shell.allow"),
837 ));
838 }
839 }
840 Ok(StepKind::Spawn) => {
841 if let Err(error) = capsule_task_text(&step.input) {
848 return Err((step.id.clone(), error));
849 }
850 }
851 Err(error) => return Err((step.id.clone(), error)),
852 }
853
854 if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
855 return Err((
856 step.id.clone(),
857 format!("invalid on_error policy '{}'", step.on_error),
858 ));
859 }
860 }
861
862 for step in &plan.steps {
863 for dep in &step.depends_on {
864 if !ids.contains(dep) {
865 return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
866 }
867 }
868 validate_references(step, &ids)?;
869 }
870
871 Ok(())
872}
873
874fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
875 let mut references = Vec::new();
876 collect_references(&step.input, &mut references);
877 if let Some(condition) = &step.condition {
878 for token in condition.split_whitespace() {
879 if token.starts_with('$') {
880 references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
881 }
882 }
883 }
884
885 for reference in references {
886 if let Some((step_id, field)) = parse_reference(&reference) {
887 if !ids.contains(step_id) {
888 return Err((step.id.clone(), format!("unknown reference '{reference}'")));
889 }
890 if field != "output" && field != "status" {
891 return Err((
892 step.id.clone(),
893 format!("unknown reference field '{field}'"),
894 ));
895 }
896 }
897 }
898
899 Ok(())
900}
901
902fn collect_references(value: &Value, references: &mut Vec<String>) {
903 match value {
904 Value::String(value) if value.starts_with('$') => references.push(value.clone()),
905 Value::Array(values) => values
906 .iter()
907 .for_each(|value| collect_references(value, references)),
908 Value::Object(map) => map
909 .values()
910 .for_each(|value| collect_references(value, references)),
911 _ => {}
912 }
913}
914
915struct StepOutcome {
921 result: StepResult,
922 attempts: u32,
923 duration_ms: u64,
924 input: Option<Value>,
927 state_effect: Option<String>,
930 resource_id: Option<String>,
931}
932
933impl StepOutcome {
934 fn plain(result: StepResult, started: Instant) -> Self {
937 Self {
938 result,
939 attempts: 1,
940 duration_ms: elapsed_ms(started),
941 input: None,
942 state_effect: None,
943 resource_id: None,
944 }
945 }
946
947 fn undispatched(result: StepResult) -> Self {
949 Self {
950 result,
951 attempts: 0,
952 duration_ms: 0,
953 input: None,
954 state_effect: None,
955 resource_id: None,
956 }
957 }
958}
959
960fn execute_step_with_retries(
961 step: &StepDef,
962 ctx: &SchedulerContext<'_>,
963 results: &HashMap<String, StepResult>,
964 enforcement: &sandbox::ShellEnforcement,
965) -> StepOutcome {
966 let started = Instant::now();
967 let attempts = step.retries.saturating_add(1);
968 let mut last = None;
969 for attempt in 1..=attempts {
970 let mut outcome = execute_step_once(step, ctx, results, enforcement);
971 outcome.attempts = attempt;
972 outcome.duration_ms = elapsed_ms(started);
973 if outcome.result.status != StepStatus::Failed || task_canceled(ctx) {
975 return outcome;
976 }
977 last = Some(outcome);
978 }
979
980 last.unwrap_or_else(|| StepOutcome::undispatched(failed(&step.id, "step was not attempted")))
981}
982
983fn execute_step_once(
984 step: &StepDef,
985 ctx: &SchedulerContext<'_>,
986 results: &HashMap<String, StepResult>,
987 enforcement: &sandbox::ShellEnforcement,
988) -> StepOutcome {
989 let started = Instant::now();
990 let mut input = step.input.clone();
991 if let Err(error) = interpolate_value(&mut input, results) {
992 return StepOutcome::plain(failed(&step.id, error), started);
993 }
994 let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
995
996 match infer_kind(step) {
997 Ok(StepKind::Tool) => {
998 let (result, state_effect, resource_id) = dispatch_tool_step(step, ctx, input_json);
999 StepOutcome {
1000 result,
1001 attempts: 1,
1002 duration_ms: elapsed_ms(started),
1003 input: (!input.is_null()).then_some(input),
1006 state_effect,
1007 resource_id,
1008 }
1009 }
1010 Ok(StepKind::Shell) => {
1011 StepOutcome::plain(dispatch_shell_step(step, ctx, enforcement), started)
1012 }
1013 Ok(StepKind::Spawn) => StepOutcome::plain(dispatch_capsule_step(step, ctx, input), started),
1014 Err(error) => StepOutcome::plain(failed(&step.id, error), started),
1015 }
1016}
1017
1018fn dispatch_tool_step(
1022 step: &StepDef,
1023 ctx: &SchedulerContext<'_>,
1024 input_json: String,
1025) -> (StepResult, Option<String>, Option<String>) {
1026 let name = step.tool.as_deref().unwrap_or_default();
1027 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Tool {
1028 name,
1029 input_json: &input_json,
1030 }) {
1031 return (failed(&step.id, refusal), None, None);
1032 }
1033 match (ctx.invoke_tool)(
1034 name,
1035 ToolInput {
1036 data: Some(input_json),
1037 log_path: None,
1038 },
1039 ) {
1040 Ok(result) => {
1041 let state_effect = crate::agent::extract_state_effect(&result.metadata);
1042 let resource_id = crate::agent::extract_resource_id(&result.metadata);
1043 let verdict = if matches!(result.status, ToolStatus::Passed) {
1044 match tool_step_output(
1045 name,
1046 &ctx.accessible_workdir,
1047 result.data,
1048 result.data_path.as_deref(),
1049 ) {
1050 Ok(output) => StepResult {
1051 step_id: step.id.clone(),
1052 status: StepStatus::Success,
1053 output,
1054 error: None,
1055 },
1056 Err(error) => failed(&step.id, error),
1057 }
1058 } else {
1059 failed(
1060 &step.id,
1061 result
1062 .summary
1063 .or(result.data)
1064 .unwrap_or_else(|| "tool step failed".to_string()),
1065 )
1066 };
1067 (verdict, state_effect, resource_id)
1068 }
1069 Err(error) => (failed(&step.id, error), None, None),
1070 }
1071}
1072
1073fn dispatch_shell_step(
1074 step: &StepDef,
1075 ctx: &SchedulerContext<'_>,
1076 enforcement: &sandbox::ShellEnforcement,
1077) -> StepResult {
1078 let command = step.shell.as_deref().unwrap_or_default();
1079 let Some((binary, args)) = parse_shell_command(command) else {
1080 return failed(&step.id, "shell command is empty");
1081 };
1082 if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Shell {
1083 binary: &binary,
1084 command,
1085 argv: &args,
1086 }) {
1087 return failed(&step.id, refusal);
1088 }
1089 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
1090
1091 match execute_shell(
1092 &binary,
1093 &arg_refs,
1094 &[],
1095 &ctx.accessible_workdir,
1096 &ctx.session_workdir,
1097 &ctx.capability_policy,
1098 enforcement,
1099 ) {
1100 Ok(result) if result.exit_code == 0 => StepResult {
1101 step_id: step.id.clone(),
1102 status: StepStatus::Success,
1103 output: Some(result.stdout),
1104 error: None,
1105 },
1106 Ok(result) => match result.resource_limit_hit {
1111 Some(limit) => failed(
1112 &step.id,
1113 crate::errors::RuntimeError::ShellResourceLimitExceeded {
1114 binary: result.binary,
1115 limit,
1116 detail: if result.stderr.trim().is_empty() {
1117 format!("exit code {}", result.exit_code)
1118 } else {
1119 result.stderr
1120 },
1121 }
1122 .to_string(),
1123 ),
1124 None if result.stderr.trim().is_empty() => {
1129 failed(&step.id, format!("exit code {}", result.exit_code))
1130 }
1131 None => failed(&step.id, result.stderr),
1132 },
1133 Err(error) => failed(&step.id, error.to_string()),
1138 }
1139}
1140
1141fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1142 let capsule = step.capsule.as_deref().unwrap_or_default();
1143 let task = match capsule_task_text(&input) {
1146 Ok(task) => task,
1147 Err(error) => return failed(&step.id, error),
1148 };
1149 let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1150 Ok(value) if !value.trim().is_empty() => value,
1151 _ => {
1152 return failed(
1153 &step.id,
1154 "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1155 )
1156 }
1157 };
1158 let Some(credential) = ctx.spawn_credential.as_ref() else {
1159 return failed(
1160 &step.id,
1161 "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1162 mints it only for a session whose manifest declares capabilities.spawn.allow",
1163 );
1164 };
1165
1166 let plane = DelegationPlane::new(
1173 roost_url,
1174 credential.clone(),
1175 ctx.accessible_workdir.clone(),
1176 ctx.current_session_id.clone().unwrap_or_default(),
1177 crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1178 std::sync::Arc::clone(&ctx.registry),
1179 ctx.capability_policy.env_allow.clone(),
1180 ctx.formation_id.clone(),
1181 );
1182 let (trace, plan_task) = (ctx.trace, ctx.task);
1188 let launched = (trace.is_some() || plan_task.is_some()).then(|| {
1189 std::sync::Arc::new(move |notice: DelegationLaunch| {
1190 if let Some(trace) = trace {
1191 trace.write_delegation_start(
1192 ¬ice.delegation_id,
1193 ¬ice.capsule,
1194 ¬ice.version,
1195 ¬ice.child_session_id,
1196 ¬ice.child_workdir,
1197 );
1198 }
1199 if let Some(plan_task) = plan_task {
1200 plan_task.delegation_started(¬ice);
1201 }
1202 }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1203 });
1204 let stop = plan_task.map(|plan_task| {
1205 std::sync::Arc::new(move || plan_task.is_canceled())
1206 as std::sync::Arc<dyn Fn() -> bool + Send + Sync>
1207 });
1208 let started = Instant::now();
1209 let result = plane.delegate(
1210 &DelegationRequest {
1211 capsule: capsule.to_string(),
1212 version: ctx
1215 .capsule_versions
1216 .get(capsule)
1217 .cloned()
1218 .unwrap_or_else(|| "0.1.0".to_string()),
1219 task,
1220 },
1221 &DelegationOrigin {
1222 context_id: ctx.current_context_id.clone().unwrap_or_default(),
1223 launched,
1224 stop,
1225 },
1226 );
1227
1228 if result.status == DelegationStatus::Canceled {
1231 return failed(&step.id, CANCELED_STEP_ERROR);
1232 }
1233 let closes_row = match plan_task {
1236 Some(plan_task) if !result.delegation_id.is_empty() => {
1237 plan_task.delegation_settled(&result.delegation_id)
1238 }
1239 _ => true,
1240 };
1241 if let Some(trace) = ctx.trace.filter(|_| closes_row) {
1242 trace.write_delegation(
1243 &result.capsule,
1244 &result.version,
1245 Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1248 Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1249 elapsed_ms(started),
1250 result.status.as_str(),
1251 match result.status {
1252 DelegationStatus::Completed => None,
1253 _ => Some(result.output.clone()),
1254 },
1255 );
1256 }
1257
1258 match result.status {
1259 DelegationStatus::Completed => StepResult {
1260 step_id: step.id.clone(),
1261 status: StepStatus::Success,
1262 output: Some(result.output),
1263 error: None,
1264 },
1265 _ => failed(&step.id, result.output),
1266 }
1267}
1268
1269fn capsule_task_text(input: &Value) -> Result<String, String> {
1288 const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1289 const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1290 receives, given as a string or as { \"objective\": \"<text>\" }";
1291
1292 match input {
1293 Value::String(text) => Ok(text.clone()),
1294 Value::Object(map) => {
1295 let mut unknown = map
1298 .keys()
1299 .filter(|key| key.as_str() != "objective")
1300 .map(String::as_str)
1301 .collect::<Vec<_>>();
1302 unknown.sort_unstable();
1303 if !unknown.is_empty() {
1304 return Err(format!(
1305 "capsule step input has keys the child cannot receive: {}; nothing on the \
1306 receiving side parses a task envelope, so put the whole instruction in \
1307 \"objective\"",
1308 unknown.join(", ")
1309 ));
1310 }
1311
1312 match map.get("objective") {
1313 Some(Value::String(text)) => Ok(text.clone()),
1314 Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1315 None => Err(ACCEPTS.to_string()),
1316 }
1317 }
1318 Value::Null => Err(NO_INPUT.to_string()),
1319 Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1320 Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1321 Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1322 }
1323}
1324
1325fn interpolate_value(
1326 value: &mut Value,
1327 results: &HashMap<String, StepResult>,
1328) -> Result<(), String> {
1329 match value {
1330 Value::String(text) if text.starts_with('$') => {
1331 *text = condition::resolve_reference(text, results)?;
1332 }
1333 Value::Array(values) => {
1334 for value in values {
1335 interpolate_value(value, results)?;
1336 }
1337 }
1338 Value::Object(map) => {
1339 for value in map.values_mut() {
1340 interpolate_value(value, results)?;
1341 }
1342 }
1343 _ => {}
1344 }
1345
1346 Ok(())
1347}
1348
1349fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1350 let count =
1351 step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1352 match count {
1353 1 if step.tool.is_some() => Ok(StepKind::Tool),
1354 1 if step.shell.is_some() => Ok(StepKind::Shell),
1355 1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1356 0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1357 _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1358 }
1359}
1360
1361fn default_on_error() -> String {
1362 "fail".to_string()
1363}
1364
1365fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1366 StepResult {
1367 step_id: step_id.to_string(),
1368 status: StepStatus::Failed,
1369 output: None,
1370 error: Some(error.into()),
1371 }
1372}
1373
1374fn insert_result(
1375 results: &mut HashMap<String, StepResult>,
1376 order: &mut Vec<String>,
1377 result: StepResult,
1378) {
1379 order.push(result.step_id.clone());
1380 results.insert(result.step_id.clone(), result);
1381}
1382
1383fn shell_binary(command: &str) -> Option<String> {
1384 parse_shell_command(command).map(|(binary, _)| binary)
1385}
1386
1387fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1388 let parts = split_shell_words(command);
1389 let (binary, args) = parts.split_first()?;
1390 Some((binary.clone(), args.to_vec()))
1391}
1392
1393fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1394 reference.strip_prefix('$')?.rsplit_once('.')
1395}
1396
1397fn tool_step_output(
1414 tool: &str,
1415 workdir: &Path,
1416 data: Option<String>,
1417 data_path: Option<&str>,
1418) -> Result<Option<String>, String> {
1419 if let Some(data) = data {
1420 return Ok(Some(data));
1421 }
1422 let Some(relpath) = data_path else {
1423 return Ok(None);
1424 };
1425 match resource_plane::read_file_beneath_root(
1426 workdir,
1427 relpath,
1428 DEFAULT_EXPORT_MAX_BYTES,
1429 SymlinkPolicy::Refuse,
1430 ) {
1431 Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1432 Err(error) => Err(RuntimeError::ToolDataPathRefused {
1433 tool: tool.to_string(),
1434 path: relpath.to_string(),
1435 code: error.code().to_string(),
1436 detail: error.message(),
1437 }
1438 .to_string()),
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use std::{
1445 sync::{
1446 atomic::{AtomicUsize, Ordering},
1447 Arc, Condvar, Mutex,
1448 },
1449 time::Duration,
1450 };
1451
1452 use serde_json::json;
1453 use tempfile::tempdir;
1454
1455 use super::*;
1456
1457 fn test_ctx<'a>(
1461 workdir: PathBuf,
1462 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1463 ) -> SchedulerContext<'a> {
1464 test_ctx_gated(workdir, invoke_tool, &UNGATED)
1465 }
1466
1467 const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1470
1471 fn test_ctx_gated<'a>(
1472 workdir: PathBuf,
1473 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1474 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1475 ) -> SchedulerContext<'a> {
1476 SchedulerContext {
1477 accessible_workdir: workdir.clone(),
1478 session_workdir: workdir,
1479 capability_policy: CapabilityPolicy {
1480 shell_allow: vec!["bash".to_string()],
1481 spawn_allow: vec!["worker".to_string()],
1482 ..CapabilityPolicy::default()
1483 },
1484 installed_tools: HashSet::from([
1485 "ok".to_string(),
1486 "fail".to_string(),
1487 "slow".to_string(),
1488 "a".to_string(),
1489 "b".to_string(),
1490 ]),
1491 capsule_versions: HashMap::new(),
1492 current_session_id: None,
1493 current_context_id: None,
1494 registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1496 tempdir().unwrap().keep(),
1497 )),
1498 spawn_credential: None,
1499 formation_id: None,
1500 trace: None,
1501 gate_step,
1502 invoke_tool,
1503 task: None,
1504 }
1505 }
1506
1507 fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1508 ToolResult {
1509 status,
1510 summary: None,
1511 data,
1512 data_path: None,
1513 truncated: false,
1514 metadata: Vec::new(),
1515 }
1516 }
1517
1518 fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1519 let path = workdir.join("plan.json");
1520 fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1521 path
1522 }
1523
1524 #[test]
1527 fn a_turn_s_own_decisions_are_not_callable_from_a_plan() {
1528 let dir = tempdir().unwrap();
1529 let invoke = |_: &str, _: ToolInput| -> Result<ToolResult, String> {
1530 panic!("a refused plan ran a step")
1531 };
1532 let ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1533 for tool in [
1534 crate::runtime::SWITCH_DRIVER_TOOL,
1535 crate::runtime::END_WITHOUT_ANSWER_TOOL,
1536 ] {
1537 let plan: PlanFile = serde_json::from_value(json!({
1538 "id": "p", "steps": [{"id": "s1", "tool": tool, "input": {"reason": "none"}}],
1539 }))
1540 .unwrap();
1541 assert_eq!(
1542 validate_plan(&plan, &ctx).unwrap_err(),
1543 (
1544 "s1".to_string(),
1545 format!("'{tool}' is not callable from inside a plan")
1546 )
1547 );
1548 }
1549 }
1550
1551 #[test]
1552 fn capsule_task_text_delivers_a_bare_string_verbatim() {
1553 assert_eq!(
1554 capsule_task_text(&json!("do the thing")).unwrap(),
1555 "do the thing"
1556 );
1557 }
1558
1559 #[test]
1560 fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1561 assert_eq!(
1562 capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1563 "do the thing"
1564 );
1565 }
1566
1567 #[test]
1570 fn capsule_task_text_accepts_empty_task_text() {
1571 assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1572 assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1573 }
1574
1575 #[test]
1576 fn capsule_task_text_refuses_a_task_envelope() {
1577 let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1578 assert!(
1579 error.contains("keys the child cannot receive: task"),
1580 "{error}"
1581 );
1582 assert!(error.contains("\"objective\""), "{error}");
1583 }
1584
1585 #[test]
1586 fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1587 let error =
1588 capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1589 .unwrap_err();
1590 assert!(
1591 error.contains("keys the child cannot receive: instructions, render_as"),
1592 "{error}"
1593 );
1594 }
1595
1596 #[test]
1597 fn capsule_task_text_refuses_a_non_string_objective() {
1598 for input in [json!({"objective": 7}), json!({"objective": null})] {
1599 assert_eq!(
1600 capsule_task_text(&input).unwrap_err(),
1601 "capsule step input \"objective\" must be a string"
1602 );
1603 }
1604 }
1605
1606 #[test]
1607 fn capsule_task_text_refuses_an_object_without_an_objective() {
1608 assert_eq!(
1609 capsule_task_text(&json!({})).unwrap_err(),
1610 "capsule step input must be a string or { \"objective\": \"<text>\" }"
1611 );
1612 }
1613
1614 #[test]
1615 fn capsule_task_text_names_the_type_it_was_handed() {
1616 assert!(capsule_task_text(&json!(7))
1617 .unwrap_err()
1618 .ends_with("not a number"));
1619 assert!(capsule_task_text(&json!(true))
1620 .unwrap_err()
1621 .ends_with("not a boolean"));
1622 assert!(capsule_task_text(&json!(["a"]))
1623 .unwrap_err()
1624 .ends_with("not an array"));
1625 }
1626
1627 #[test]
1630 fn capsule_task_text_refuses_an_absent_input() {
1631 let error = capsule_task_text(&Value::Null).unwrap_err();
1632 assert_eq!(
1633 error,
1634 "capsule step has no input; its input is the task text the child receives, given as \
1635 a string or as { \"objective\": \"<text>\" }"
1636 );
1637 }
1638
1639 #[test]
1642 fn capsule_task_text_accepts_an_uninterpolated_reference() {
1643 assert_eq!(
1644 capsule_task_text(&json!("$analyse.output")).unwrap(),
1645 "$analyse.output"
1646 );
1647 assert_eq!(
1648 capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1649 "$analyse.output"
1650 );
1651 }
1652
1653 fn test_ctx_unbounded<'a>(
1656 workdir: PathBuf,
1657 invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1658 gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1659 ) -> SchedulerContext<'a> {
1660 SchedulerContext {
1661 capability_policy: CapabilityPolicy::default(),
1662 ..test_ctx_gated(workdir, invoke_tool, gate_step)
1663 }
1664 }
1665
1666 #[test]
1671 fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1672 let dir = tempdir().unwrap();
1673 let dispatched = Arc::new(AtomicUsize::new(0));
1674 let counted = Arc::clone(&dispatched);
1675 let invoke = move |_name: &str, _input: ToolInput| {
1676 counted.fetch_add(1, Ordering::SeqCst);
1677 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1678 };
1679 let gate = |call: &PlannedCall<'_>| match call {
1680 PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1681 _ => None,
1682 };
1683 let plan = write_plan(
1684 dir.path(),
1685 json!({
1686 "id": "p",
1687 "steps": [
1688 {"id": "refused", "tool": "a", "on_error": "continue"},
1689 {"id": "allowed", "tool": "b"}
1690 ]
1691 }),
1692 );
1693
1694 let report = execute(
1695 &plan,
1696 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1697 );
1698 let refused = report
1699 .results
1700 .iter()
1701 .find(|result| result.step_id == "refused")
1702 .expect("the refused step settled");
1703 assert_eq!(refused.status, StepStatus::Failed);
1704 assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1705 assert_eq!(refused.output, None);
1706 assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1708 }
1709
1710 #[test]
1713 fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1714 if crate::cgroup::skip_without_host_support(
1715 "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1716 ) {
1717 return;
1718 }
1719 let dir = tempdir().unwrap();
1720 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1721 let plan = write_plan(
1722 dir.path(),
1723 json!({
1724 "id": "p",
1725 "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1726 }),
1727 );
1728
1729 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1730 assert!(!report.completed);
1731 assert_eq!(report.results[0].status, StepStatus::Failed);
1732 assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1733 }
1734
1735 #[test]
1737 fn a_refused_shell_step_starts_no_subprocess() {
1738 if crate::cgroup::skip_without_host_support(
1739 "plan::tests::a_refused_shell_step_starts_no_subprocess",
1740 ) {
1741 return;
1742 }
1743 let dir = tempdir().unwrap();
1744 let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1745 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1746 let recorded = Arc::clone(&seen);
1747 let gate = move |call: &PlannedCall<'_>| match call {
1748 PlannedCall::Shell { binary, argv, .. } => {
1749 recorded
1750 .lock()
1751 .unwrap()
1752 .push((binary.to_string(), argv.to_vec()));
1753 Some("refused by the manifest".to_string())
1754 }
1755 _ => None,
1756 };
1757 let marker = dir.path().join("written-by-a-refused-step");
1758 let plan = write_plan(
1759 dir.path(),
1760 json!({
1761 "id": "p",
1762 "steps": [{
1763 "id": "w",
1764 "shell": format!("bash -c 'printf x > {}'", marker.display()),
1765 }]
1766 }),
1767 );
1768
1769 let report = execute(
1770 &plan,
1771 &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
1772 );
1773 assert!(!report.completed);
1774 assert_eq!(report.failed_step.as_deref(), Some("w"));
1775 assert_eq!(
1776 report.results[0].error.as_deref(),
1777 Some("refused by the manifest")
1778 );
1779 assert!(!marker.exists(), "a refused step must not have run");
1780 let seen = seen.lock().unwrap();
1782 assert_eq!(seen.len(), 1);
1783 assert_eq!(seen[0].0, "bash");
1784 assert_eq!(seen[0].1[0], "-c");
1785 }
1786
1787 #[test]
1788 fn dependent_step_receives_upstream_output() {
1789 if crate::cgroup::skip_without_host_support(
1790 "plan::tests::dependent_step_receives_upstream_output",
1791 ) {
1792 return;
1793 }
1794 let dir = tempdir().unwrap();
1795 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1796 let seen_tool = Arc::clone(&seen);
1797 let invoke = move |_name: &str, input: ToolInput| {
1798 seen_tool
1799 .lock()
1800 .unwrap()
1801 .push(input.data.unwrap_or_default());
1802 Ok(tool_result(
1803 ToolStatus::Passed,
1804 Some("upstream".to_string()),
1805 ))
1806 };
1807 let plan = write_plan(
1808 dir.path(),
1809 json!({
1810 "id": "p",
1811 "steps": [
1812 {"id": "a", "tool": "ok"},
1813 {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
1814 ]
1815 }),
1816 );
1817
1818 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1819 assert!(report.completed);
1820 assert_eq!(report.results.len(), 2);
1821 assert!(seen.lock().unwrap()[1].contains("upstream"));
1822 }
1823
1824 #[test]
1825 fn parallel_tool_steps_run_concurrently() {
1826 if crate::cgroup::skip_without_host_support(
1827 "plan::tests::parallel_tool_steps_run_concurrently",
1828 ) {
1829 return;
1830 }
1831 const RENDEZVOUS_GUARD: Duration = Duration::from_secs(30);
1834
1835 #[derive(Default)]
1838 struct Rendezvous {
1839 in_flight: usize,
1840 met: bool,
1841 }
1842
1843 let dir = tempdir().unwrap();
1844 let rendezvous = Arc::new((Mutex::new(Rendezvous::default()), Condvar::new()));
1845 let met_sibling = Arc::new(AtomicUsize::new(0));
1846 let invoke_rendezvous = Arc::clone(&rendezvous);
1847 let invoke_met = Arc::clone(&met_sibling);
1848 let invoke = move |_name: &str, _input: ToolInput| {
1849 let (state, changed) = &*invoke_rendezvous;
1850 let mut state = state.lock().unwrap();
1851 state.in_flight += 1;
1852 if state.in_flight == 2 {
1853 state.met = true;
1854 }
1855 changed.notify_all();
1856 let (mut state, _) = changed
1857 .wait_timeout_while(state, RENDEZVOUS_GUARD, |state| !state.met)
1858 .unwrap();
1859 if state.met {
1860 invoke_met.fetch_add(1, Ordering::SeqCst);
1861 }
1862 state.in_flight -= 1;
1863 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1864 };
1865 let plan = write_plan(
1866 dir.path(),
1867 json!({
1868 "id": "p",
1869 "steps": [
1870 {"id": "a", "tool": "slow"},
1871 {"id": "b", "tool": "slow"}
1872 ]
1873 }),
1874 );
1875
1876 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1877 assert!(report.completed);
1878 assert_eq!(
1879 met_sibling.load(Ordering::SeqCst),
1880 2,
1881 "the two independent steps never ran at the same time"
1882 );
1883 }
1884
1885 #[test]
1886 fn on_error_and_retries_are_applied() {
1887 if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
1888 {
1889 return;
1890 }
1891 let dir = tempdir().unwrap();
1892 let calls = Arc::new(AtomicUsize::new(0));
1893 let invoke_calls = Arc::clone(&calls);
1894 let invoke = move |name: &str, _input: ToolInput| {
1895 invoke_calls.fetch_add(1, Ordering::SeqCst);
1896 if name == "a" {
1897 Ok(tool_result(ToolStatus::Failed, None))
1898 } else {
1899 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1900 }
1901 };
1902 let plan = write_plan(
1903 dir.path(),
1904 json!({
1905 "id": "p",
1906 "steps": [
1907 {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
1908 {"id": "b", "tool": "b", "depends_on": ["a"]}
1909 ]
1910 }),
1911 );
1912
1913 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1914 assert!(report.completed);
1915 assert_eq!(calls.load(Ordering::SeqCst), 4);
1916 }
1917
1918 #[test]
1919 fn condition_false_skips_step() {
1920 if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
1921 return;
1922 }
1923 let dir = tempdir().unwrap();
1924 let invoke = |_name: &str, _input: ToolInput| {
1925 Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1926 };
1927 let plan = write_plan(
1928 dir.path(),
1929 json!({
1930 "id": "p",
1931 "steps": [
1932 {"id": "a", "tool": "ok"},
1933 {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
1934 ]
1935 }),
1936 );
1937
1938 let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1939 assert!(report.completed);
1940 assert_eq!(report.results[1].status, StepStatus::Skipped);
1941 }
1942
1943 #[derive(Default)]
1946 struct FlaggedTask(std::sync::atomic::AtomicBool);
1947
1948 impl PlanTask for FlaggedTask {
1949 fn is_canceled(&self) -> bool {
1950 self.0.load(Ordering::SeqCst)
1951 }
1952 fn delegation_started(&self, _launch: &DelegationLaunch) {}
1953 fn delegation_settled(&self, _delegation_id: &str) -> bool {
1954 true
1955 }
1956 }
1957
1958 #[test]
1961 fn a_cancelled_task_tries_no_step_again() {
1962 let dir = tempdir().unwrap();
1963 let task = FlaggedTask::default();
1964 let calls = AtomicUsize::new(0);
1965 let invoke = |_name: &str, _input: ToolInput| {
1966 calls.fetch_add(1, Ordering::SeqCst);
1967 task.0.store(true, Ordering::SeqCst);
1968 Ok(tool_result(ToolStatus::Failed, None))
1969 };
1970 let plan = write_plan(
1971 dir.path(),
1972 json!({
1973 "id": "p",
1974 "steps": [
1975 {"id": "a", "tool": "a", "retries": 2, "on_error": "fail"},
1976 {"id": "b", "tool": "b", "depends_on": ["a"]}
1977 ]
1978 }),
1979 );
1980
1981 let ctx = SchedulerContext {
1982 task: Some(&task),
1983 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
1984 };
1985 let report = execute(&plan, &ctx);
1986
1987 assert_eq!(calls.load(Ordering::SeqCst), 1, "{report:?}");
1988 assert!(report.canceled, "{report:?}");
1989 assert!(!report.completed);
1990 assert_eq!(report.failed_step, None);
1991 assert_eq!(report.results[0].status, StepStatus::Failed);
1992 assert_eq!(report.results[1].status, StepStatus::Skipped);
1993 assert_eq!(
1994 report.results[1].error.as_deref(),
1995 Some(CANCELED_BEFORE_STEP_ERROR)
1996 );
1997 }
1998
1999 #[test]
2001 fn a_task_cancelled_before_the_plan_ran_dispatches_nothing() {
2002 let dir = tempdir().unwrap();
2003 let task = FlaggedTask::default();
2004 task.0.store(true, Ordering::SeqCst);
2005 let calls = AtomicUsize::new(0);
2006 let invoke = |_name: &str, _input: ToolInput| {
2007 calls.fetch_add(1, Ordering::SeqCst);
2008 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2009 };
2010 let plan = write_plan(
2011 dir.path(),
2012 json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2013 );
2014
2015 let ctx = SchedulerContext {
2016 task: Some(&task),
2017 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2018 };
2019 let report = execute(&plan, &ctx);
2020
2021 assert_eq!(calls.load(Ordering::SeqCst), 0);
2022 assert!(report.canceled && !report.completed, "{report:?}");
2023 assert!(report
2024 .results
2025 .iter()
2026 .all(|result| result.status == StepStatus::Skipped));
2027 }
2028
2029 #[test]
2031 fn a_task_nobody_cancels_changes_nothing() {
2032 let dir = tempdir().unwrap();
2033 let task = FlaggedTask::default();
2034 let invoke = |_name: &str, _input: ToolInput| {
2035 Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2036 };
2037 let plan = write_plan(
2038 dir.path(),
2039 json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2040 );
2041
2042 let without = execute(
2043 &plan,
2044 &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED),
2045 );
2046 let with = execute(
2047 &plan,
2048 &SchedulerContext {
2049 task: Some(&task),
2050 ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2051 },
2052 );
2053 assert_eq!(with, without);
2054 assert!(with.completed && !with.canceled);
2055 }
2056}