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                // The agent's own switch is a decision it makes in a turn, which a plan step is
714                // not: the step would change the model under the turn that submitted the plan.
715                if tool == crate::runtime::SWITCH_DRIVER_TOOL {
716                    return Err((
717                        step.id.clone(),
718                        format!("'{tool}' is not callable from inside a plan"),
719                    ));
720                }
721                if !ctx.installed_tools.contains(tool) {
722                    return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
723                }
724            }
725            Ok(StepKind::Shell) => {
726                let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
727                    return Err((step.id.clone(), "shell command is empty".to_string()));
728                };
729                if !ctx.capability_policy.shell_allow.contains(&binary) {
730                    return Err((
731                        step.id.clone(),
732                        format!("binary '{binary}' is not in capabilities.shell.allow"),
733                    ));
734                }
735            }
736            Ok(StepKind::Spawn) => {
737                // spawn_allow is enforced by mur-roost from the parent's CapabilityPolicy.
738                // No local check here — the capsule cannot self-authorize its own spawn rights.
739
740                // The input shape is settled before any step runs, so a plan that could only
741                // hand its child an envelope is refused without taking a cgroup scope, without
742                // spawning anything, and without the earlier steps having already had effects.
743                if let Err(error) = capsule_task_text(&step.input) {
744                    return Err((step.id.clone(), error));
745                }
746            }
747            Err(error) => return Err((step.id.clone(), error)),
748        }
749
750        if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
751            return Err((
752                step.id.clone(),
753                format!("invalid on_error policy '{}'", step.on_error),
754            ));
755        }
756    }
757
758    for step in &plan.steps {
759        for dep in &step.depends_on {
760            if !ids.contains(dep) {
761                return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
762            }
763        }
764        validate_references(step, &ids)?;
765    }
766
767    Ok(())
768}
769
770fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
771    let mut references = Vec::new();
772    collect_references(&step.input, &mut references);
773    if let Some(condition) = &step.condition {
774        for token in condition.split_whitespace() {
775            if token.starts_with('$') {
776                references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
777            }
778        }
779    }
780
781    for reference in references {
782        if let Some((step_id, field)) = parse_reference(&reference) {
783            if !ids.contains(step_id) {
784                return Err((step.id.clone(), format!("unknown reference '{reference}'")));
785            }
786            if field != "output" && field != "status" {
787                return Err((
788                    step.id.clone(),
789                    format!("unknown reference field '{field}'"),
790                ));
791            }
792        }
793    }
794
795    Ok(())
796}
797
798fn collect_references(value: &Value, references: &mut Vec<String>) {
799    match value {
800        Value::String(value) if value.starts_with('$') => references.push(value.clone()),
801        Value::Array(values) => values
802            .iter()
803            .for_each(|value| collect_references(value, references)),
804        Value::Object(map) => map
805            .values()
806            .for_each(|value| collect_references(value, references)),
807        _ => {}
808    }
809}
810
811/// What a worker thread reports back to the scheduler loop about the step it ran: the verdict,
812/// plus the facts only the worker observed and the terminal `plan_step` line needs.
813///
814/// [`execute_step_once`] returns one attempt's own `attempts: 1` and duration;
815/// [`execute_step_with_retries`] overwrites both with the totals for the step.
816struct StepOutcome {
817    result: StepResult,
818    attempts: u32,
819    duration_ms: u64,
820    /// The interpolated input the attempt dispatched. `Some` for a tool step; `None` for every
821    /// other kind, and for a failure that never reached a dispatch.
822    input: Option<Value>,
823    /// What the tool declared about this call in `tool-result.metadata`. Both `None` for every
824    /// kind but a tool step, and for a tool that declared nothing.
825    state_effect: Option<String>,
826    resource_id: Option<String>,
827}
828
829impl StepOutcome {
830    /// One attempt of a step that declares nothing about itself: every kind but a tool step, and
831    /// every failure that never reached a dispatch.
832    fn plain(result: StepResult, started: Instant) -> Self {
833        Self {
834            result,
835            attempts: 1,
836            duration_ms: elapsed_ms(started),
837            input: None,
838            state_effect: None,
839            resource_id: None,
840        }
841    }
842
843    /// A verdict reached without a dispatch, and so without an attempt to count or time.
844    fn undispatched(result: StepResult) -> Self {
845        Self {
846            result,
847            attempts: 0,
848            duration_ms: 0,
849            input: None,
850            state_effect: None,
851            resource_id: None,
852        }
853    }
854}
855
856fn execute_step_with_retries(
857    step: &StepDef,
858    ctx: &SchedulerContext<'_>,
859    results: &HashMap<String, StepResult>,
860    enforcement: &sandbox::ShellEnforcement,
861) -> StepOutcome {
862    let started = Instant::now();
863    let attempts = step.retries.saturating_add(1);
864    let mut last = None;
865    for attempt in 1..=attempts {
866        let mut outcome = execute_step_once(step, ctx, results, enforcement);
867        outcome.attempts = attempt;
868        outcome.duration_ms = elapsed_ms(started);
869        if outcome.result.status != StepStatus::Failed {
870            return outcome;
871        }
872        last = Some(outcome);
873    }
874
875    last.unwrap_or_else(|| StepOutcome::undispatched(failed(&step.id, "step was not attempted")))
876}
877
878fn execute_step_once(
879    step: &StepDef,
880    ctx: &SchedulerContext<'_>,
881    results: &HashMap<String, StepResult>,
882    enforcement: &sandbox::ShellEnforcement,
883) -> StepOutcome {
884    let started = Instant::now();
885    let mut input = step.input.clone();
886    if let Err(error) = interpolate_value(&mut input, results) {
887        return StepOutcome::plain(failed(&step.id, error), started);
888    }
889    let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
890
891    match infer_kind(step) {
892        Ok(StepKind::Tool) => {
893            let (result, state_effect, resource_id) = dispatch_tool_step(step, ctx, input_json);
894            StepOutcome {
895                result,
896                attempts: 1,
897                duration_ms: elapsed_ms(started),
898                // A step declaring no `input` interpolates to JSON null, which would write
899                // `"input": null` rather than omitting the key. Absent means absent.
900                input: (!input.is_null()).then_some(input),
901                state_effect,
902                resource_id,
903            }
904        }
905        Ok(StepKind::Shell) => {
906            StepOutcome::plain(dispatch_shell_step(step, ctx, enforcement), started)
907        }
908        Ok(StepKind::Spawn) => StepOutcome::plain(dispatch_capsule_step(step, ctx, input), started),
909        Err(error) => StepOutcome::plain(failed(&step.id, error), started),
910    }
911}
912
913/// Dispatches one tool step, and returns the verdict beside the `state_effect` and `resource_id`
914/// the tool declared about the call. Both are read through the agent loop's own extractors, so
915/// a plan step and an agent turn read one tool's self-description the same way.
916fn dispatch_tool_step(
917    step: &StepDef,
918    ctx: &SchedulerContext<'_>,
919    input_json: String,
920) -> (StepResult, Option<String>, Option<String>) {
921    let name = step.tool.as_deref().unwrap_or_default();
922    if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Tool {
923        name,
924        input_json: &input_json,
925    }) {
926        return (failed(&step.id, refusal), None, None);
927    }
928    match (ctx.invoke_tool)(
929        name,
930        ToolInput {
931            data: Some(input_json),
932            log_path: None,
933        },
934    ) {
935        Ok(result) => {
936            let state_effect = crate::agent::extract_state_effect(&result.metadata);
937            let resource_id = crate::agent::extract_resource_id(&result.metadata);
938            let verdict = if matches!(result.status, ToolStatus::Passed) {
939                match tool_step_output(
940                    name,
941                    &ctx.accessible_workdir,
942                    result.data,
943                    result.data_path.as_deref(),
944                ) {
945                    Ok(output) => StepResult {
946                        step_id: step.id.clone(),
947                        status: StepStatus::Success,
948                        output,
949                        error: None,
950                    },
951                    Err(error) => failed(&step.id, error),
952                }
953            } else {
954                failed(
955                    &step.id,
956                    result
957                        .summary
958                        .or(result.data)
959                        .unwrap_or_else(|| "tool step failed".to_string()),
960                )
961            };
962            (verdict, state_effect, resource_id)
963        }
964        Err(error) => (failed(&step.id, error), None, None),
965    }
966}
967
968fn dispatch_shell_step(
969    step: &StepDef,
970    ctx: &SchedulerContext<'_>,
971    enforcement: &sandbox::ShellEnforcement,
972) -> StepResult {
973    let command = step.shell.as_deref().unwrap_or_default();
974    let Some((binary, args)) = parse_shell_command(command) else {
975        return failed(&step.id, "shell command is empty");
976    };
977    if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Shell {
978        binary: &binary,
979        command,
980        argv: &args,
981    }) {
982        return failed(&step.id, refusal);
983    }
984    let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
985
986    match execute_shell(
987        &binary,
988        &arg_refs,
989        &[],
990        &ctx.accessible_workdir,
991        &ctx.session_workdir,
992        &ctx.capability_policy,
993        enforcement,
994    ) {
995        Ok(result) if result.exit_code == 0 => StepResult {
996            step_id: step.id.clone(),
997            status: StepStatus::Success,
998            output: Some(result.stdout),
999            error: None,
1000        },
1001        // A step killed for exceeding a host resource limit fails with the limit named, not with
1002        // whatever the dying process managed to write to stderr — for `SIGXCPU`/`SIGXFSZ` and the
1003        // cgroup kills that is usually nothing at all, which would otherwise report an empty
1004        // error for a step that was very deliberately terminated.
1005        Ok(result) => match result.resource_limit_hit {
1006            Some(limit) => failed(
1007                &step.id,
1008                crate::errors::RuntimeError::ShellResourceLimitExceeded {
1009                    binary: result.binary,
1010                    limit,
1011                    detail: if result.stderr.trim().is_empty() {
1012                        format!("exit code {}", result.exit_code)
1013                    } else {
1014                        result.stderr
1015                    },
1016                }
1017                .to_string(),
1018            ),
1019            // A command that fails without writing to stderr is the common case, not the odd
1020            // one — `test`, `grep -q` and `diff` all report only through their exit status. The
1021            // code is the whole of what failed, so the step carries it rather than an empty
1022            // error the model has to guess at.
1023            None if result.stderr.trim().is_empty() => {
1024                failed(&step.id, format!("exit code {}", result.exit_code))
1025            }
1026            None => failed(&step.id, result.stderr),
1027        },
1028        // Including a sealed composed-root failure, which fails the step carrying its full
1029        // named text. Unlike the agent turn loop (which owns a session and ends it — see
1030        // `agent::run_agent_loop`), this scheduler owns nothing above the step it is running,
1031        // so naming the cause in the step's error is the whole of what it can do.
1032        Err(error) => failed(&step.id, error.to_string()),
1033    }
1034}
1035
1036fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1037    let capsule = step.capsule.as_deref().unwrap_or_default();
1038    // `validate_plan` already accepted this step's un-interpolated input, and interpolation
1039    // replaces a string with a string, so the refusal here is unreachable through `execute`.
1040    let task = match capsule_task_text(&input) {
1041        Ok(task) => task,
1042        Err(error) => return failed(&step.id, error),
1043    };
1044    let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1045        Ok(value) if !value.trim().is_empty() => value,
1046        _ => {
1047            return failed(
1048                &step.id,
1049                "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1050            )
1051        }
1052    };
1053    let Some(credential) = ctx.spawn_credential.as_ref() else {
1054        return failed(
1055            &step.id,
1056            "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1057             mints it only for a session whose manifest declares capabilities.spawn.allow",
1058        );
1059    };
1060
1061    // Everything a delegation is — the referee's approval, the child's process, the task delivery
1062    // and the bounded wait — belongs to `DelegationPlane`, and a capsule step is input and output
1063    // marshalling around it. The agent-facing `delegate-task` tool calls the same code, so there
1064    // is one delegation mechanism in this crate rather than two that drift.
1065    // A plan step reads no manifest of its own here, so it delegates under the default bound
1066    // rather than under a declared one.
1067    let plane = DelegationPlane::new(
1068        roost_url,
1069        credential.clone(),
1070        ctx.accessible_workdir.clone(),
1071        ctx.current_session_id.clone().unwrap_or_default(),
1072        crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1073        std::sync::Arc::clone(&ctx.registry),
1074        ctx.capability_policy.env_allow.clone(),
1075    );
1076    // The launch notice is written where it arrives, on this thread, while the child is still
1077    // holding its task: `delegate` blocks until the child answers, so a notice parked for later
1078    // would only reach disk once there was already a terminal line to write beside it.
1079    let launched = ctx.trace.map(|trace| {
1080        std::sync::Arc::new(move |notice: crate::delegation_plane::DelegationLaunch| {
1081            trace.write_delegation_start(
1082                &notice.delegation_id,
1083                &notice.capsule,
1084                &notice.version,
1085                &notice.child_session_id,
1086                &notice.child_workdir,
1087            );
1088        }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1089    });
1090    let started = Instant::now();
1091    let result = plane.delegate(
1092        &DelegationRequest {
1093            capsule: capsule.to_string(),
1094            // A plan names a capsule; the version comes from the context the plan was validated
1095            // against, and a context that names none means `0.1.0`.
1096            version: ctx
1097                .capsule_versions
1098                .get(capsule)
1099                .cloned()
1100                .unwrap_or_else(|| "0.1.0".to_string()),
1101            task,
1102        },
1103        &DelegationOrigin {
1104            context_id: ctx.current_context_id.clone().unwrap_or_default(),
1105            launched,
1106            // `delegate` waits on the connection it holds, so nothing this delegation does posts
1107            // a completion and there is no trust for one to inherit.
1108            trust: None,
1109        },
1110    );
1111
1112    if let Some(trace) = ctx.trace {
1113        trace.write_delegation(
1114            &result.capsule,
1115            &result.version,
1116            // Empty rather than absent is how a result names no child; the record says `null`,
1117            // so nothing reading it can join against an id that was never minted.
1118            Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1119            Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1120            elapsed_ms(started),
1121            result.status.as_str(),
1122            match result.status {
1123                DelegationStatus::Completed => None,
1124                _ => Some(result.output.clone()),
1125            },
1126        );
1127    }
1128
1129    match result.status {
1130        DelegationStatus::Completed => StepResult {
1131            step_id: step.id.clone(),
1132            status: StepStatus::Success,
1133            output: Some(result.output),
1134            error: None,
1135        },
1136        _ => failed(&step.id, result.output),
1137    }
1138}
1139
1140/// The task text a capsule step hands to the child capsule, or the reason the step's `input`
1141/// cannot become task text.
1142///
1143/// Whatever this returns becomes the child's first user message verbatim: `DelegationPlane`
1144/// sends it as the sole text part of an A2A `message/send`, the receiving runtime writes that
1145/// text to `task.md`, and `agent::read_task` pushes it at the model. Nothing on the receiving
1146/// side parses a task envelope, so an envelope is not structure — it is prose that happens to be
1147/// JSON, and it spends the child's first turn on decoding. A capsule step's `input` is therefore
1148/// the task text and nothing else, in one of two shapes:
1149///
1150/// | `input` | task text |
1151/// | --- | --- |
1152/// | a JSON string | that string, verbatim |
1153/// | `{ "objective": "<text>" }`, and no other key | `<text>`, verbatim |
1154///
1155/// Every other shape is refused rather than serialized into the message. Interpolation replaces
1156/// a string with a string and so cannot change the shape, which is what lets `validate_plan`
1157/// refuse the whole plan before a cgroup scope is taken or a child is spawned.
1158fn capsule_task_text(input: &Value) -> Result<String, String> {
1159    const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1160    const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1161                            receives, given as a string or as { \"objective\": \"<text>\" }";
1162
1163    match input {
1164        Value::String(text) => Ok(text.clone()),
1165        Value::Object(map) => {
1166            // serde_json's map is a BTreeMap or an IndexMap depending on the `preserve_order`
1167            // feature, so the listed keys are sorted here rather than taken in iteration order.
1168            let mut unknown = map
1169                .keys()
1170                .filter(|key| key.as_str() != "objective")
1171                .map(String::as_str)
1172                .collect::<Vec<_>>();
1173            unknown.sort_unstable();
1174            if !unknown.is_empty() {
1175                return Err(format!(
1176                    "capsule step input has keys the child cannot receive: {}; nothing on the \
1177                     receiving side parses a task envelope, so put the whole instruction in \
1178                     \"objective\"",
1179                    unknown.join(", ")
1180                ));
1181            }
1182
1183            match map.get("objective") {
1184                Some(Value::String(text)) => Ok(text.clone()),
1185                Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1186                None => Err(ACCEPTS.to_string()),
1187            }
1188        }
1189        Value::Null => Err(NO_INPUT.to_string()),
1190        Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1191        Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1192        Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1193    }
1194}
1195
1196fn interpolate_value(
1197    value: &mut Value,
1198    results: &HashMap<String, StepResult>,
1199) -> Result<(), String> {
1200    match value {
1201        Value::String(text) if text.starts_with('$') => {
1202            *text = condition::resolve_reference(text, results)?;
1203        }
1204        Value::Array(values) => {
1205            for value in values {
1206                interpolate_value(value, results)?;
1207            }
1208        }
1209        Value::Object(map) => {
1210            for value in map.values_mut() {
1211                interpolate_value(value, results)?;
1212            }
1213        }
1214        _ => {}
1215    }
1216
1217    Ok(())
1218}
1219
1220fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1221    let count =
1222        step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1223    match count {
1224        1 if step.tool.is_some() => Ok(StepKind::Tool),
1225        1 if step.shell.is_some() => Ok(StepKind::Shell),
1226        1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1227        0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1228        _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1229    }
1230}
1231
1232fn default_on_error() -> String {
1233    "fail".to_string()
1234}
1235
1236fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1237    StepResult {
1238        step_id: step_id.to_string(),
1239        status: StepStatus::Failed,
1240        output: None,
1241        error: Some(error.into()),
1242    }
1243}
1244
1245fn insert_result(
1246    results: &mut HashMap<String, StepResult>,
1247    order: &mut Vec<String>,
1248    result: StepResult,
1249) {
1250    order.push(result.step_id.clone());
1251    results.insert(result.step_id.clone(), result);
1252}
1253
1254fn shell_binary(command: &str) -> Option<String> {
1255    parse_shell_command(command).map(|(binary, _)| binary)
1256}
1257
1258fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1259    let parts = split_shell_words(command);
1260    let (binary, args) = parts.split_first()?;
1261    Some((binary.clone(), args.to_vec()))
1262}
1263
1264fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1265    reference.strip_prefix('$')?.rsplit_once('.')
1266}
1267
1268/// The output of a tool step that passed: in-band `data` when the tool sent it, otherwise the
1269/// file the tool named in `data_path`.
1270///
1271/// `data_path` is the escape hatch for a result too large to carry in band — the system prompt
1272/// tells the model to read it when `truncated` is set — so a path the host cannot honour is
1273/// resolved against the guest's own boundary rather than dropped from the interface. The
1274/// resolution is [`resource_plane::read_file_beneath_root`] against `workdir`, the directory the
1275/// tool's `.` means, and a refusal fails the step: a step whose declared output could not be read
1276/// did not succeed.
1277///
1278/// The symlink policy is [`SymlinkPolicy::Refuse`] whatever containment class the session
1279/// achieved. `resource_plane::symlink_policy` follows symlinks under `sealed` and `advisory`
1280/// because a capsule reading its own export root has no outside to name; this read runs in the
1281/// host process, which sits outside every class's boundary with ambient filesystem authority, so
1282/// a symlink under the workdir pointing at `/etc/shadow` would be followed successfully at any
1283/// class.
1284fn tool_step_output(
1285    tool: &str,
1286    workdir: &Path,
1287    data: Option<String>,
1288    data_path: Option<&str>,
1289) -> Result<Option<String>, String> {
1290    if let Some(data) = data {
1291        return Ok(Some(data));
1292    }
1293    let Some(relpath) = data_path else {
1294        return Ok(None);
1295    };
1296    match resource_plane::read_file_beneath_root(
1297        workdir,
1298        relpath,
1299        DEFAULT_EXPORT_MAX_BYTES,
1300        SymlinkPolicy::Refuse,
1301    ) {
1302        Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1303        Err(error) => Err(RuntimeError::ToolDataPathRefused {
1304            tool: tool.to_string(),
1305            path: relpath.to_string(),
1306            code: error.code().to_string(),
1307            detail: error.message(),
1308        }
1309        .to_string()),
1310    }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315    use std::{
1316        sync::{
1317            atomic::{AtomicUsize, Ordering},
1318            Arc,
1319        },
1320        time::{Duration, Instant},
1321    };
1322
1323    use serde_json::json;
1324    use tempfile::tempdir;
1325
1326    use super::*;
1327
1328    /// The `shell_allow` grant below is what makes every test built on this context need a
1329    /// delegated cgroup v2 scope: `execute` bounds the plan's whole subprocess tree before
1330    /// running a step, and fails closed when it cannot.
1331    fn test_ctx<'a>(
1332        workdir: PathBuf,
1333        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1334    ) -> SchedulerContext<'a> {
1335        test_ctx_gated(workdir, invoke_tool, &UNGATED)
1336    }
1337
1338    /// A decision point that refuses nothing, which is what a session with neither a policy hook
1339    /// nor a `capabilities.filesystem.read_only` declaration presents.
1340    const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1341
1342    fn test_ctx_gated<'a>(
1343        workdir: PathBuf,
1344        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1345        gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1346    ) -> SchedulerContext<'a> {
1347        SchedulerContext {
1348            accessible_workdir: workdir.clone(),
1349            session_workdir: workdir,
1350            capability_policy: CapabilityPolicy {
1351                shell_allow: vec!["bash".to_string()],
1352                spawn_allow: vec!["worker".to_string()],
1353                ..CapabilityPolicy::default()
1354            },
1355            installed_tools: HashSet::from([
1356                "ok".to_string(),
1357                "fail".to_string(),
1358                "slow".to_string(),
1359                "a".to_string(),
1360                "b".to_string(),
1361            ]),
1362            capsule_versions: HashMap::new(),
1363            current_session_id: None,
1364            current_context_id: None,
1365            // Nothing in these cases delegates, so no artifact is ever resolved through it.
1366            registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1367                tempdir().unwrap().keep(),
1368            )),
1369            spawn_credential: None,
1370            trace: None,
1371            gate_step,
1372            invoke_tool,
1373        }
1374    }
1375
1376    fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1377        ToolResult {
1378            status,
1379            summary: None,
1380            data,
1381            data_path: None,
1382            truncated: false,
1383            metadata: Vec::new(),
1384        }
1385    }
1386
1387    fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1388        let path = workdir.join("plan.json");
1389        fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1390        path
1391    }
1392
1393    #[test]
1394    fn capsule_task_text_delivers_a_bare_string_verbatim() {
1395        assert_eq!(
1396            capsule_task_text(&json!("do the thing")).unwrap(),
1397            "do the thing"
1398        );
1399    }
1400
1401    #[test]
1402    fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1403        assert_eq!(
1404            capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1405            "do the thing"
1406        );
1407    }
1408
1409    /// An empty task is a well-shaped one. What a child does with nothing to do is the child's
1410    /// business, not this contract's.
1411    #[test]
1412    fn capsule_task_text_accepts_empty_task_text() {
1413        assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1414        assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1415    }
1416
1417    #[test]
1418    fn capsule_task_text_refuses_a_task_envelope() {
1419        let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1420        assert!(
1421            error.contains("keys the child cannot receive: task"),
1422            "{error}"
1423        );
1424        assert!(error.contains("\"objective\""), "{error}");
1425    }
1426
1427    #[test]
1428    fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1429        let error =
1430            capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1431                .unwrap_err();
1432        assert!(
1433            error.contains("keys the child cannot receive: instructions, render_as"),
1434            "{error}"
1435        );
1436    }
1437
1438    #[test]
1439    fn capsule_task_text_refuses_a_non_string_objective() {
1440        for input in [json!({"objective": 7}), json!({"objective": null})] {
1441            assert_eq!(
1442                capsule_task_text(&input).unwrap_err(),
1443                "capsule step input \"objective\" must be a string"
1444            );
1445        }
1446    }
1447
1448    #[test]
1449    fn capsule_task_text_refuses_an_object_without_an_objective() {
1450        assert_eq!(
1451            capsule_task_text(&json!({})).unwrap_err(),
1452            "capsule step input must be a string or { \"objective\": \"<text>\" }"
1453        );
1454    }
1455
1456    #[test]
1457    fn capsule_task_text_names_the_type_it_was_handed() {
1458        assert!(capsule_task_text(&json!(7))
1459            .unwrap_err()
1460            .ends_with("not a number"));
1461        assert!(capsule_task_text(&json!(true))
1462            .unwrap_err()
1463            .ends_with("not a boolean"));
1464        assert!(capsule_task_text(&json!(["a"]))
1465            .unwrap_err()
1466            .ends_with("not an array"));
1467    }
1468
1469    /// `StepDef::input` defaults to `Value::Null`, so an omitted `input` and an explicit null
1470    /// are the same value and get the same refusal.
1471    #[test]
1472    fn capsule_task_text_refuses_an_absent_input() {
1473        let error = capsule_task_text(&Value::Null).unwrap_err();
1474        assert_eq!(
1475            error,
1476            "capsule step has no input; its input is the task text the child receives, given as \
1477             a string or as { \"objective\": \"<text>\" }"
1478        );
1479    }
1480
1481    /// A `$reference` is a string, so both accepted shapes survive load-time validation and are
1482    /// resolved at dispatch.
1483    #[test]
1484    fn capsule_task_text_accepts_an_uninterpolated_reference() {
1485        assert_eq!(
1486            capsule_task_text(&json!("$analyse.output")).unwrap(),
1487            "$analyse.output"
1488        );
1489        assert_eq!(
1490            capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1491            "$analyse.output"
1492        );
1493    }
1494
1495    /// [`test_ctx_gated`] with no `shell` or `spawn` grant, so the run needs no delegated cgroup
1496    /// scope and holds on any host.
1497    fn test_ctx_unbounded<'a>(
1498        workdir: PathBuf,
1499        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1500        gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1501    ) -> SchedulerContext<'a> {
1502        SchedulerContext {
1503            capability_policy: CapabilityPolicy::default(),
1504            ..test_ctx_gated(workdir, invoke_tool, gate_step)
1505        }
1506    }
1507
1508    /// The decision point refuses, and the step it refused never reaches the executor.
1509    ///
1510    /// The refusal has to arrive as the step's own error rather than as an empty success, because
1511    /// a plan whose steps quietly did nothing is indistinguishable from one that worked.
1512    #[test]
1513    fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1514        let dir = tempdir().unwrap();
1515        let dispatched = Arc::new(AtomicUsize::new(0));
1516        let counted = Arc::clone(&dispatched);
1517        let invoke = move |_name: &str, _input: ToolInput| {
1518            counted.fetch_add(1, Ordering::SeqCst);
1519            Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1520        };
1521        let gate = |call: &PlannedCall<'_>| match call {
1522            PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1523            _ => None,
1524        };
1525        let plan = write_plan(
1526            dir.path(),
1527            json!({
1528                "id": "p",
1529                "steps": [
1530                    {"id": "refused", "tool": "a", "on_error": "continue"},
1531                    {"id": "allowed", "tool": "b"}
1532                ]
1533            }),
1534        );
1535
1536        let report = execute(
1537            &plan,
1538            &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1539        );
1540        let refused = report
1541            .results
1542            .iter()
1543            .find(|result| result.step_id == "refused")
1544            .expect("the refused step settled");
1545        assert_eq!(refused.status, StepStatus::Failed);
1546        assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1547        assert_eq!(refused.output, None);
1548        // The one dispatch is the step the gate let through.
1549        assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1550    }
1551
1552    /// A command that fails silently still says why. The model is handed the step's `error` as
1553    /// the whole account of what went wrong, so an empty one reports a failure with no reason.
1554    #[test]
1555    fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1556        if crate::cgroup::skip_without_host_support(
1557            "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1558        ) {
1559            return;
1560        }
1561        let dir = tempdir().unwrap();
1562        let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1563        let plan = write_plan(
1564            dir.path(),
1565            json!({
1566                "id": "p",
1567                "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1568            }),
1569        );
1570
1571        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1572        assert!(!report.completed);
1573        assert_eq!(report.results[0].status, StepStatus::Failed);
1574        assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1575    }
1576
1577    /// A `shell` step is put to the same decision point, and a refused one starts no subprocess.
1578    #[test]
1579    fn a_refused_shell_step_starts_no_subprocess() {
1580        if crate::cgroup::skip_without_host_support(
1581            "plan::tests::a_refused_shell_step_starts_no_subprocess",
1582        ) {
1583            return;
1584        }
1585        let dir = tempdir().unwrap();
1586        let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1587        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1588        let recorded = Arc::clone(&seen);
1589        let gate = move |call: &PlannedCall<'_>| match call {
1590            PlannedCall::Shell { binary, argv, .. } => {
1591                recorded
1592                    .lock()
1593                    .unwrap()
1594                    .push((binary.to_string(), argv.to_vec()));
1595                Some("refused by the manifest".to_string())
1596            }
1597            _ => None,
1598        };
1599        let marker = dir.path().join("written-by-a-refused-step");
1600        let plan = write_plan(
1601            dir.path(),
1602            json!({
1603                "id": "p",
1604                "steps": [{
1605                    "id": "w",
1606                    "shell": format!("bash -c 'printf x > {}'", marker.display()),
1607                }]
1608            }),
1609        );
1610
1611        let report = execute(
1612            &plan,
1613            &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
1614        );
1615        assert!(!report.completed);
1616        assert_eq!(report.failed_step.as_deref(), Some("w"));
1617        assert_eq!(
1618            report.results[0].error.as_deref(),
1619            Some("refused by the manifest")
1620        );
1621        assert!(!marker.exists(), "a refused step must not have run");
1622        // The gate is shown the argv the executable would have received, not the raw command.
1623        let seen = seen.lock().unwrap();
1624        assert_eq!(seen.len(), 1);
1625        assert_eq!(seen[0].0, "bash");
1626        assert_eq!(seen[0].1[0], "-c");
1627    }
1628
1629    #[test]
1630    fn dependent_step_receives_upstream_output() {
1631        if crate::cgroup::skip_without_host_support(
1632            "plan::tests::dependent_step_receives_upstream_output",
1633        ) {
1634            return;
1635        }
1636        let dir = tempdir().unwrap();
1637        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1638        let seen_tool = Arc::clone(&seen);
1639        let invoke = move |_name: &str, input: ToolInput| {
1640            seen_tool
1641                .lock()
1642                .unwrap()
1643                .push(input.data.unwrap_or_default());
1644            Ok(tool_result(
1645                ToolStatus::Passed,
1646                Some("upstream".to_string()),
1647            ))
1648        };
1649        let plan = write_plan(
1650            dir.path(),
1651            json!({
1652                "id": "p",
1653                "steps": [
1654                    {"id": "a", "tool": "ok"},
1655                    {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
1656                ]
1657            }),
1658        );
1659
1660        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1661        assert!(report.completed);
1662        assert_eq!(report.results.len(), 2);
1663        assert!(seen.lock().unwrap()[1].contains("upstream"));
1664    }
1665
1666    #[test]
1667    fn parallel_tool_steps_run_concurrently() {
1668        if crate::cgroup::skip_without_host_support(
1669            "plan::tests::parallel_tool_steps_run_concurrently",
1670        ) {
1671            return;
1672        }
1673        let dir = tempdir().unwrap();
1674        let active = Arc::new(AtomicUsize::new(0));
1675        let max_active = Arc::new(AtomicUsize::new(0));
1676        let invoke_active = Arc::clone(&active);
1677        let invoke_max = Arc::clone(&max_active);
1678        let invoke = move |_name: &str, _input: ToolInput| {
1679            let now = invoke_active.fetch_add(1, Ordering::SeqCst) + 1;
1680            invoke_max.fetch_max(now, Ordering::SeqCst);
1681            thread::sleep(Duration::from_millis(150));
1682            invoke_active.fetch_sub(1, Ordering::SeqCst);
1683            Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1684        };
1685        let plan = write_plan(
1686            dir.path(),
1687            json!({
1688                "id": "p",
1689                "steps": [
1690                    {"id": "a", "tool": "slow"},
1691                    {"id": "b", "tool": "slow"}
1692                ]
1693            }),
1694        );
1695
1696        let started = Instant::now();
1697        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1698        assert!(report.completed);
1699        assert!(started.elapsed() < Duration::from_millis(280));
1700        assert_eq!(max_active.load(Ordering::SeqCst), 2);
1701    }
1702
1703    #[test]
1704    fn on_error_and_retries_are_applied() {
1705        if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
1706        {
1707            return;
1708        }
1709        let dir = tempdir().unwrap();
1710        let calls = Arc::new(AtomicUsize::new(0));
1711        let invoke_calls = Arc::clone(&calls);
1712        let invoke = move |name: &str, _input: ToolInput| {
1713            invoke_calls.fetch_add(1, Ordering::SeqCst);
1714            if name == "a" {
1715                Ok(tool_result(ToolStatus::Failed, None))
1716            } else {
1717                Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1718            }
1719        };
1720        let plan = write_plan(
1721            dir.path(),
1722            json!({
1723                "id": "p",
1724                "steps": [
1725                    {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
1726                    {"id": "b", "tool": "b", "depends_on": ["a"]}
1727                ]
1728            }),
1729        );
1730
1731        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1732        assert!(report.completed);
1733        assert_eq!(calls.load(Ordering::SeqCst), 4);
1734    }
1735
1736    #[test]
1737    fn condition_false_skips_step() {
1738        if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
1739            return;
1740        }
1741        let dir = tempdir().unwrap();
1742        let invoke = |_name: &str, _input: ToolInput| {
1743            Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
1744        };
1745        let plan = write_plan(
1746            dir.path(),
1747            json!({
1748                "id": "p",
1749                "steps": [
1750                    {"id": "a", "tool": "ok"},
1751                    {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
1752                ]
1753            }),
1754        );
1755
1756        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1757        assert!(report.completed);
1758        assert_eq!(report.results[1].status, StepStatus::Skipped);
1759    }
1760}