Skip to main content

capsule_runtime/
plan.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::{Path, PathBuf},
5    thread,
6    time::Instant,
7};
8
9use serde::Deserialize;
10use serde_json::Value;
11
12use murmur_artifact::DEFAULT_EXPORT_MAX_BYTES;
13
14use crate::{
15    bindings::host::murmur::tool::run::{Status as ToolStatus, ToolInput, ToolResult},
16    delegation_plane::{DelegationOrigin, DelegationPlane, DelegationRequest, DelegationStatus},
17    errors::RuntimeError,
18    resource_plane::{self, SymlinkPolicy},
19    sandbox,
20    shell::{execute_shell, split_shell_words},
21    spawn_credential::SpawnCredential,
22    trace::{PlanEndRecord, PlanStepRecord, PlanStepShape, PlanTraceAppender},
23    types::CapabilityPolicy,
24};
25
26pub mod condition;
27
28#[derive(Debug, Deserialize)]
29struct PlanFile {
30    id: String,
31    steps: Vec<StepDef>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35struct StepDef {
36    id: String,
37    tool: Option<String>,
38    shell: Option<String>,
39    capsule: Option<String>,
40    #[serde(default)]
41    input: Value,
42    #[serde(default)]
43    depends_on: Vec<String>,
44    #[serde(rename = "if")]
45    condition: Option<String>,
46    #[serde(default = "default_on_error")]
47    on_error: String,
48    #[serde(default)]
49    retries: u32,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum StepKind {
54    Tool,
55    Shell,
56    Spawn,
57}
58
59impl StepKind {
60    /// The name a trace line carries for this kind. `"capsule"` rather than `"spawn"`: a trace
61    /// names the plan key its author wrote, not the host's word for what it does.
62    fn as_str(self) -> &'static str {
63        match self {
64            StepKind::Tool => "tool",
65            StepKind::Shell => "shell",
66            StepKind::Spawn => "capsule",
67        }
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum StepStatus {
73    Success,
74    Failed,
75    Skipped,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct StepResult {
80    pub step_id: String,
81    pub status: StepStatus,
82    pub output: Option<String>,
83    pub error: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ExecutionReport {
88    pub results: Vec<StepResult>,
89    pub completed: bool,
90    pub failed_step: Option<String>,
91}
92
93pub struct SchedulerContext<'a> {
94    /// The directory a `shell` step runs in and a `tool` step resolves a `data_path` against —
95    /// the capsule's own workdir, which under `--workdir` is the operator's directory.
96    pub accessible_workdir: PathBuf,
97    /// `<accessible_workdir>/.murmur/<session id>/` under `--workdir`, and the same path as
98    /// [`Self::accessible_workdir`] without it. Where a `shell` step's synthetic `$HOME` and, on
99    /// a sealed host, its `/tmp` and `/etc` staging live.
100    pub session_workdir: PathBuf,
101    pub capability_policy: CapabilityPolicy,
102    pub installed_tools: HashSet<String>,
103    pub capsule_versions: HashMap<String, String>,
104    pub current_session_id: Option<String>,
105    /// The conversation the task that started this plan is running in, named on the
106    /// `MURMUR_SPAWNER` handle every `capsule` step injects into its child.
107    ///
108    /// Nothing is minted here. `None` names no conversation and injects no handle at all, which
109    /// is the existing behaviour for a caller that has none, and not an error: a child told it
110    /// was spawned from a conversation that does not exist is worse than one told nothing.
111    pub current_context_id: Option<String>,
112    /// Where a `capsule` step reads a sub-capsule's manifest from, to learn which host variables
113    /// that sub-capsule declares. The session's own store, so a step resolves the artifact its
114    /// child will run.
115    pub registry: std::sync::Arc<dyn murmur_artifact::Registry>,
116    /// The credential this session presents to `mur-roost` when a `capsule` step delegates.
117    ///
118    /// `None` for every session that was not granted `capabilities.spawn.allow`, and a `capsule`
119    /// step in such a session fails before any request is made. The token is held here and read
120    /// exactly twice — into the two request headers below — so nothing that formats this context,
121    /// a step result or a trace record can carry it.
122    pub spawn_credential: Option<SpawnCredential>,
123    /// Where this run's per-step lifecycle records and its `capsule` steps' delegation records
124    /// go, or `None` to record nothing.
125    ///
126    /// Emission is opt-in at the call site: with `None` no file is opened and no line is
127    /// written, and the run's `ExecutionReport` is identical either way.
128    ///
129    /// A `capsule` step writes into it twice over, and the two pairs describe different things:
130    /// the plan-step pair is the scheduler's unit of work — its dependencies, its attempts, its
131    /// status after `on_error` — and the delegation pair is one child launch, named by its `dlg_`
132    /// id. A retried step is one plan step and several delegations.
133    pub trace: Option<&'a PlanTraceAppender>,
134    /// The session's own pre-dispatch decision point, asked about every `tool` and `shell` step
135    /// before it runs. `Some(reason)` fails that step with `reason` as its error and dispatches
136    /// nothing.
137    ///
138    /// A plan step is a call the model made, one indirection removed, so it is subject to the
139    /// same two refusals a direct call is: `capabilities.filesystem.read_only` and a
140    /// `commit_policy: deny` policy hook. Without this the scheduler would reach the session's
141    /// executors underneath both, and a command the session refuses would run as a plan step.
142    ///
143    /// A `capsule` step is not asked about: it runs nothing in this session, and the child it
144    /// delegates to applies its own manifest to whatever it does.
145    pub gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
146    pub invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
147}
148
149/// What a step is about to dispatch, as [`SchedulerContext::gate_step`] is shown it.
150///
151/// The scheduler holds no session state and resolves nothing itself: it hands over the same
152/// values it is about to hand the executor, and the gate turns them into whatever its own
153/// decision point reads.
154pub enum PlannedCall<'a> {
155    Tool {
156        name: &'a str,
157        /// The step's `input`, interpolated, exactly as the tool will receive it.
158        input_json: &'a str,
159    },
160    Shell {
161        /// The first word of the step's `shell` command.
162        binary: &'a str,
163        /// The step's `shell` command as written, untruncated.
164        command: &'a str,
165        /// The argument list the executable will receive, already split. No shell expands it, so
166        /// what is here is literally what runs.
167        argv: &'a [String],
168    },
169}
170
171pub fn execute(plan_path: &Path, ctx: &SchedulerContext<'_>) -> ExecutionReport {
172    let started = Instant::now();
173    match execute_inner(plan_path, ctx, started) {
174        Ok(report) => report,
175        // The only failures that reach here are a plan file that could not be read or parsed, so
176        // there is no DAG to have described and no plan node for the summary to hang off.
177        Err((failed_step, error)) => {
178            if let Some(appender) = ctx.trace {
179                appender.write_plan_end(
180                    None,
181                    PlanEndRecord {
182                        plan_id: String::new(),
183                        outcome: "failed",
184                        failed_step: Some(failed_step.clone()),
185                        steps_total: 0,
186                        steps_succeeded: 0,
187                        steps_failed: 0,
188                        steps_skipped: 0,
189                        duration_ms: elapsed_ms(started),
190                        reason: Some(error.clone()),
191                    },
192                );
193            }
194            ExecutionReport {
195                results: vec![StepResult {
196                    step_id: failed_step.clone(),
197                    status: StepStatus::Failed,
198                    output: None,
199                    error: Some(error),
200                }],
201                completed: false,
202                failed_step: Some(failed_step),
203            }
204        }
205    }
206}
207
208fn execute_inner(
209    plan_path: &Path,
210    ctx: &SchedulerContext<'_>,
211    started: Instant,
212) -> Result<ExecutionReport, (String, String)> {
213    let raw = fs::read_to_string(plan_path).map_err(|error| {
214        (
215            "plan".to_string(),
216            format!("failed to read plan file {}: {error}", plan_path.display()),
217        )
218    })?;
219    let plan: PlanFile = serde_json::from_str(&raw).map_err(|error| {
220        (
221            "plan".to_string(),
222            format!("failed to parse plan JSON: {error}"),
223        )
224    })?;
225
226    // The DAG is described the moment the file parses, before it is validated: a plan the
227    // validator refuses still has a structure worth reading, and the refusal is only legible
228    // beside the steps it names.
229    let plan_node = ctx
230        .trace
231        .map(|appender| appender.write_plan_start(&plan.id, plan_shape(&plan)));
232
233    if let Err((step_id, error)) = validate_plan(&plan, ctx) {
234        trace_plan_end(
235            ctx,
236            plan_node.as_deref(),
237            PlanEndRecord {
238                plan_id: plan.id.clone(),
239                outcome: "failed",
240                failed_step: Some(step_id.clone()),
241                steps_total: plan.steps.len(),
242                steps_succeeded: 0,
243                steps_failed: 0,
244                steps_skipped: 0,
245                duration_ms: elapsed_ms(started),
246                reason: Some(error.clone()),
247            },
248        );
249        return Ok(ExecutionReport {
250            results: vec![StepResult {
251                step_id: step_id.clone(),
252                status: StepStatus::Failed,
253                output: None,
254                error: Some(error),
255            }],
256            completed: false,
257            failed_step: Some(step_id.clone()),
258        });
259    }
260
261    // Aggregate process bounding for this plan's shell steps, on the same fail-closed terms as
262    // `runtime::launch_session`: a plan that can run a subprocess on Linux does not start at all
263    // unless its tree can be put in a cgroup scope. `has_native_artifact` is `false` here — a
264    // plan step reaches native binaries only through `invoke_tool`, which routes back into the
265    // session that already established its own scope.
266    let cgroup_scope = match crate::cgroup::prepare_scope(
267        crate::cgroup::requires_process_bounding(&ctx.capability_policy, false),
268        &ctx.capability_policy.resources,
269        &plan.id,
270        &ctx.accessible_workdir,
271    ) {
272        // The plan's own `io.max` outcome is dropped here rather than reported: this scope is a
273        // second one, created for the plan's shell steps inside a session whose `session_start`
274        // already carries the launch scope's `io_max` and whose launch already fired `W-SEC-021`
275        // if the ceiling did not apply. Both scopes sit on the same delegated base over the same
276        // workdir device, so a per-plan repetition would say nothing new.
277        Ok(prepared) => prepared.scope,
278        Err(reason) => {
279            let error =
280                crate::errors::RuntimeError::CgroupDelegationUnavailable { reason }.to_string();
281            return Ok(refused_before_any_step(
282                &plan,
283                ctx,
284                plan_node.as_deref(),
285                started,
286                error,
287            ));
288        }
289    };
290    let workdir_guard = Some(crate::resources::WorkdirGuard::spawn(
291        &ctx.accessible_workdir,
292        ctx.capability_policy.resources.workdir_max_bytes,
293    ));
294
295    // Host-probed kernel enforcement tier + resolved network allowlist for this plan's shell
296    // steps — resolved once, up front, same cadence as `runtime::launch_session`.
297    let shell_enforcement = match sandbox::ShellEnforcement::resolve(
298        &ctx.capability_policy,
299        ctx.capability_policy.containment_floor,
300        sandbox::HostProbe::probe(),
301    ) {
302        Ok(enforcement) => enforcement.with_host_bounding(cgroup_scope, workdir_guard),
303        Err(error) => {
304            let error = format!("failed to resolve shell subprocess sandbox enforcement: {error}");
305            return Ok(refused_before_any_step(
306                &plan,
307                ctx,
308                plan_node.as_deref(),
309                started,
310                error,
311            ));
312        }
313    };
314
315    let mut results = HashMap::<String, StepResult>::new();
316    let mut result_order = Vec::<String>::new();
317    let mut failed_step = None;
318
319    loop {
320        if results.len() == plan.steps.len() || failed_step.is_some() {
321            break;
322        }
323
324        let mut ready = Vec::new();
325        let mut progressed = false;
326
327        for step in &plan.steps {
328            if results.contains_key(&step.id) {
329                continue;
330            }
331            if !step.depends_on.iter().all(|dep| results.contains_key(dep)) {
332                continue;
333            }
334
335            match condition::evaluate(step.condition.as_deref(), &results) {
336                Ok(true) => ready.push(step.clone()),
337                Ok(false) => {
338                    // Never dispatched, so it writes no `plan_step_start` and no attempt: a step
339                    // that did not run must not read like one that did.
340                    trace_undispatched(ctx, plan_node.as_deref(), &plan.id, step, "skipped", None);
341                    insert_result(
342                        &mut results,
343                        &mut result_order,
344                        StepResult {
345                            step_id: step.id.clone(),
346                            status: StepStatus::Skipped,
347                            output: None,
348                            error: None,
349                        },
350                    );
351                    progressed = true;
352                }
353                Err(error) => {
354                    trace_undispatched(
355                        ctx,
356                        plan_node.as_deref(),
357                        &plan.id,
358                        step,
359                        "failed",
360                        Some(error.clone()),
361                    );
362                    insert_result(
363                        &mut results,
364                        &mut result_order,
365                        StepResult {
366                            step_id: step.id.clone(),
367                            status: StepStatus::Failed,
368                            output: None,
369                            error: Some(error),
370                        },
371                    );
372                    failed_step = Some(step.id.clone());
373                    progressed = true;
374                    break;
375                }
376            }
377        }
378
379        if failed_step.is_some() {
380            break;
381        }
382
383        if ready.is_empty() {
384            if progressed {
385                continue;
386            }
387            const BLOCKED: &str = "plan is blocked by a dependency cycle or missing dependency";
388            let blocked_step = plan
389                .steps
390                .iter()
391                .find(|step| !results.contains_key(&step.id));
392            let blocked = blocked_step
393                .map(|step| step.id.clone())
394                .unwrap_or_else(|| "plan".to_string());
395            if let Some(step) = blocked_step {
396                trace_undispatched(
397                    ctx,
398                    plan_node.as_deref(),
399                    &plan.id,
400                    step,
401                    "failed",
402                    Some(BLOCKED.to_string()),
403                );
404            }
405            insert_result(
406                &mut results,
407                &mut result_order,
408                StepResult {
409                    step_id: blocked.clone(),
410                    status: StepStatus::Failed,
411                    output: None,
412                    error: Some(BLOCKED.to_string()),
413                },
414            );
415            failed_step = Some(blocked);
416            break;
417        }
418
419        for step in &ready {
420            trace_step_start(ctx, plan_node.as_deref(), &plan.id, step);
421        }
422
423        let mut completed = Vec::new();
424        thread::scope(|scope| {
425            let mut handles = Vec::new();
426            for step in ready {
427                let snapshot = results.clone();
428                let shell_enforcement = &shell_enforcement;
429                handles.push(scope.spawn(move || {
430                    execute_step_with_retries(&step, ctx, &snapshot, shell_enforcement)
431                }));
432            }
433            for handle in handles {
434                completed.push(handle.join().unwrap_or_else(|_| {
435                    StepOutcome::undispatched(failed("unknown", "step dispatch thread panicked"))
436                }));
437            }
438        });
439
440        for outcome in completed {
441            let Some(step) = plan
442                .steps
443                .iter()
444                .find(|step| step.id == outcome.result.step_id)
445            else {
446                // A dispatch thread that panicked names no step this plan declared.
447                trace_step(
448                    ctx,
449                    plan_node.as_deref(),
450                    PlanStepRecord {
451                        plan_id: plan.id.clone(),
452                        step_id: outcome.result.step_id.clone(),
453                        kind: "unknown",
454                        status: outcome.result.status.as_str(),
455                        attempts: outcome.attempts,
456                        duration_ms: outcome.duration_ms,
457                        error: outcome.result.error.clone(),
458                        input: None,
459                        state_effect: None,
460                        resource_id: None,
461                    },
462                );
463                insert_result(&mut results, &mut result_order, outcome.result);
464                failed_step = Some("unknown".to_string());
465                break;
466            };
467
468            let kind = step_kind_name(step);
469            let StepOutcome {
470                result,
471                attempts,
472                duration_ms,
473                input,
474                state_effect,
475                resource_id,
476            } = outcome;
477
478            // The `on_error` policy is applied here, before the step is recorded either way, so
479            // the trace and the returned report can never disagree about what a step settled as.
480            let (settled, stops) = if result.status == StepStatus::Failed {
481                match step.on_error.as_str() {
482                    "fail" => (result, true),
483                    "skip" => (
484                        StepResult {
485                            step_id: result.step_id,
486                            status: StepStatus::Skipped,
487                            output: None,
488                            error: result.error,
489                        },
490                        false,
491                    ),
492                    "continue" => (result, false),
493                    policy => (
494                        StepResult {
495                            error: Some(format!("invalid on_error policy '{policy}'")),
496                            ..result
497                        },
498                        true,
499                    ),
500                }
501            } else {
502                (result, false)
503            };
504
505            trace_step(
506                ctx,
507                plan_node.as_deref(),
508                PlanStepRecord {
509                    plan_id: plan.id.clone(),
510                    step_id: settled.step_id.clone(),
511                    kind,
512                    status: settled.status.as_str(),
513                    attempts,
514                    duration_ms,
515                    error: settled.error.clone(),
516                    input,
517                    state_effect,
518                    resource_id,
519                },
520            );
521
522            let id = settled.step_id.clone();
523            insert_result(&mut results, &mut result_order, settled);
524            if stops {
525                failed_step = Some(id);
526                break;
527            }
528        }
529    }
530
531    let ordered_results = result_order
532        .into_iter()
533        .filter_map(|id| results.remove(&id))
534        .collect::<Vec<_>>();
535
536    trace_plan_end(
537        ctx,
538        plan_node.as_deref(),
539        PlanEndRecord {
540            plan_id: plan.id.clone(),
541            outcome: if failed_step.is_none() {
542                "completed"
543            } else {
544                "failed"
545            },
546            failed_step: failed_step.clone(),
547            steps_total: plan.steps.len(),
548            steps_succeeded: count_status(&ordered_results, StepStatus::Success),
549            steps_failed: count_status(&ordered_results, StepStatus::Failed),
550            steps_skipped: count_status(&ordered_results, StepStatus::Skipped),
551            duration_ms: elapsed_ms(started),
552            reason: None,
553        },
554    );
555
556    Ok(ExecutionReport {
557        results: ordered_results,
558        completed: failed_step.is_none(),
559        failed_step,
560    })
561}
562
563// ── Trace emission ───────────────────────────────────────────────────────────
564
565/// The name a step's trace lines carry for its kind. `"unknown"` only for a step declaring
566/// none or several of `tool`/`shell`/`capsule`, which `validate_plan` refuses — it can reach a
567/// line only through `plan_start`, which is written before validation.
568fn step_kind_name(step: &StepDef) -> &'static str {
569    infer_kind(step).map(StepKind::as_str).unwrap_or("unknown")
570}
571
572/// The DAG as authored, for `plan_start`.
573fn plan_shape(plan: &PlanFile) -> Vec<PlanStepShape> {
574    plan.steps
575        .iter()
576        .map(|step| PlanStepShape {
577            step_id: step.id.clone(),
578            kind: step_kind_name(step),
579            depends_on: step.depends_on.clone(),
580            has_condition: step.condition.is_some(),
581        })
582        .collect()
583}
584
585fn trace_step_start(
586    ctx: &SchedulerContext<'_>,
587    plan_node: Option<&str>,
588    plan_id: &str,
589    step: &StepDef,
590) {
591    if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
592        appender.write_plan_step_start(
593            node,
594            plan_id,
595            &step.id,
596            step_kind_name(step),
597            step.depends_on.clone(),
598        );
599    }
600}
601
602fn trace_step(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanStepRecord) {
603    if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
604        appender.write_plan_step(node, record);
605    }
606}
607
608/// The terminal line for a step that settled without ever being dispatched: a false `if`, a
609/// condition that would not evaluate, a step the DAG left unreachable. No attempt was made, so
610/// none is claimed.
611fn trace_undispatched(
612    ctx: &SchedulerContext<'_>,
613    plan_node: Option<&str>,
614    plan_id: &str,
615    step: &StepDef,
616    status: &'static str,
617    error: Option<String>,
618) {
619    trace_step(
620        ctx,
621        plan_node,
622        PlanStepRecord {
623            plan_id: plan_id.to_string(),
624            step_id: step.id.clone(),
625            kind: step_kind_name(step),
626            status,
627            attempts: 0,
628            duration_ms: 0,
629            error,
630            input: None,
631            state_effect: None,
632            resource_id: None,
633        },
634    );
635}
636
637fn trace_plan_end(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanEndRecord) {
638    if let Some(appender) = ctx.trace {
639        appender.write_plan_end(plan_node, record);
640    }
641}
642
643/// The report and the trace for a plan the host refused before any step ran: a cgroup scope it
644/// could not delegate, a shell sandbox it could not resolve. The DAG is already described, so
645/// the summary hangs off the plan node and names the plan — only the reason is new.
646fn refused_before_any_step(
647    plan: &PlanFile,
648    ctx: &SchedulerContext<'_>,
649    plan_node: Option<&str>,
650    started: Instant,
651    error: String,
652) -> ExecutionReport {
653    trace_plan_end(
654        ctx,
655        plan_node,
656        PlanEndRecord {
657            plan_id: plan.id.clone(),
658            outcome: "failed",
659            failed_step: Some("plan".to_string()),
660            steps_total: plan.steps.len(),
661            steps_succeeded: 0,
662            steps_failed: 0,
663            steps_skipped: 0,
664            duration_ms: elapsed_ms(started),
665            reason: Some(error.clone()),
666        },
667    );
668    ExecutionReport {
669        results: vec![StepResult {
670            step_id: "plan".to_string(),
671            status: StepStatus::Failed,
672            output: None,
673            error: Some(error),
674        }],
675        completed: false,
676        failed_step: Some("plan".to_string()),
677    }
678}
679
680fn count_status(results: &[StepResult], status: StepStatus) -> usize {
681    results
682        .iter()
683        .filter(|result| result.status == status)
684        .count()
685}
686
687fn elapsed_ms(started: Instant) -> u64 {
688    started.elapsed().as_millis() as u64
689}
690
691fn validate_plan(plan: &PlanFile, ctx: &SchedulerContext<'_>) -> Result<(), (String, String)> {
692    let mut ids = HashSet::new();
693    for step in &plan.steps {
694        if !ids.insert(step.id.clone()) {
695            return Err((step.id.clone(), format!("duplicate step id '{}'", step.id)));
696        }
697
698        match infer_kind(step) {
699            Ok(StepKind::Tool) => {
700                let tool = step.tool.as_ref().unwrap();
701                // Refused here rather than bounded by a depth counter: a plan that could submit a
702                // plan would nest one scheduler inside another's worker thread, and the outer
703                // report would carry the inner one as a step's output text.
704                if tool == crate::runtime::SUBMIT_PLAN_TOOL {
705                    return Err((
706                        step.id.clone(),
707                        format!(
708                            "a plan step cannot submit another plan: '{tool}' is not callable \
709                             from inside a plan"
710                        ),
711                    ));
712                }
713                if !ctx.installed_tools.contains(tool) {
714                    return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
715                }
716            }
717            Ok(StepKind::Shell) => {
718                let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
719                    return Err((step.id.clone(), "shell command is empty".to_string()));
720                };
721                if !ctx.capability_policy.shell_allow.contains(&binary) {
722                    return Err((
723                        step.id.clone(),
724                        format!("binary '{binary}' is not in capabilities.shell.allow"),
725                    ));
726                }
727            }
728            Ok(StepKind::Spawn) => {
729                // spawn_allow is enforced by mur-roost from the parent's CapabilityPolicy.
730                // No local check here — the capsule cannot self-authorize its own spawn rights.
731
732                // The input shape is settled before any step runs, so a plan that could only
733                // hand its child an envelope is refused without taking a cgroup scope, without
734                // spawning anything, and without the earlier steps having already had effects.
735                if let Err(error) = capsule_task_text(&step.input) {
736                    return Err((step.id.clone(), error));
737                }
738            }
739            Err(error) => return Err((step.id.clone(), error)),
740        }
741
742        if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
743            return Err((
744                step.id.clone(),
745                format!("invalid on_error policy '{}'", step.on_error),
746            ));
747        }
748    }
749
750    for step in &plan.steps {
751        for dep in &step.depends_on {
752            if !ids.contains(dep) {
753                return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
754            }
755        }
756        validate_references(step, &ids)?;
757    }
758
759    Ok(())
760}
761
762fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
763    let mut references = Vec::new();
764    collect_references(&step.input, &mut references);
765    if let Some(condition) = &step.condition {
766        for token in condition.split_whitespace() {
767            if token.starts_with('$') {
768                references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
769            }
770        }
771    }
772
773    for reference in references {
774        if let Some((step_id, field)) = parse_reference(&reference) {
775            if !ids.contains(step_id) {
776                return Err((step.id.clone(), format!("unknown reference '{reference}'")));
777            }
778            if field != "output" && field != "status" {
779                return Err((
780                    step.id.clone(),
781                    format!("unknown reference field '{field}'"),
782                ));
783            }
784        }
785    }
786
787    Ok(())
788}
789
790fn collect_references(value: &Value, references: &mut Vec<String>) {
791    match value {
792        Value::String(value) if value.starts_with('$') => references.push(value.clone()),
793        Value::Array(values) => values
794            .iter()
795            .for_each(|value| collect_references(value, references)),
796        Value::Object(map) => map
797            .values()
798            .for_each(|value| collect_references(value, references)),
799        _ => {}
800    }
801}
802
803/// What a worker thread reports back to the scheduler loop about the step it ran: the verdict,
804/// plus the facts only the worker observed and the terminal `plan_step` line needs.
805///
806/// [`execute_step_once`] returns one attempt's own `attempts: 1` and duration;
807/// [`execute_step_with_retries`] overwrites both with the totals for the step.
808struct StepOutcome {
809    result: StepResult,
810    attempts: u32,
811    duration_ms: u64,
812    /// The interpolated input the attempt dispatched. `Some` for a tool step; `None` for every
813    /// other kind, and for a failure that never reached a dispatch.
814    input: Option<Value>,
815    /// What the tool declared about this call in `tool-result.metadata`. Both `None` for every
816    /// kind but a tool step, and for a tool that declared nothing.
817    state_effect: Option<String>,
818    resource_id: Option<String>,
819}
820
821impl StepOutcome {
822    /// One attempt of a step that declares nothing about itself: every kind but a tool step, and
823    /// every failure that never reached a dispatch.
824    fn plain(result: StepResult, started: Instant) -> Self {
825        Self {
826            result,
827            attempts: 1,
828            duration_ms: elapsed_ms(started),
829            input: None,
830            state_effect: None,
831            resource_id: None,
832        }
833    }
834
835    /// A verdict reached without a dispatch, and so without an attempt to count or time.
836    fn undispatched(result: StepResult) -> Self {
837        Self {
838            result,
839            attempts: 0,
840            duration_ms: 0,
841            input: None,
842            state_effect: None,
843            resource_id: None,
844        }
845    }
846}
847
848fn execute_step_with_retries(
849    step: &StepDef,
850    ctx: &SchedulerContext<'_>,
851    results: &HashMap<String, StepResult>,
852    enforcement: &sandbox::ShellEnforcement,
853) -> StepOutcome {
854    let started = Instant::now();
855    let attempts = step.retries.saturating_add(1);
856    let mut last = None;
857    for attempt in 1..=attempts {
858        let mut outcome = execute_step_once(step, ctx, results, enforcement);
859        outcome.attempts = attempt;
860        outcome.duration_ms = elapsed_ms(started);
861        if outcome.result.status != StepStatus::Failed {
862            return outcome;
863        }
864        last = Some(outcome);
865    }
866
867    last.unwrap_or_else(|| StepOutcome::undispatched(failed(&step.id, "step was not attempted")))
868}
869
870fn execute_step_once(
871    step: &StepDef,
872    ctx: &SchedulerContext<'_>,
873    results: &HashMap<String, StepResult>,
874    enforcement: &sandbox::ShellEnforcement,
875) -> StepOutcome {
876    let started = Instant::now();
877    let mut input = step.input.clone();
878    if let Err(error) = interpolate_value(&mut input, results) {
879        return StepOutcome::plain(failed(&step.id, error), started);
880    }
881    let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
882
883    match infer_kind(step) {
884        Ok(StepKind::Tool) => {
885            let (result, state_effect, resource_id) = dispatch_tool_step(step, ctx, input_json);
886            StepOutcome {
887                result,
888                attempts: 1,
889                duration_ms: elapsed_ms(started),
890                // A step declaring no `input` interpolates to JSON null, which would write
891                // `"input": null` rather than omitting the key. Absent means absent.
892                input: (!input.is_null()).then_some(input),
893                state_effect,
894                resource_id,
895            }
896        }
897        Ok(StepKind::Shell) => {
898            StepOutcome::plain(dispatch_shell_step(step, ctx, enforcement), started)
899        }
900        Ok(StepKind::Spawn) => StepOutcome::plain(dispatch_capsule_step(step, ctx, input), started),
901        Err(error) => StepOutcome::plain(failed(&step.id, error), started),
902    }
903}
904
905/// Dispatches one tool step, and returns the verdict beside the `state_effect` and `resource_id`
906/// the tool declared about the call. Both are read through the agent loop's own extractors, so
907/// a plan step and an agent turn read one tool's self-description the same way.
908fn dispatch_tool_step(
909    step: &StepDef,
910    ctx: &SchedulerContext<'_>,
911    input_json: String,
912) -> (StepResult, Option<String>, Option<String>) {
913    let name = step.tool.as_deref().unwrap_or_default();
914    if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Tool {
915        name,
916        input_json: &input_json,
917    }) {
918        return (failed(&step.id, refusal), None, None);
919    }
920    match (ctx.invoke_tool)(
921        name,
922        ToolInput {
923            data: Some(input_json),
924            log_path: None,
925        },
926    ) {
927        Ok(result) => {
928            let state_effect = crate::agent::extract_state_effect(&result.metadata);
929            let resource_id = crate::agent::extract_resource_id(&result.metadata);
930            let verdict = if matches!(result.status, ToolStatus::Passed) {
931                match tool_step_output(
932                    name,
933                    &ctx.accessible_workdir,
934                    result.data,
935                    result.data_path.as_deref(),
936                ) {
937                    Ok(output) => StepResult {
938                        step_id: step.id.clone(),
939                        status: StepStatus::Success,
940                        output,
941                        error: None,
942                    },
943                    Err(error) => failed(&step.id, error),
944                }
945            } else {
946                failed(
947                    &step.id,
948                    result
949                        .summary
950                        .or(result.data)
951                        .unwrap_or_else(|| "tool step failed".to_string()),
952                )
953            };
954            (verdict, state_effect, resource_id)
955        }
956        Err(error) => (failed(&step.id, error), None, None),
957    }
958}
959
960fn dispatch_shell_step(
961    step: &StepDef,
962    ctx: &SchedulerContext<'_>,
963    enforcement: &sandbox::ShellEnforcement,
964) -> StepResult {
965    let command = step.shell.as_deref().unwrap_or_default();
966    let Some((binary, args)) = parse_shell_command(command) else {
967        return failed(&step.id, "shell command is empty");
968    };
969    if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Shell {
970        binary: &binary,
971        command,
972        argv: &args,
973    }) {
974        return failed(&step.id, refusal);
975    }
976    let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
977
978    match execute_shell(
979        &binary,
980        &arg_refs,
981        &[],
982        &ctx.accessible_workdir,
983        &ctx.session_workdir,
984        &ctx.capability_policy,
985        enforcement,
986    ) {
987        Ok(result) if result.exit_code == 0 => StepResult {
988            step_id: step.id.clone(),
989            status: StepStatus::Success,
990            output: Some(result.stdout),
991            error: None,
992        },
993        // A step killed for exceeding a host resource limit fails with the limit named, not with
994        // whatever the dying process managed to write to stderr — for `SIGXCPU`/`SIGXFSZ` and the
995        // cgroup kills that is usually nothing at all, which would otherwise report an empty
996        // error for a step that was very deliberately terminated.
997        Ok(result) => match result.resource_limit_hit {
998            Some(limit) => failed(
999                &step.id,
1000                crate::errors::RuntimeError::ShellResourceLimitExceeded {
1001                    binary: result.binary,
1002                    limit,
1003                    detail: if result.stderr.trim().is_empty() {
1004                        format!("exit code {}", result.exit_code)
1005                    } else {
1006                        result.stderr
1007                    },
1008                }
1009                .to_string(),
1010            ),
1011            // A command that fails without writing to stderr is the common case, not the odd
1012            // one — `test`, `grep -q` and `diff` all report only through their exit status. The
1013            // code is the whole of what failed, so the step carries it rather than an empty
1014            // error the model has to guess at.
1015            None if result.stderr.trim().is_empty() => {
1016                failed(&step.id, format!("exit code {}", result.exit_code))
1017            }
1018            None => failed(&step.id, result.stderr),
1019        },
1020        // Including a sealed composed-root failure, which fails the step carrying its full
1021        // named text. Unlike the agent turn loop (which owns a session and ends it — see
1022        // `agent::run_agent_loop`), this scheduler owns nothing above the step it is running,
1023        // so naming the cause in the step's error is the whole of what it can do.
1024        Err(error) => failed(&step.id, error.to_string()),
1025    }
1026}
1027
1028fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1029    let capsule = step.capsule.as_deref().unwrap_or_default();
1030    // `validate_plan` already accepted this step's un-interpolated input, and interpolation
1031    // replaces a string with a string, so the refusal here is unreachable through `execute`.
1032    let task = match capsule_task_text(&input) {
1033        Ok(task) => task,
1034        Err(error) => return failed(&step.id, error),
1035    };
1036    let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1037        Ok(value) if !value.trim().is_empty() => value,
1038        _ => {
1039            return failed(
1040                &step.id,
1041                "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1042            )
1043        }
1044    };
1045    let Some(credential) = ctx.spawn_credential.as_ref() else {
1046        return failed(
1047            &step.id,
1048            "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1049             mints it only for a session whose manifest declares capabilities.spawn.allow",
1050        );
1051    };
1052
1053    // Everything a delegation is — the referee's approval, the child's process, the task delivery
1054    // and the bounded wait — belongs to `DelegationPlane`, and a capsule step is input and output
1055    // marshalling around it. The agent-facing `delegate-task` tool calls the same code, so there
1056    // is one delegation mechanism in this crate rather than two that drift.
1057    // A plan step reads no manifest of its own here, so it delegates under the default bound
1058    // rather than under a declared one.
1059    let plane = DelegationPlane::new(
1060        roost_url,
1061        credential.clone(),
1062        ctx.accessible_workdir.clone(),
1063        ctx.current_session_id.clone().unwrap_or_default(),
1064        crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1065        std::sync::Arc::clone(&ctx.registry),
1066        ctx.capability_policy.env_allow.clone(),
1067    );
1068    // The launch notice is written where it arrives, on this thread, while the child is still
1069    // holding its task: `delegate` blocks until the child answers, so a notice parked for later
1070    // would only reach disk once there was already a terminal line to write beside it.
1071    let launched = ctx.trace.map(|trace| {
1072        std::sync::Arc::new(move |notice: crate::delegation_plane::DelegationLaunch| {
1073            trace.write_delegation_start(
1074                &notice.delegation_id,
1075                &notice.capsule,
1076                &notice.version,
1077                &notice.child_session_id,
1078                &notice.child_workdir,
1079            );
1080        }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1081    });
1082    let started = Instant::now();
1083    let result = plane.delegate(
1084        &DelegationRequest {
1085            capsule: capsule.to_string(),
1086            // A plan names a capsule; the version comes from the context the plan was validated
1087            // against, and a context that names none means `0.1.0`.
1088            version: ctx
1089                .capsule_versions
1090                .get(capsule)
1091                .cloned()
1092                .unwrap_or_else(|| "0.1.0".to_string()),
1093            task,
1094        },
1095        &DelegationOrigin {
1096            context_id: ctx.current_context_id.clone().unwrap_or_default(),
1097            launched,
1098            // `delegate` waits on the connection it holds, so nothing this delegation does posts
1099            // a completion and there is no trust for one to inherit.
1100            trust: None,
1101        },
1102    );
1103
1104    if let Some(trace) = ctx.trace {
1105        trace.write_delegation(
1106            &result.capsule,
1107            &result.version,
1108            // Empty rather than absent is how a result names no child; the record says `null`,
1109            // so nothing reading it can join against an id that was never minted.
1110            Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1111            Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1112            elapsed_ms(started),
1113            result.status.as_str(),
1114            match result.status {
1115                DelegationStatus::Completed => None,
1116                _ => Some(result.output.clone()),
1117            },
1118        );
1119    }
1120
1121    match result.status {
1122        DelegationStatus::Completed => StepResult {
1123            step_id: step.id.clone(),
1124            status: StepStatus::Success,
1125            output: Some(result.output),
1126            error: None,
1127        },
1128        _ => failed(&step.id, result.output),
1129    }
1130}
1131
1132/// The task text a capsule step hands to the child capsule, or the reason the step's `input`
1133/// cannot become task text.
1134///
1135/// Whatever this returns becomes the child's first user message verbatim: `DelegationPlane`
1136/// sends it as the sole text part of an A2A `message/send`, the receiving runtime writes that
1137/// text to `task.md`, and `agent::read_task` pushes it at the model. Nothing on the receiving
1138/// side parses a task envelope, so an envelope is not structure — it is prose that happens to be
1139/// JSON, and it spends the child's first turn on decoding. A capsule step's `input` is therefore
1140/// the task text and nothing else, in one of two shapes:
1141///
1142/// | `input` | task text |
1143/// | --- | --- |
1144/// | a JSON string | that string, verbatim |
1145/// | `{ "objective": "<text>" }`, and no other key | `<text>`, verbatim |
1146///
1147/// Every other shape is refused rather than serialized into the message. Interpolation replaces
1148/// a string with a string and so cannot change the shape, which is what lets `validate_plan`
1149/// refuse the whole plan before a cgroup scope is taken or a child is spawned.
1150fn capsule_task_text(input: &Value) -> Result<String, String> {
1151    const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1152    const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1153                            receives, given as a string or as { \"objective\": \"<text>\" }";
1154
1155    match input {
1156        Value::String(text) => Ok(text.clone()),
1157        Value::Object(map) => {
1158            // serde_json's map is a BTreeMap or an IndexMap depending on the `preserve_order`
1159            // feature, so the listed keys are sorted here rather than taken in iteration order.
1160            let mut unknown = map
1161                .keys()
1162                .filter(|key| key.as_str() != "objective")
1163                .map(String::as_str)
1164                .collect::<Vec<_>>();
1165            unknown.sort_unstable();
1166            if !unknown.is_empty() {
1167                return Err(format!(
1168                    "capsule step input has keys the child cannot receive: {}; nothing on the \
1169                     receiving side parses a task envelope, so put the whole instruction in \
1170                     \"objective\"",
1171                    unknown.join(", ")
1172                ));
1173            }
1174
1175            match map.get("objective") {
1176                Some(Value::String(text)) => Ok(text.clone()),
1177                Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1178                None => Err(ACCEPTS.to_string()),
1179            }
1180        }
1181        Value::Null => Err(NO_INPUT.to_string()),
1182        Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1183        Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1184        Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1185    }
1186}
1187
1188fn interpolate_value(
1189    value: &mut Value,
1190    results: &HashMap<String, StepResult>,
1191) -> Result<(), String> {
1192    match value {
1193        Value::String(text) if text.starts_with('$') => {
1194            *text = condition::resolve_reference(text, results)?;
1195        }
1196        Value::Array(values) => {
1197            for value in values {
1198                interpolate_value(value, results)?;
1199            }
1200        }
1201        Value::Object(map) => {
1202            for value in map.values_mut() {
1203                interpolate_value(value, results)?;
1204            }
1205        }
1206        _ => {}
1207    }
1208
1209    Ok(())
1210}
1211
1212fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1213    let count =
1214        step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1215    match count {
1216        1 if step.tool.is_some() => Ok(StepKind::Tool),
1217        1 if step.shell.is_some() => Ok(StepKind::Shell),
1218        1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1219        0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1220        _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1221    }
1222}
1223
1224fn default_on_error() -> String {
1225    "fail".to_string()
1226}
1227
1228fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1229    StepResult {
1230        step_id: step_id.to_string(),
1231        status: StepStatus::Failed,
1232        output: None,
1233        error: Some(error.into()),
1234    }
1235}
1236
1237fn insert_result(
1238    results: &mut HashMap<String, StepResult>,
1239    order: &mut Vec<String>,
1240    result: StepResult,
1241) {
1242    order.push(result.step_id.clone());
1243    results.insert(result.step_id.clone(), result);
1244}
1245
1246fn shell_binary(command: &str) -> Option<String> {
1247    parse_shell_command(command).map(|(binary, _)| binary)
1248}
1249
1250fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1251    let parts = split_shell_words(command);
1252    let (binary, args) = parts.split_first()?;
1253    Some((binary.clone(), args.to_vec()))
1254}
1255
1256fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1257    reference.strip_prefix('$')?.rsplit_once('.')
1258}
1259
1260/// The output of a tool step that passed: in-band `data` when the tool sent it, otherwise the
1261/// file the tool named in `data_path`.
1262///
1263/// `data_path` is the escape hatch for a result too large to carry in band — the system prompt
1264/// tells the model to read it when `truncated` is set — so a path the host cannot honour is
1265/// resolved against the guest's own boundary rather than dropped from the interface. The
1266/// resolution is [`resource_plane::read_file_beneath_root`] against `workdir`, the directory the
1267/// tool's `.` means, and a refusal fails the step: a step whose declared output could not be read
1268/// did not succeed.
1269///
1270/// The symlink policy is [`SymlinkPolicy::Refuse`] whatever containment class the session
1271/// achieved. `resource_plane::symlink_policy` follows symlinks under `sealed` and `advisory`
1272/// because a capsule reading its own export root has no outside to name; this read runs in the
1273/// host process, which sits outside every class's boundary with ambient filesystem authority, so
1274/// a symlink under the workdir pointing at `/etc/shadow` would be followed successfully at any
1275/// class.
1276fn tool_step_output(
1277    tool: &str,
1278    workdir: &Path,
1279    data: Option<String>,
1280    data_path: Option<&str>,
1281) -> Result<Option<String>, String> {
1282    if let Some(data) = data {
1283        return Ok(Some(data));
1284    }
1285    let Some(relpath) = data_path else {
1286        return Ok(None);
1287    };
1288    match resource_plane::read_file_beneath_root(
1289        workdir,
1290        relpath,
1291        DEFAULT_EXPORT_MAX_BYTES,
1292        SymlinkPolicy::Refuse,
1293    ) {
1294        Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1295        Err(error) => Err(RuntimeError::ToolDataPathRefused {
1296            tool: tool.to_string(),
1297            path: relpath.to_string(),
1298            code: error.code().to_string(),
1299            detail: error.message(),
1300        }
1301        .to_string()),
1302    }
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307    use std::{
1308        sync::{
1309            atomic::{AtomicUsize, Ordering},
1310            Arc,
1311        },
1312        time::{Duration, Instant},
1313    };
1314
1315    use serde_json::json;
1316    use tempfile::tempdir;
1317
1318    use super::*;
1319
1320    /// The `shell_allow` grant below is what makes every test built on this context need a
1321    /// delegated cgroup v2 scope: `execute` bounds the plan's whole subprocess tree before
1322    /// running a step, and fails closed when it cannot.
1323    fn test_ctx<'a>(
1324        workdir: PathBuf,
1325        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1326    ) -> SchedulerContext<'a> {
1327        test_ctx_gated(workdir, invoke_tool, &UNGATED)
1328    }
1329
1330    /// A decision point that refuses nothing, which is what a session with neither a policy hook
1331    /// nor a `capabilities.filesystem.read_only` declaration presents.
1332    const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1333
1334    fn test_ctx_gated<'a>(
1335        workdir: PathBuf,
1336        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1337        gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1338    ) -> SchedulerContext<'a> {
1339        SchedulerContext {
1340            accessible_workdir: workdir.clone(),
1341            session_workdir: workdir,
1342            capability_policy: CapabilityPolicy {
1343                shell_allow: vec!["bash".to_string()],
1344                spawn_allow: vec!["worker".to_string()],
1345                ..CapabilityPolicy::default()
1346            },
1347            installed_tools: HashSet::from([
1348                "ok".to_string(),
1349                "fail".to_string(),
1350                "slow".to_string(),
1351                "a".to_string(),
1352                "b".to_string(),
1353            ]),
1354            capsule_versions: HashMap::new(),
1355            current_session_id: None,
1356            current_context_id: None,
1357            // Nothing in these cases delegates, so no artifact is ever resolved through it.
1358            registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1359                tempdir().unwrap().keep(),
1360            )),
1361            spawn_credential: None,
1362            trace: None,
1363            gate_step,
1364            invoke_tool,
1365        }
1366    }
1367
1368    fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1369        ToolResult {
1370            status,
1371            summary: None,
1372            data,
1373            data_path: None,
1374            truncated: false,
1375            metadata: Vec::new(),
1376        }
1377    }
1378
1379    fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1380        let path = workdir.join("plan.json");
1381        fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1382        path
1383    }
1384
1385    #[test]
1386    fn capsule_task_text_delivers_a_bare_string_verbatim() {
1387        assert_eq!(
1388            capsule_task_text(&json!("do the thing")).unwrap(),
1389            "do the thing"
1390        );
1391    }
1392
1393    #[test]
1394    fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1395        assert_eq!(
1396            capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1397            "do the thing"
1398        );
1399    }
1400
1401    /// An empty task is a well-shaped one. What a child does with nothing to do is the child's
1402    /// business, not this contract's.
1403    #[test]
1404    fn capsule_task_text_accepts_empty_task_text() {
1405        assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1406        assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1407    }
1408
1409    #[test]
1410    fn capsule_task_text_refuses_a_task_envelope() {
1411        let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1412        assert!(
1413            error.contains("keys the child cannot receive: task"),
1414            "{error}"
1415        );
1416        assert!(error.contains("\"objective\""), "{error}");
1417    }
1418
1419    #[test]
1420    fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1421        let error =
1422            capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1423                .unwrap_err();
1424        assert!(
1425            error.contains("keys the child cannot receive: instructions, render_as"),
1426            "{error}"
1427        );
1428    }
1429
1430    #[test]
1431    fn capsule_task_text_refuses_a_non_string_objective() {
1432        for input in [json!({"objective": 7}), json!({"objective": null})] {
1433            assert_eq!(
1434                capsule_task_text(&input).unwrap_err(),
1435                "capsule step input \"objective\" must be a string"
1436            );
1437        }
1438    }
1439
1440    #[test]
1441    fn capsule_task_text_refuses_an_object_without_an_objective() {
1442        assert_eq!(
1443            capsule_task_text(&json!({})).unwrap_err(),
1444            "capsule step input must be a string or { \"objective\": \"<text>\" }"
1445        );
1446    }
1447
1448    #[test]
1449    fn capsule_task_text_names_the_type_it_was_handed() {
1450        assert!(capsule_task_text(&json!(7))
1451            .unwrap_err()
1452            .ends_with("not a number"));
1453        assert!(capsule_task_text(&json!(true))
1454            .unwrap_err()
1455            .ends_with("not a boolean"));
1456        assert!(capsule_task_text(&json!(["a"]))
1457            .unwrap_err()
1458            .ends_with("not an array"));
1459    }
1460
1461    /// `StepDef::input` defaults to `Value::Null`, so an omitted `input` and an explicit null
1462    /// are the same value and get the same refusal.
1463    #[test]
1464    fn capsule_task_text_refuses_an_absent_input() {
1465        let error = capsule_task_text(&Value::Null).unwrap_err();
1466        assert_eq!(
1467            error,
1468            "capsule step has no input; its input is the task text the child receives, given as \
1469             a string or as { \"objective\": \"<text>\" }"
1470        );
1471    }
1472
1473    /// A `$reference` is a string, so both accepted shapes survive load-time validation and are
1474    /// resolved at dispatch.
1475    #[test]
1476    fn capsule_task_text_accepts_an_uninterpolated_reference() {
1477        assert_eq!(
1478            capsule_task_text(&json!("$analyse.output")).unwrap(),
1479            "$analyse.output"
1480        );
1481        assert_eq!(
1482            capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1483            "$analyse.output"
1484        );
1485    }
1486
1487    /// [`test_ctx_gated`] with no `shell` or `spawn` grant, so the run needs no delegated cgroup
1488    /// scope and holds on any host.
1489    fn test_ctx_unbounded<'a>(
1490        workdir: PathBuf,
1491        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1492        gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1493    ) -> SchedulerContext<'a> {
1494        SchedulerContext {
1495            capability_policy: CapabilityPolicy::default(),
1496            ..test_ctx_gated(workdir, invoke_tool, gate_step)
1497        }
1498    }
1499
1500    /// The decision point refuses, and the step it refused never reaches the executor.
1501    ///
1502    /// The refusal has to arrive as the step's own error rather than as an empty success, because
1503    /// a plan whose steps quietly did nothing is indistinguishable from one that worked.
1504    #[test]
1505    fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1506        let dir = tempdir().unwrap();
1507        let dispatched = Arc::new(AtomicUsize::new(0));
1508        let counted = Arc::clone(&dispatched);
1509        let invoke = move |_name: &str, _input: ToolInput| {
1510            counted.fetch_add(1, Ordering::SeqCst);
1511            Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1512        };
1513        let gate = |call: &PlannedCall<'_>| match call {
1514            PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1515            _ => None,
1516        };
1517        let plan = write_plan(
1518            dir.path(),
1519            json!({
1520                "id": "p",
1521                "steps": [
1522                    {"id": "refused", "tool": "a", "on_error": "continue"},
1523                    {"id": "allowed", "tool": "b"}
1524                ]
1525            }),
1526        );
1527
1528        let report = execute(
1529            &plan,
1530            &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1531        );
1532        let refused = report
1533            .results
1534            .iter()
1535            .find(|result| result.step_id == "refused")
1536            .expect("the refused step settled");
1537        assert_eq!(refused.status, StepStatus::Failed);
1538        assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1539        assert_eq!(refused.output, None);
1540        // The one dispatch is the step the gate let through.
1541        assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1542    }
1543
1544    /// A command that fails silently still says why. The model is handed the step's `error` as
1545    /// the whole account of what went wrong, so an empty one reports a failure with no reason.
1546    #[test]
1547    fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1548        if crate::cgroup::skip_without_host_support(
1549            "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1550        ) {
1551            return;
1552        }
1553        let dir = tempdir().unwrap();
1554        let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1555        let plan = write_plan(
1556            dir.path(),
1557            json!({
1558                "id": "p",
1559                "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1560            }),
1561        );
1562
1563        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1564        assert!(!report.completed);
1565        assert_eq!(report.results[0].status, StepStatus::Failed);
1566        assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1567    }
1568
1569    /// A `shell` step is put to the same decision point, and a refused one starts no subprocess.
1570    #[test]
1571    fn a_refused_shell_step_starts_no_subprocess() {
1572        if crate::cgroup::skip_without_host_support(
1573            "plan::tests::a_refused_shell_step_starts_no_subprocess",
1574        ) {
1575            return;
1576        }
1577        let dir = tempdir().unwrap();
1578        let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1579        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1580        let recorded = Arc::clone(&seen);
1581        let gate = move |call: &PlannedCall<'_>| match call {
1582            PlannedCall::Shell { binary, argv, .. } => {
1583                recorded
1584                    .lock()
1585                    .unwrap()
1586                    .push((binary.to_string(), argv.to_vec()));
1587                Some("refused by the manifest".to_string())
1588            }
1589            _ => None,
1590        };
1591        let marker = dir.path().join("written-by-a-refused-step");
1592        let plan = write_plan(
1593            dir.path(),
1594            json!({
1595                "id": "p",
1596                "steps": [{
1597                    "id": "w",
1598                    "shell": format!("bash -c 'printf x > {}'", marker.display()),
1599                }]
1600            }),
1601        );
1602
1603        let report = execute(
1604            &plan,
1605            &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
1606        );
1607        assert!(!report.completed);
1608        assert_eq!(report.failed_step.as_deref(), Some("w"));
1609        assert_eq!(
1610            report.results[0].error.as_deref(),
1611            Some("refused by the manifest")
1612        );
1613        assert!(!marker.exists(), "a refused step must not have run");
1614        // The gate is shown the argv the executable would have received, not the raw command.
1615        let seen = seen.lock().unwrap();
1616        assert_eq!(seen.len(), 1);
1617        assert_eq!(seen[0].0, "bash");
1618        assert_eq!(seen[0].1[0], "-c");
1619    }
1620
1621    #[test]
1622    fn dependent_step_receives_upstream_output() {
1623        if crate::cgroup::skip_without_host_support(
1624            "plan::tests::dependent_step_receives_upstream_output",
1625        ) {
1626            return;
1627        }
1628        let dir = tempdir().unwrap();
1629        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1630        let seen_tool = Arc::clone(&seen);
1631        let invoke = move |_name: &str, input: ToolInput| {
1632            seen_tool
1633                .lock()
1634                .unwrap()
1635                .push(input.data.unwrap_or_default());
1636            Ok(tool_result(
1637                ToolStatus::Passed,
1638                Some("upstream".to_string()),
1639            ))
1640        };
1641        let plan = write_plan(
1642            dir.path(),
1643            json!({
1644                "id": "p",
1645                "steps": [
1646                    {"id": "a", "tool": "ok"},
1647                    {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
1648                ]
1649            }),
1650        );
1651
1652        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1653        assert!(report.completed);
1654        assert_eq!(report.results.len(), 2);
1655        assert!(seen.lock().unwrap()[1].contains("upstream"));
1656    }
1657
1658    #[test]
1659    fn parallel_tool_steps_run_concurrently() {
1660        if crate::cgroup::skip_without_host_support(
1661            "plan::tests::parallel_tool_steps_run_concurrently",
1662        ) {
1663            return;
1664        }
1665        let dir = tempdir().unwrap();
1666        let active = Arc::new(AtomicUsize::new(0));
1667        let max_active = Arc::new(AtomicUsize::new(0));
1668        let invoke_active = Arc::clone(&active);
1669        let invoke_max = Arc::clone(&max_active);
1670        let invoke = move |_name: &str, _input: ToolInput| {
1671            let now = invoke_active.fetch_add(1, Ordering::SeqCst) + 1;
1672            invoke_max.fetch_max(now, Ordering::SeqCst);
1673            thread::sleep(Duration::from_millis(150));
1674            invoke_active.fetch_sub(1, Ordering::SeqCst);
1675            Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1676        };
1677        let plan = write_plan(
1678            dir.path(),
1679            json!({
1680                "id": "p",
1681                "steps": [
1682                    {"id": "a", "tool": "slow"},
1683                    {"id": "b", "tool": "slow"}
1684                ]
1685            }),
1686        );
1687
1688        let started = Instant::now();
1689        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1690        assert!(report.completed);
1691        assert!(started.elapsed() < Duration::from_millis(280));
1692        assert_eq!(max_active.load(Ordering::SeqCst), 2);
1693    }
1694
1695    #[test]
1696    fn on_error_and_retries_are_applied() {
1697        if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
1698        {
1699            return;
1700        }
1701        let dir = tempdir().unwrap();
1702        let calls = Arc::new(AtomicUsize::new(0));
1703        let invoke_calls = Arc::clone(&calls);
1704        let invoke = move |name: &str, _input: ToolInput| {
1705            invoke_calls.fetch_add(1, Ordering::SeqCst);
1706            if name == "a" {
1707                Ok(tool_result(ToolStatus::Failed, None))
1708            } else {
1709                Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1710            }
1711        };
1712        let plan = write_plan(
1713            dir.path(),
1714            json!({
1715                "id": "p",
1716                "steps": [
1717                    {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
1718                    {"id": "b", "tool": "b", "depends_on": ["a"]}
1719                ]
1720            }),
1721        );
1722
1723        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1724        assert!(report.completed);
1725        assert_eq!(calls.load(Ordering::SeqCst), 4);
1726    }
1727
1728    #[test]
1729    fn condition_false_skips_step() {
1730        if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
1731            return;
1732        }
1733        let dir = tempdir().unwrap();
1734        let invoke = |_name: &str, _input: ToolInput| {
1735            Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1736        };
1737        let plan = write_plan(
1738            dir.path(),
1739            json!({
1740                "id": "p",
1741                "steps": [
1742                    {"id": "a", "tool": "ok"},
1743                    {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
1744                ]
1745            }),
1746        );
1747
1748        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1749        assert!(report.completed);
1750        assert_eq!(report.results[1].status, StepStatus::Skipped);
1751    }
1752}