Skip to main content

capsule_runtime/
plan.rs

1use std::{
2    collections::{HashMap, HashSet},
3    path::Path,
4    sync::Arc,
5    thread,
6    time::Instant,
7};
8
9use serde::Deserialize;
10use serde_json::Value;
11
12use murmur_artifact::DEFAULT_EXPORT_MAX_BYTES;
13
14use crate::{
15    bindings::host::murmur::tool::run::{Status as ToolStatus, ToolInput, ToolResult},
16    delegation_plane::{
17        DelegationLaunch, DelegationOrigin, DelegationPlane, DelegationRequest, DelegationStatus,
18    },
19    errors::RuntimeError,
20    network_namespace::SessionNetns,
21    resource_plane::{self, SymlinkPolicy},
22    sandbox,
23    shell::{execute_shell, split_shell_words},
24    spawn_credential::SpawnCredential,
25    trace::{PlanEndRecord, PlanStepRecord, PlanStepShape, PlanTraceAppender},
26    types::CapabilityPolicy,
27    workdir_fs::WorkdirRoot,
28};
29
30pub mod condition;
31
32#[derive(Debug, Deserialize)]
33struct PlanFile {
34    id: String,
35    steps: Vec<StepDef>,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39struct StepDef {
40    id: String,
41    tool: Option<String>,
42    shell: Option<String>,
43    capsule: Option<String>,
44    #[serde(default)]
45    input: Value,
46    #[serde(default)]
47    depends_on: Vec<String>,
48    #[serde(rename = "if")]
49    condition: Option<String>,
50    #[serde(default = "default_on_error")]
51    on_error: String,
52    #[serde(default)]
53    retries: u32,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum StepKind {
58    Tool,
59    Shell,
60    Spawn,
61}
62
63impl StepKind {
64    /// The name a trace line carries for this kind. `"capsule"` rather than `"spawn"`: a trace
65    /// names the plan key its author wrote, not the host's word for what it does.
66    fn as_str(self) -> &'static str {
67        match self {
68            StepKind::Tool => "tool",
69            StepKind::Shell => "shell",
70            StepKind::Spawn => "capsule",
71        }
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum StepStatus {
77    Success,
78    Failed,
79    Skipped,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct StepResult {
84    pub step_id: String,
85    pub status: StepStatus,
86    pub output: Option<String>,
87    pub error: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ExecutionReport {
92    pub results: Vec<StepResult>,
93    pub completed: bool,
94    /// `None` on a cancelled run: no step's failure is what stopped it.
95    pub failed_step: Option<String>,
96    /// Whether the run stopped because [`SchedulerContext::task`] reported its task cancelled.
97    /// `completed` is `false` whenever this is `true`.
98    pub canceled: bool,
99}
100
101/// The error a `capsule` step settles with when the task running its plan is cancelled while
102/// its sub-capsule is running.
103pub const CANCELED_STEP_ERROR: &str =
104    "the task running this plan was cancelled; this step's sub-capsule was ended with it";
105
106/// The error every step a cancelled run never dispatched settles `skipped` with.
107pub const CANCELED_BEFORE_STEP_ERROR: &str =
108    "the task running this plan was cancelled before this step ran";
109
110/// The `reason` on a cancelled run's `plan_end`.
111pub const PLAN_CANCELED_REASON: &str = "the task running this plan was cancelled";
112
113/// The task a plan runs for, as the scheduler sees it: whether it has been cancelled, and the set
114/// that accounts for every sub-capsule the task starts.
115///
116/// One rule keeps a `capsule` step's terminal `delegation` line single: whoever takes the step's
117/// row out of the task's set writes it. A step that ends on its own takes the row through
118/// [`PlanTask::delegation_settled`]; a step whose task was cancelled leaves it, and the task
119/// closes it after its `task_canceled`.
120pub trait PlanTask: Sync {
121    /// Whether the task this plan runs for has been cancelled. Polled; cheap, never blocks.
122    fn is_canceled(&self) -> bool;
123    /// A capsule step's child is up: account for it in the task's delegation set before it is
124    /// handed its task.
125    fn delegation_started(&self, launch: &DelegationLaunch);
126    /// The step is closing its row. `true` when the row was still the step's and the step now
127    /// writes the terminal `delegation` line; `false` when the task already holds it.
128    #[must_use]
129    fn delegation_settled(&self, delegation_id: &str) -> bool;
130}
131
132pub struct SchedulerContext<'a> {
133    /// The directory a `shell` step runs in and a `tool` step resolves a `data_path` against —
134    /// the capsule's own workdir, which under `--workdir` is the operator's directory.
135    pub accessible_workdir: WorkdirRoot,
136    /// The session's runtime directory and its siblings: the plan file is read from
137    /// `runtime/plans/`, and every `shell` step is told the directory through
138    /// `$MURMUR_RUNTIME_DIR` and may read it and not change it, as the session's own commands may.
139    /// A `shell` step's `$HOME` and `$TMPDIR` are its records directory's `home/` and `tmp/`.
140    pub runtime_dir: crate::runtime_dir::RuntimeDirRoots,
141    pub capability_policy: CapabilityPolicy,
142    pub installed_tools: HashSet<String>,
143    pub capsule_versions: HashMap<String, String>,
144    pub current_session_id: Option<String>,
145    /// The conversation the task that started this plan is running in, named on the
146    /// `MURMUR_SPAWNER` handle every `capsule` step injects into its child.
147    ///
148    /// Nothing is minted here. `None` names no conversation and injects no handle at all, which
149    /// is the existing behaviour for a caller that has none, and not an error: a child told it
150    /// was spawned from a conversation that does not exist is worse than one told nothing.
151    pub current_context_id: Option<String>,
152    /// Where a `capsule` step reads a sub-capsule's manifest from, to learn which host variables
153    /// that sub-capsule declares. The session's own store, so a step resolves the artifact its
154    /// child will run.
155    pub registry: std::sync::Arc<dyn murmur_artifact::Registry>,
156    /// The credential this session presents to `mur-roost` when a `capsule` step delegates.
157    ///
158    /// `None` for every session that was not granted `capabilities.spawn.allow`, and a `capsule`
159    /// step in such a session fails before any request is made. The token is held here and read
160    /// exactly twice — into the two request headers below — so nothing that formats this context,
161    /// a step result or a trace record can carry it.
162    pub spawn_credential: Option<SpawnCredential>,
163    /// The session's formation, which every `capsule` step's child joins. `None` for a session
164    /// nobody placed in a formation, whose children carry none either.
165    pub formation_id: Option<crate::formation::FormationId>,
166    /// The session's deadline, which every `capsule` step's child inherits as its own session
167    /// deadline. `None` bounds no child.
168    pub deadline: Option<crate::session_status::SessionDeadline>,
169    /// Where this run's per-step lifecycle records and its `capsule` steps' delegation records
170    /// go, or `None` to record nothing.
171    ///
172    /// Emission is opt-in at the call site: with `None` no file is opened and no line is
173    /// written, and the run's `ExecutionReport` is identical either way.
174    ///
175    /// A `capsule` step writes into it twice over, and the two pairs describe different things:
176    /// the plan-step pair is the scheduler's unit of work — its dependencies, its attempts, its
177    /// status after `on_error` — and the delegation pair is one child launch, named by its `dlg_`
178    /// id. A retried step is one plan step and several delegations.
179    pub trace: Option<&'a PlanTraceAppender>,
180    /// The session's own pre-dispatch decision point, asked about every `tool` and `shell` step
181    /// before it runs. `Some(reason)` fails that step with `reason` as its error and dispatches
182    /// nothing.
183    ///
184    /// A plan step is a call the model made, one indirection removed, so it is subject to the
185    /// same two refusals a direct call is: `capabilities.filesystem.read_only` and a
186    /// `commit_policy: deny` policy hook. Without this the scheduler would reach the session's
187    /// executors underneath both, and a command the session refuses would run as a plan step.
188    ///
189    /// A `capsule` step is not asked about: it runs nothing in this session, and the child it
190    /// delegates to applies its own manifest to whatever it does.
191    pub gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
192    pub invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
193    /// The task this plan runs for, or `None` for a run that cannot be cancelled and whose
194    /// `capsule` steps account for their children in no set beyond their own trace lines.
195    ///
196    /// Once it reports cancelled, a running `capsule` step ends its sub-capsule, no step is
197    /// retried or dispatched, and the run ends `canceled`. A `tool` or `shell` step already
198    /// running finishes on its own bound.
199    pub task: Option<&'a dyn PlanTask>,
200}
201
202/// What a step is about to dispatch, as [`SchedulerContext::gate_step`] is shown it.
203///
204/// The scheduler holds no session state and resolves nothing itself: it hands over the same
205/// values it is about to hand the executor, and the gate turns them into whatever its own
206/// decision point reads.
207pub enum PlannedCall<'a> {
208    Tool {
209        name: &'a str,
210        /// The step's `input`, interpolated, exactly as the tool will receive it.
211        input_json: &'a str,
212    },
213    Shell {
214        /// The first word of the step's `shell` command.
215        binary: &'a str,
216        /// The step's `shell` command as written, untruncated.
217        command: &'a str,
218        /// The argument list the executable will receive, already split. No shell expands it, so
219        /// what is here is literally what runs.
220        argv: &'a [String],
221    },
222}
223
224/// Run the plan at `plan_path`. Its shell steps get a network namespace of their own, released
225/// when the plan ends.
226pub fn execute(plan_path: &Path, ctx: &SchedulerContext<'_>) -> ExecutionReport {
227    execute_with_netns(plan_path, ctx, None)
228}
229
230/// [`execute`] for a plan submitted inside a session: its shell steps run in that session's
231/// network namespace, so they reach a server the session's own commands started, and the plan
232/// leaves the namespace to the session when it ends.
233pub(crate) fn execute_in_session(
234    plan_path: &Path,
235    ctx: &SchedulerContext<'_>,
236    session_netns: Arc<SessionNetns>,
237) -> ExecutionReport {
238    execute_with_netns(plan_path, ctx, Some(session_netns))
239}
240
241fn execute_with_netns(
242    plan_path: &Path,
243    ctx: &SchedulerContext<'_>,
244    session_netns: Option<Arc<SessionNetns>>,
245) -> ExecutionReport {
246    let started = Instant::now();
247    match execute_inner(plan_path, ctx, started, session_netns) {
248        Ok(report) => report,
249        // The only failures that reach here are a plan file that could not be read or parsed, so
250        // there is no DAG to have described and no plan node for the summary to hang off.
251        Err((failed_step, error)) => {
252            if let Some(appender) = ctx.trace {
253                appender.write_plan_end(
254                    None,
255                    PlanEndRecord {
256                        plan_id: String::new(),
257                        outcome: "failed",
258                        failed_step: Some(failed_step.clone()),
259                        steps_total: 0,
260                        steps_succeeded: 0,
261                        steps_failed: 0,
262                        steps_skipped: 0,
263                        duration_ms: elapsed_ms(started),
264                        reason: Some(error.clone()),
265                    },
266                );
267            }
268            ExecutionReport {
269                results: vec![StepResult {
270                    step_id: failed_step.clone(),
271                    status: StepStatus::Failed,
272                    output: None,
273                    error: Some(error),
274                }],
275                completed: false,
276                failed_step: Some(failed_step),
277                canceled: false,
278            }
279        }
280    }
281}
282
283fn execute_inner(
284    plan_path: &Path,
285    ctx: &SchedulerContext<'_>,
286    started: Instant,
287    session_netns: Option<Arc<SessionNetns>>,
288) -> Result<ExecutionReport, (String, String)> {
289    // Read through the runtime directory's root, never by path, from the directory no capsule
290    // command can write: the plan that runs is the plan that was validated.
291    let runtime_root = &ctx.runtime_dir.runtime;
292    let relative = plan_path
293        .strip_prefix(runtime_root.path())
294        .unwrap_or(plan_path);
295    let raw = match runtime_root.read_to_string(relative) {
296        Ok(Some(raw)) => raw,
297        Ok(None) => {
298            return Err((
299                "plan".to_string(),
300                format!(
301                    "failed to read plan file {}: it does not exist",
302                    plan_path.display()
303                ),
304            ))
305        }
306        Err(error) => {
307            return Err((
308                "plan".to_string(),
309                format!("failed to read plan file: {error}"),
310            ))
311        }
312    };
313    let plan: PlanFile = serde_json::from_str(&raw).map_err(|error| {
314        (
315            "plan".to_string(),
316            format!("failed to parse plan JSON: {error}"),
317        )
318    })?;
319
320    // The DAG is described the moment the file parses, before it is validated: a plan the
321    // validator refuses still has a structure worth reading, and the refusal is only legible
322    // beside the steps it names.
323    let plan_node = ctx
324        .trace
325        .map(|appender| appender.write_plan_start(&plan.id, plan_shape(&plan)));
326
327    if let Err((step_id, error)) = validate_plan(&plan, ctx) {
328        trace_plan_end(
329            ctx,
330            plan_node.as_deref(),
331            PlanEndRecord {
332                plan_id: plan.id.clone(),
333                outcome: "failed",
334                failed_step: Some(step_id.clone()),
335                steps_total: plan.steps.len(),
336                steps_succeeded: 0,
337                steps_failed: 0,
338                steps_skipped: 0,
339                duration_ms: elapsed_ms(started),
340                reason: Some(error.clone()),
341            },
342        );
343        return Ok(ExecutionReport {
344            results: vec![StepResult {
345                step_id: step_id.clone(),
346                status: StepStatus::Failed,
347                output: None,
348                error: Some(error),
349            }],
350            completed: false,
351            failed_step: Some(step_id.clone()),
352            canceled: false,
353        });
354    }
355
356    // Aggregate process bounding for this plan's shell steps, on the same fail-closed terms as
357    // `runtime::launch_session`: a plan that can run a subprocess on Linux does not start at all
358    // unless its tree can be put in a cgroup scope.
359    let cgroup_scope = match crate::cgroup::prepare_policy_scope(
360        &ctx.capability_policy,
361        &plan.id,
362        ctx.accessible_workdir.path(),
363    ) {
364        // The plan's own `io.max` outcome is dropped here rather than reported: this scope is a
365        // second one, created for the plan's shell steps inside a session whose `session_start`
366        // already carries the launch scope's `io_max` and whose launch already fired `W-SEC-021`
367        // if the ceiling did not apply. Both scopes sit on the same delegated base over the same
368        // workdir device, so a per-plan repetition would say nothing new.
369        Ok(prepared) => prepared.scope,
370        Err(reason) => {
371            let error =
372                crate::errors::RuntimeError::CgroupDelegationUnavailable { reason }.to_string();
373            return Ok(refused_before_any_step(
374                &plan,
375                ctx,
376                plan_node.as_deref(),
377                started,
378                error,
379            ));
380        }
381    };
382    let workdir_guard =
383        crate::resources::workdir_guard_max_bytes(&ctx.capability_policy).map(|max_bytes| {
384            crate::resources::WorkdirGuard::spawn(
385                &ctx.accessible_workdir,
386                ctx.runtime_dir.temp.as_ref(),
387                ctx.runtime_dir.home.as_ref(),
388                ctx.runtime_dir.records.as_ref(),
389                max_bytes,
390            )
391        });
392
393    // Host-probed kernel enforcement tier + resolved network allowlist for this plan's shell
394    // steps — resolved once, up front, same cadence as `runtime::launch_session`. The `/tmp`
395    // mapping is decided from the same reading, for the tier this enforcement applies.
396    let probe = sandbox::HostProbe::probe();
397    let mount_namespace_usable = probe.mount_namespace_usable();
398    // The runtime directory's placement caps the class and never the tier, and only the tier is
399    // installed here, so `false` stands in for a placement the plan context does not carry.
400    let decision = crate::containment::ContainmentDecision::decide(
401        probe.tier(),
402        ctx.capability_policy.containment_floor,
403        ctx.capability_policy.workdir_exec_allowed,
404        false,
405    );
406    let shell_enforcement =
407        match sandbox::ShellEnforcement::resolve(&ctx.capability_policy, &decision, probe) {
408            Ok(enforcement) => {
409                let temp_mapping = crate::temp_dir::temp_mapping(
410                    enforcement.tier,
411                    mount_namespace_usable,
412                    ctx.accessible_workdir.path(),
413                );
414                enforcement
415                    .with_host_bounding(cgroup_scope, workdir_guard)
416                    .with_runtime_dir(ctx.runtime_dir.clone(), None)
417                    .with_temp_mapping(temp_mapping)
418            }
419            Err(error) => {
420                let error =
421                    format!("failed to resolve shell subprocess sandbox enforcement: {error}");
422                return Ok(refused_before_any_step(
423                    &plan,
424                    ctx,
425                    plan_node.as_deref(),
426                    started,
427                    error,
428                ));
429            }
430        };
431    let owns_netns = session_netns.is_none();
432    let shell_enforcement = match session_netns {
433        Some(session_netns) => shell_enforcement.with_session_netns(session_netns),
434        None => shell_enforcement,
435    };
436    // Held to the end of this function, so every return from here on ends what the plan started.
437    let _plan_end = PlanEnd {
438        enforcement: &shell_enforcement,
439        owns_netns,
440    };
441
442    let mut results = HashMap::<String, StepResult>::new();
443    let mut result_order = Vec::<String>::new();
444    let mut failed_step = None;
445    let mut canceled = false;
446
447    loop {
448        if canceled {
449            // Nothing is dispatched once the task is cancelled, and nothing is left unsettled:
450            // every step without a result is recorded as never having run.
451            for step in &plan.steps {
452                if results.contains_key(&step.id) {
453                    continue;
454                }
455                trace_undispatched(
456                    ctx,
457                    plan_node.as_deref(),
458                    &plan.id,
459                    step,
460                    "skipped",
461                    Some(CANCELED_BEFORE_STEP_ERROR.to_string()),
462                );
463                insert_result(
464                    &mut results,
465                    &mut result_order,
466                    StepResult {
467                        step_id: step.id.clone(),
468                        status: StepStatus::Skipped,
469                        output: None,
470                        error: Some(CANCELED_BEFORE_STEP_ERROR.to_string()),
471                    },
472                );
473            }
474            break;
475        }
476        if results.len() == plan.steps.len() || failed_step.is_some() {
477            break;
478        }
479        if task_canceled(ctx) {
480            canceled = true;
481            continue;
482        }
483
484        let mut ready = Vec::new();
485        let mut progressed = false;
486
487        for step in &plan.steps {
488            if results.contains_key(&step.id) {
489                continue;
490            }
491            if !step.depends_on.iter().all(|dep| results.contains_key(dep)) {
492                continue;
493            }
494
495            match condition::evaluate(step.condition.as_deref(), &results) {
496                Ok(true) => ready.push(step.clone()),
497                Ok(false) => {
498                    // Never dispatched, so it writes no `plan_step_start` and no attempt: a step
499                    // that did not run must not read like one that did.
500                    trace_undispatched(ctx, plan_node.as_deref(), &plan.id, step, "skipped", None);
501                    insert_result(
502                        &mut results,
503                        &mut result_order,
504                        StepResult {
505                            step_id: step.id.clone(),
506                            status: StepStatus::Skipped,
507                            output: None,
508                            error: None,
509                        },
510                    );
511                    progressed = true;
512                }
513                Err(error) => {
514                    trace_undispatched(
515                        ctx,
516                        plan_node.as_deref(),
517                        &plan.id,
518                        step,
519                        "failed",
520                        Some(error.clone()),
521                    );
522                    insert_result(
523                        &mut results,
524                        &mut result_order,
525                        StepResult {
526                            step_id: step.id.clone(),
527                            status: StepStatus::Failed,
528                            output: None,
529                            error: Some(error),
530                        },
531                    );
532                    failed_step = Some(step.id.clone());
533                    progressed = true;
534                    break;
535                }
536            }
537        }
538
539        if failed_step.is_some() {
540            break;
541        }
542
543        if ready.is_empty() {
544            if progressed {
545                continue;
546            }
547            const BLOCKED: &str = "plan is blocked by a dependency cycle or missing dependency";
548            let blocked_step = plan
549                .steps
550                .iter()
551                .find(|step| !results.contains_key(&step.id));
552            let blocked = blocked_step
553                .map(|step| step.id.clone())
554                .unwrap_or_else(|| "plan".to_string());
555            if let Some(step) = blocked_step {
556                trace_undispatched(
557                    ctx,
558                    plan_node.as_deref(),
559                    &plan.id,
560                    step,
561                    "failed",
562                    Some(BLOCKED.to_string()),
563                );
564            }
565            insert_result(
566                &mut results,
567                &mut result_order,
568                StepResult {
569                    step_id: blocked.clone(),
570                    status: StepStatus::Failed,
571                    output: None,
572                    error: Some(BLOCKED.to_string()),
573                },
574            );
575            failed_step = Some(blocked);
576            break;
577        }
578
579        for step in &ready {
580            trace_step_start(ctx, plan_node.as_deref(), &plan.id, step);
581        }
582
583        let mut completed = Vec::new();
584        thread::scope(|scope| {
585            let mut handles = Vec::new();
586            for step in ready {
587                let snapshot = results.clone();
588                let shell_enforcement = &shell_enforcement;
589                handles.push(scope.spawn(move || {
590                    execute_step_with_retries(&step, ctx, &snapshot, shell_enforcement)
591                }));
592            }
593            for handle in handles {
594                completed.push(handle.join().unwrap_or_else(|_| {
595                    StepOutcome::undispatched(failed("unknown", "step dispatch thread panicked"))
596                }));
597            }
598        });
599        // A wave that ends with its task cancelled settles each step as it returned. No
600        // `on_error` policy applies: the run stops whatever the steps say, and a policy that
601        // turned a failure into `fail` would name a step as the reason it stopped.
602        canceled = task_canceled(ctx);
603
604        for outcome in completed {
605            let Some(step) = plan
606                .steps
607                .iter()
608                .find(|step| step.id == outcome.result.step_id)
609            else {
610                // A dispatch thread that panicked names no step this plan declared.
611                trace_step(
612                    ctx,
613                    plan_node.as_deref(),
614                    PlanStepRecord {
615                        plan_id: plan.id.clone(),
616                        step_id: outcome.result.step_id.clone(),
617                        kind: "unknown",
618                        status: outcome.result.status.as_str(),
619                        attempts: outcome.attempts,
620                        duration_ms: outcome.duration_ms,
621                        error: outcome.result.error.clone(),
622                        input: None,
623                        state_effect: None,
624                        resource_id: None,
625                    },
626                );
627                insert_result(&mut results, &mut result_order, outcome.result);
628                if !canceled {
629                    failed_step = Some("unknown".to_string());
630                    break;
631                }
632                continue;
633            };
634
635            let kind = step_kind_name(step);
636            let StepOutcome {
637                result,
638                attempts,
639                duration_ms,
640                input,
641                state_effect,
642                resource_id,
643            } = outcome;
644
645            // The `on_error` policy is applied here, before the step is recorded either way, so
646            // the trace and the returned report can never disagree about what a step settled as.
647            let (settled, stops) = if result.status == StepStatus::Failed && !canceled {
648                match step.on_error.as_str() {
649                    "fail" => (result, true),
650                    "skip" => (
651                        StepResult {
652                            step_id: result.step_id,
653                            status: StepStatus::Skipped,
654                            output: None,
655                            error: result.error,
656                        },
657                        false,
658                    ),
659                    "continue" => (result, false),
660                    policy => (
661                        StepResult {
662                            error: Some(format!("invalid on_error policy '{policy}'")),
663                            ..result
664                        },
665                        true,
666                    ),
667                }
668            } else {
669                (result, false)
670            };
671
672            trace_step(
673                ctx,
674                plan_node.as_deref(),
675                PlanStepRecord {
676                    plan_id: plan.id.clone(),
677                    step_id: settled.step_id.clone(),
678                    kind,
679                    status: settled.status.as_str(),
680                    attempts,
681                    duration_ms,
682                    error: settled.error.clone(),
683                    input,
684                    state_effect,
685                    resource_id,
686                },
687            );
688
689            let id = settled.step_id.clone();
690            insert_result(&mut results, &mut result_order, settled);
691            if stops {
692                failed_step = Some(id);
693                break;
694            }
695        }
696    }
697
698    let ordered_results = result_order
699        .into_iter()
700        .filter_map(|id| results.remove(&id))
701        .collect::<Vec<_>>();
702
703    if canceled {
704        failed_step = None;
705    }
706    trace_plan_end(
707        ctx,
708        plan_node.as_deref(),
709        PlanEndRecord {
710            plan_id: plan.id.clone(),
711            outcome: if canceled {
712                "canceled"
713            } else if failed_step.is_none() {
714                "completed"
715            } else {
716                "failed"
717            },
718            failed_step: failed_step.clone(),
719            steps_total: plan.steps.len(),
720            steps_succeeded: count_status(&ordered_results, StepStatus::Success),
721            steps_failed: count_status(&ordered_results, StepStatus::Failed),
722            steps_skipped: count_status(&ordered_results, StepStatus::Skipped),
723            duration_ms: elapsed_ms(started),
724            reason: canceled.then(|| PLAN_CANCELED_REASON.to_string()),
725        },
726    );
727
728    Ok(ExecutionReport {
729        results: ordered_results,
730        completed: !canceled && failed_step.is_none(),
731        failed_step,
732        canceled,
733    })
734}
735
736/// Ends what a plan's shell steps started, on whichever path the plan returns by.
737///
738/// Kills the plan's own cgroup scope, so nothing a step backgrounded outlives the plan. Releases
739/// the network namespace only when the plan created it: a session's namespace belongs to the
740/// session, whose own commands may still be using it.
741struct PlanEnd<'a> {
742    enforcement: &'a sandbox::ShellEnforcement,
743    owns_netns: bool,
744}
745
746impl Drop for PlanEnd<'_> {
747    fn drop(&mut self) {
748        self.enforcement.kill_session_processes();
749        if self.owns_netns {
750            self.enforcement.session_netns.end();
751        }
752    }
753}
754
755// ── Trace emission ───────────────────────────────────────────────────────────
756
757/// The name a step's trace lines carry for its kind. `"unknown"` only for a step declaring
758/// none or several of `tool`/`shell`/`capsule`, which `validate_plan` refuses — it can reach a
759/// line only through `plan_start`, which is written before validation.
760fn step_kind_name(step: &StepDef) -> &'static str {
761    infer_kind(step).map(StepKind::as_str).unwrap_or("unknown")
762}
763
764/// The DAG as authored, for `plan_start`.
765fn plan_shape(plan: &PlanFile) -> Vec<PlanStepShape> {
766    plan.steps
767        .iter()
768        .map(|step| PlanStepShape {
769            step_id: step.id.clone(),
770            kind: step_kind_name(step),
771            depends_on: step.depends_on.clone(),
772            has_condition: step.condition.is_some(),
773        })
774        .collect()
775}
776
777fn trace_step_start(
778    ctx: &SchedulerContext<'_>,
779    plan_node: Option<&str>,
780    plan_id: &str,
781    step: &StepDef,
782) {
783    if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
784        appender.write_plan_step_start(
785            node,
786            plan_id,
787            &step.id,
788            step_kind_name(step),
789            step.depends_on.clone(),
790        );
791    }
792}
793
794fn trace_step(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanStepRecord) {
795    if let (Some(appender), Some(node)) = (ctx.trace, plan_node) {
796        appender.write_plan_step(node, record);
797    }
798}
799
800/// The terminal line for a step that settled without ever being dispatched: a false `if`, a
801/// condition that would not evaluate, a step the DAG left unreachable. No attempt was made, so
802/// none is claimed.
803fn trace_undispatched(
804    ctx: &SchedulerContext<'_>,
805    plan_node: Option<&str>,
806    plan_id: &str,
807    step: &StepDef,
808    status: &'static str,
809    error: Option<String>,
810) {
811    trace_step(
812        ctx,
813        plan_node,
814        PlanStepRecord {
815            plan_id: plan_id.to_string(),
816            step_id: step.id.clone(),
817            kind: step_kind_name(step),
818            status,
819            attempts: 0,
820            duration_ms: 0,
821            error,
822            input: None,
823            state_effect: None,
824            resource_id: None,
825        },
826    );
827}
828
829fn trace_plan_end(ctx: &SchedulerContext<'_>, plan_node: Option<&str>, record: PlanEndRecord) {
830    if let Some(appender) = ctx.trace {
831        appender.write_plan_end(plan_node, record);
832    }
833}
834
835/// The report and the trace for a plan the host refused before any step ran: a cgroup scope it
836/// could not delegate, a shell sandbox it could not resolve. The DAG is already described, so
837/// the summary hangs off the plan node and names the plan — only the reason is new.
838fn refused_before_any_step(
839    plan: &PlanFile,
840    ctx: &SchedulerContext<'_>,
841    plan_node: Option<&str>,
842    started: Instant,
843    error: String,
844) -> ExecutionReport {
845    trace_plan_end(
846        ctx,
847        plan_node,
848        PlanEndRecord {
849            plan_id: plan.id.clone(),
850            outcome: "failed",
851            failed_step: Some("plan".to_string()),
852            steps_total: plan.steps.len(),
853            steps_succeeded: 0,
854            steps_failed: 0,
855            steps_skipped: 0,
856            duration_ms: elapsed_ms(started),
857            reason: Some(error.clone()),
858        },
859    );
860    ExecutionReport {
861        results: vec![StepResult {
862            step_id: "plan".to_string(),
863            status: StepStatus::Failed,
864            output: None,
865            error: Some(error),
866        }],
867        completed: false,
868        failed_step: Some("plan".to_string()),
869        canceled: false,
870    }
871}
872
873/// Whether the task this run is for has been cancelled. `false` for a run with no task.
874fn task_canceled(ctx: &SchedulerContext<'_>) -> bool {
875    ctx.task.is_some_and(PlanTask::is_canceled)
876}
877
878fn count_status(results: &[StepResult], status: StepStatus) -> usize {
879    results
880        .iter()
881        .filter(|result| result.status == status)
882        .count()
883}
884
885fn elapsed_ms(started: Instant) -> u64 {
886    started.elapsed().as_millis() as u64
887}
888
889fn validate_plan(plan: &PlanFile, ctx: &SchedulerContext<'_>) -> Result<(), (String, String)> {
890    let mut ids = HashSet::new();
891    for step in &plan.steps {
892        if !ids.insert(step.id.clone()) {
893            return Err((step.id.clone(), format!("duplicate step id '{}'", step.id)));
894        }
895
896        match infer_kind(step) {
897            Ok(StepKind::Tool) => {
898                let tool = step.tool.as_ref().unwrap();
899                // Refused here rather than bounded by a depth counter: a plan that could submit a
900                // plan would nest one scheduler inside another's worker thread, and the outer
901                // report would carry the inner one as a step's output text.
902                if tool == crate::runtime::SUBMIT_PLAN_TOOL {
903                    return Err((
904                        step.id.clone(),
905                        format!(
906                            "a plan step cannot submit another plan: '{tool}' is not callable \
907                             from inside a plan"
908                        ),
909                    ));
910                }
911                // The agent's own switch, ending its task without an answer, and pulling or
912                // removing an artifact are decisions it makes in a turn, which a plan step is
913                // not: the step would change the model, end the task, or change the session's
914                // tools under the turn that submitted the plan.
915                if tool == crate::runtime::SWITCH_DRIVER_TOOL
916                    || tool == crate::runtime::END_WITHOUT_ANSWER_TOOL
917                    || tool == crate::runtime::PULL_ARTIFACT_TOOL
918                    || tool == crate::runtime::REMOVE_ARTIFACT_TOOL
919                {
920                    return Err((
921                        step.id.clone(),
922                        format!("'{tool}' is not callable from inside a plan"),
923                    ));
924                }
925                if !ctx.installed_tools.contains(tool) {
926                    return Err((step.id.clone(), format!("tool '{tool}' is not installed")));
927                }
928            }
929            Ok(StepKind::Shell) => {
930                let Some(binary) = shell_binary(step.shell.as_deref().unwrap_or_default()) else {
931                    return Err((step.id.clone(), "shell command is empty".to_string()));
932                };
933                if !ctx.capability_policy.shell_allow.contains(&binary) {
934                    return Err((
935                        step.id.clone(),
936                        format!("binary '{binary}' is not in capabilities.shell.allow"),
937                    ));
938                }
939            }
940            Ok(StepKind::Spawn) => {
941                // spawn_allow is enforced by mur-roost from the parent's CapabilityPolicy.
942                // No local check here — the capsule cannot self-authorize its own spawn rights.
943
944                // The input shape is settled before any step runs, so a plan that could only
945                // hand its child an envelope is refused without taking a cgroup scope, without
946                // spawning anything, and without the earlier steps having already had effects.
947                if let Err(error) = capsule_task_text(&step.input) {
948                    return Err((step.id.clone(), error));
949                }
950            }
951            Err(error) => return Err((step.id.clone(), error)),
952        }
953
954        if !matches!(step.on_error.as_str(), "fail" | "skip" | "continue") {
955            return Err((
956                step.id.clone(),
957                format!("invalid on_error policy '{}'", step.on_error),
958            ));
959        }
960    }
961
962    for step in &plan.steps {
963        for dep in &step.depends_on {
964            if !ids.contains(dep) {
965                return Err((step.id.clone(), format!("unknown dependency '{dep}'")));
966            }
967        }
968        validate_references(step, &ids)?;
969    }
970
971    Ok(())
972}
973
974fn validate_references(step: &StepDef, ids: &HashSet<String>) -> Result<(), (String, String)> {
975    let mut references = Vec::new();
976    collect_references(&step.input, &mut references);
977    if let Some(condition) = &step.condition {
978        for token in condition.split_whitespace() {
979            if token.starts_with('$') {
980                references.push(token.trim_matches(|c| c == '(' || c == ')').to_string());
981            }
982        }
983    }
984
985    for reference in references {
986        if let Some((step_id, field)) = parse_reference(&reference) {
987            if !ids.contains(step_id) {
988                return Err((step.id.clone(), format!("unknown reference '{reference}'")));
989            }
990            if field != "output" && field != "status" {
991                return Err((
992                    step.id.clone(),
993                    format!("unknown reference field '{field}'"),
994                ));
995            }
996        }
997    }
998
999    Ok(())
1000}
1001
1002fn collect_references(value: &Value, references: &mut Vec<String>) {
1003    match value {
1004        Value::String(value) if value.starts_with('$') => references.push(value.clone()),
1005        Value::Array(values) => values
1006            .iter()
1007            .for_each(|value| collect_references(value, references)),
1008        Value::Object(map) => map
1009            .values()
1010            .for_each(|value| collect_references(value, references)),
1011        _ => {}
1012    }
1013}
1014
1015/// What a worker thread reports back to the scheduler loop about the step it ran: the verdict,
1016/// plus the facts only the worker observed and the terminal `plan_step` line needs.
1017///
1018/// [`execute_step_once`] returns one attempt's own `attempts: 1` and duration;
1019/// [`execute_step_with_retries`] overwrites both with the totals for the step.
1020struct StepOutcome {
1021    result: StepResult,
1022    attempts: u32,
1023    duration_ms: u64,
1024    /// The interpolated input the attempt dispatched. `Some` for a tool step; `None` for every
1025    /// other kind, and for a failure that never reached a dispatch.
1026    input: Option<Value>,
1027    /// What the tool declared about this call in `tool-result.metadata`. Both `None` for every
1028    /// kind but a tool step, and for a tool that declared nothing.
1029    state_effect: Option<String>,
1030    resource_id: Option<String>,
1031}
1032
1033impl StepOutcome {
1034    /// One attempt of a step that declares nothing about itself: every kind but a tool step, and
1035    /// every failure that never reached a dispatch.
1036    fn plain(result: StepResult, started: Instant) -> Self {
1037        Self {
1038            result,
1039            attempts: 1,
1040            duration_ms: elapsed_ms(started),
1041            input: None,
1042            state_effect: None,
1043            resource_id: None,
1044        }
1045    }
1046
1047    /// A verdict reached without a dispatch, and so without an attempt to count or time.
1048    fn undispatched(result: StepResult) -> Self {
1049        Self {
1050            result,
1051            attempts: 0,
1052            duration_ms: 0,
1053            input: None,
1054            state_effect: None,
1055            resource_id: None,
1056        }
1057    }
1058}
1059
1060fn execute_step_with_retries(
1061    step: &StepDef,
1062    ctx: &SchedulerContext<'_>,
1063    results: &HashMap<String, StepResult>,
1064    enforcement: &sandbox::ShellEnforcement,
1065) -> StepOutcome {
1066    let started = Instant::now();
1067    let attempts = step.retries.saturating_add(1);
1068    let mut last = None;
1069    for attempt in 1..=attempts {
1070        let mut outcome = execute_step_once(step, ctx, results, enforcement);
1071        outcome.attempts = attempt;
1072        outcome.duration_ms = elapsed_ms(started);
1073        // A cancelled task's step is not tried again; the attempt that saw the cancel stands.
1074        if outcome.result.status != StepStatus::Failed || task_canceled(ctx) {
1075            return outcome;
1076        }
1077        last = Some(outcome);
1078    }
1079
1080    last.unwrap_or_else(|| StepOutcome::undispatched(failed(&step.id, "step was not attempted")))
1081}
1082
1083fn execute_step_once(
1084    step: &StepDef,
1085    ctx: &SchedulerContext<'_>,
1086    results: &HashMap<String, StepResult>,
1087    enforcement: &sandbox::ShellEnforcement,
1088) -> StepOutcome {
1089    let started = Instant::now();
1090    let mut input = step.input.clone();
1091    if let Err(error) = interpolate_value(&mut input, results) {
1092        return StepOutcome::plain(failed(&step.id, error), started);
1093    }
1094    let input_json = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
1095
1096    match infer_kind(step) {
1097        Ok(StepKind::Tool) => {
1098            let (result, state_effect, resource_id) = dispatch_tool_step(step, ctx, input_json);
1099            StepOutcome {
1100                result,
1101                attempts: 1,
1102                duration_ms: elapsed_ms(started),
1103                // A step declaring no `input` interpolates to JSON null, which would write
1104                // `"input": null` rather than omitting the key. Absent means absent.
1105                input: (!input.is_null()).then_some(input),
1106                state_effect,
1107                resource_id,
1108            }
1109        }
1110        Ok(StepKind::Shell) => {
1111            StepOutcome::plain(dispatch_shell_step(step, ctx, enforcement), started)
1112        }
1113        Ok(StepKind::Spawn) => StepOutcome::plain(dispatch_capsule_step(step, ctx, input), started),
1114        Err(error) => StepOutcome::plain(failed(&step.id, error), started),
1115    }
1116}
1117
1118/// Dispatches one tool step, and returns the verdict beside the `state_effect` and `resource_id`
1119/// the tool declared about the call. Both are read through the agent loop's own extractors, so
1120/// a plan step and an agent turn read one tool's self-description the same way.
1121fn dispatch_tool_step(
1122    step: &StepDef,
1123    ctx: &SchedulerContext<'_>,
1124    input_json: String,
1125) -> (StepResult, Option<String>, Option<String>) {
1126    let name = step.tool.as_deref().unwrap_or_default();
1127    if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Tool {
1128        name,
1129        input_json: &input_json,
1130    }) {
1131        return (failed(&step.id, refusal), None, None);
1132    }
1133    match (ctx.invoke_tool)(
1134        name,
1135        ToolInput {
1136            data: Some(input_json),
1137            log_path: None,
1138        },
1139    ) {
1140        Ok(result) => {
1141            let state_effect = crate::agent::extract_state_effect(&result.metadata);
1142            let resource_id = crate::agent::extract_resource_id(&result.metadata);
1143            let verdict = if matches!(result.status, ToolStatus::Passed) {
1144                match tool_step_output(
1145                    name,
1146                    ctx.accessible_workdir.path(),
1147                    result.data,
1148                    result.data_path.as_deref(),
1149                ) {
1150                    Ok(output) => StepResult {
1151                        step_id: step.id.clone(),
1152                        status: StepStatus::Success,
1153                        output,
1154                        error: None,
1155                    },
1156                    Err(error) => failed(&step.id, error),
1157                }
1158            } else {
1159                failed(
1160                    &step.id,
1161                    result
1162                        .summary
1163                        .or(result.data)
1164                        .unwrap_or_else(|| "tool step failed".to_string()),
1165                )
1166            };
1167            (verdict, state_effect, resource_id)
1168        }
1169        Err(error) => (failed(&step.id, error), None, None),
1170    }
1171}
1172
1173fn dispatch_shell_step(
1174    step: &StepDef,
1175    ctx: &SchedulerContext<'_>,
1176    enforcement: &sandbox::ShellEnforcement,
1177) -> StepResult {
1178    let command = step.shell.as_deref().unwrap_or_default();
1179    let Some((binary, args)) = parse_shell_command(command) else {
1180        return failed(&step.id, "shell command is empty");
1181    };
1182    if let Some(refusal) = (ctx.gate_step)(&PlannedCall::Shell {
1183        binary: &binary,
1184        command,
1185        argv: &args,
1186    }) {
1187        return failed(&step.id, refusal);
1188    }
1189    let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
1190
1191    match execute_shell(
1192        &binary,
1193        &arg_refs,
1194        &[],
1195        &ctx.accessible_workdir,
1196        &ctx.capability_policy,
1197        enforcement,
1198    ) {
1199        Ok(result) if result.exit_code == 0 => StepResult {
1200            step_id: step.id.clone(),
1201            status: StepStatus::Success,
1202            output: Some(result.stdout),
1203            error: None,
1204        },
1205        // A step killed for exceeding a host resource limit fails with the limit named, not with
1206        // whatever the dying process managed to write to stderr — for `SIGXCPU`/`SIGXFSZ` and the
1207        // cgroup kills that is usually nothing at all, which would otherwise report an empty
1208        // error for a step that was very deliberately terminated.
1209        Ok(result) => match result.resource_limit_hit {
1210            Some(limit) => failed(
1211                &step.id,
1212                crate::errors::RuntimeError::ShellResourceLimitExceeded {
1213                    binary: result.binary,
1214                    limit,
1215                    detail: if result.stderr.trim().is_empty() {
1216                        format!("exit code {}", result.exit_code)
1217                    } else {
1218                        result.stderr
1219                    },
1220                }
1221                .to_string(),
1222            ),
1223            // A command that fails without writing to stderr is the common case, not the odd
1224            // one — `test`, `grep -q` and `diff` all report only through their exit status. The
1225            // code is the whole of what failed, so the step carries it rather than an empty
1226            // error the model has to guess at.
1227            None if result.stderr.trim().is_empty() => {
1228                failed(&step.id, format!("exit code {}", result.exit_code))
1229            }
1230            None => failed(&step.id, result.stderr),
1231        },
1232        // Including a sealed composed-root failure, which fails the step carrying its full
1233        // named text. Unlike the agent turn loop (which owns a session and ends it — see
1234        // `agent::run_agent_loop`), this scheduler owns nothing above the step it is running,
1235        // so naming the cause in the step's error is the whole of what it can do.
1236        Err(error) => failed(&step.id, error.to_string()),
1237    }
1238}
1239
1240/// The session's `capabilities.spawn.workdir_snapshot`, copying its work folder and never its
1241/// runtime or records directory: what a `capsule` step's plane starts a listed child with, exactly
1242/// as the session's own `delegate-task` plane does.
1243fn snapshot_grant(
1244    ctx: &SchedulerContext<'_>,
1245    records_root: &WorkdirRoot,
1246) -> crate::delegation_plane::WorkdirSnapshotGrant {
1247    crate::delegation_plane::WorkdirSnapshotGrant::new(
1248        ctx.capability_policy.spawn_workdir_snapshot.clone(),
1249        ctx.accessible_workdir.clone(),
1250        &[&ctx.runtime_dir.runtime, records_root],
1251    )
1252}
1253
1254fn dispatch_capsule_step(step: &StepDef, ctx: &SchedulerContext<'_>, input: Value) -> StepResult {
1255    let named = step.capsule.as_deref().unwrap_or_default();
1256    // A step names a capsule by the bare part, as `delegate-task` does; mur-roost is asked about
1257    // the full `capabilities.spawn.allow` entry it stands for.
1258    let full = crate::tool_names::spawn_target(&ctx.capability_policy.spawn_allow, named);
1259    let capsule = full.as_str();
1260    // `validate_plan` already accepted this step's un-interpolated input, and interpolation
1261    // replaces a string with a string, so the refusal here is unreachable through `execute`.
1262    let task = match capsule_task_text(&input) {
1263        Ok(task) => task,
1264        Err(error) => return failed(&step.id, error),
1265    };
1266    let roost_url = match std::env::var("MURMUR_ROOST_URL") {
1267        Ok(value) if !value.trim().is_empty() => value,
1268        _ => {
1269            return failed(
1270                &step.id,
1271                "MURMUR_ROOST_URL is not set; capsule steps require mur-roost",
1272            )
1273        }
1274    };
1275    let Some(credential) = ctx.spawn_credential.as_ref() else {
1276        return failed(
1277            &step.id,
1278            "this session holds no spawn credential; a capsule step requires one, and mur-roost \
1279             mints it only for a session whose manifest declares capabilities.spawn.allow",
1280        );
1281    };
1282
1283    // Everything a delegation is — the referee's approval, the child's process, the task delivery
1284    // and the bounded wait — belongs to `DelegationPlane`, and a capsule step is input and output
1285    // marshalling around it. The agent-facing `delegate-task` tool calls the same code, so there
1286    // is one delegation mechanism in this crate rather than two that drift.
1287    // A plan step reads no manifest of its own here, so it delegates under the default bound
1288    // rather than under a declared one.
1289    let Some(records_root) = ctx.runtime_dir.records.clone() else {
1290        return failed(
1291            &step.id,
1292            "this plan runs outside a session's records directory, where a capsule step's child \
1293             work folder is created",
1294        );
1295    };
1296    let snapshot_grant = snapshot_grant(ctx, &records_root);
1297    let plane = DelegationPlane::new(
1298        roost_url,
1299        credential.clone(),
1300        records_root,
1301        ctx.current_session_id.clone().unwrap_or_default(),
1302        crate::delegation_plane::DELEGATION_RESULT_TIMEOUT,
1303        std::sync::Arc::clone(&ctx.registry),
1304        ctx.capability_policy.env_allow.clone(),
1305        ctx.formation_id.clone(),
1306    )
1307    .bounded_by(ctx.deadline)
1308    .snapshotting_workdir(snapshot_grant);
1309    // The launch notice is written where it arrives, on this thread, while the child is still
1310    // holding its task: `delegate` blocks until the child answers, so a notice parked for later
1311    // would only reach disk once there was already a terminal line to write beside it. The task
1312    // accounts for the child in the same callback, before the child is handed its task, so a
1313    // cancel from then on finds it in the task's set.
1314    let (trace, plan_task) = (ctx.trace, ctx.task);
1315    let launched = (trace.is_some() || plan_task.is_some()).then(|| {
1316        std::sync::Arc::new(move |notice: DelegationLaunch| {
1317            if let Some(trace) = trace {
1318                trace.write_delegation_start(
1319                    &notice.delegation_id,
1320                    &notice.capsule,
1321                    &notice.version,
1322                    &notice.child_session_id,
1323                    &notice.child_workdir,
1324                    &notice.child_records_dir,
1325                    notice.workdir_snapshot.as_ref(),
1326                );
1327            }
1328            if let Some(plan_task) = plan_task {
1329                plan_task.delegation_started(&notice);
1330            }
1331        }) as std::sync::Arc<dyn Fn(_) + Send + Sync>
1332    });
1333    let stop = plan_task.map(|plan_task| {
1334        std::sync::Arc::new(move || plan_task.is_canceled())
1335            as std::sync::Arc<dyn Fn() -> bool + Send + Sync>
1336    });
1337    let started = Instant::now();
1338    let result = plane.delegate(
1339        &DelegationRequest {
1340            capsule: capsule.to_string(),
1341            // A plan names a capsule; the version comes from the context the plan was validated
1342            // against, and a context that names none means `0.1.0`.
1343            version: ctx
1344                .capsule_versions
1345                .get(named)
1346                .cloned()
1347                .unwrap_or_else(|| "0.1.0".to_string()),
1348            task,
1349        },
1350        &DelegationOrigin {
1351            context_id: ctx.current_context_id.clone().unwrap_or_default(),
1352            launched,
1353            stop,
1354        },
1355        &ctx.runtime_dir.runtime,
1356    );
1357
1358    // The child is ended and its row is the cancelled task's: the task writes the terminal
1359    // `delegation` line after its `task_canceled`, so the step writes none.
1360    if result.status == DelegationStatus::Canceled {
1361        return failed(&step.id, CANCELED_STEP_ERROR);
1362    }
1363    // Whoever takes the row out of the task's set writes the terminal line. A step with no task,
1364    // or a delegation that named no child, has no row to take.
1365    let closes_row = match plan_task {
1366        Some(plan_task) if !result.delegation_id.is_empty() => {
1367            plan_task.delegation_settled(&result.delegation_id)
1368        }
1369        _ => true,
1370    };
1371    if let Some(trace) = ctx.trace.filter(|_| closes_row) {
1372        trace.write_delegation(
1373            &result.capsule,
1374            &result.version,
1375            // Empty rather than absent is how a result names no child; the record says `null`,
1376            // so nothing reading it can join against an id that was never minted.
1377            Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
1378            Some(result.session_id.clone()).filter(|id| !id.is_empty()),
1379            elapsed_ms(started),
1380            result.status.as_str(),
1381            match result.status {
1382                DelegationStatus::Completed => None,
1383                _ => Some(result.output.clone()),
1384            },
1385        );
1386    }
1387
1388    match result.status {
1389        DelegationStatus::Completed => StepResult {
1390            step_id: step.id.clone(),
1391            status: StepStatus::Success,
1392            output: Some(result.output),
1393            error: None,
1394        },
1395        _ => failed(&step.id, result.output),
1396    }
1397}
1398
1399/// The task text a capsule step hands to the child capsule, or the reason the step's `input`
1400/// cannot become task text.
1401///
1402/// Whatever this returns becomes the child's first user message verbatim: `DelegationPlane`
1403/// sends it as the sole text part of an A2A `SendMessage`, the receiving runtime writes that
1404/// text to `task.md`, and `agent::read_task` pushes it at the model. Nothing on the receiving
1405/// side parses a task envelope, so an envelope is not structure — it is prose that happens to be
1406/// JSON, and it spends the child's first turn on decoding. A capsule step's `input` is therefore
1407/// the task text and nothing else, in one of two shapes:
1408///
1409/// | `input` | task text |
1410/// | --- | --- |
1411/// | a JSON string | that string, verbatim |
1412/// | `{ "objective": "<text>" }`, and no other key | `<text>`, verbatim |
1413///
1414/// Every other shape is refused rather than serialized into the message. Interpolation replaces
1415/// a string with a string and so cannot change the shape, which is what lets `validate_plan`
1416/// refuse the whole plan before a cgroup scope is taken or a child is spawned.
1417fn capsule_task_text(input: &Value) -> Result<String, String> {
1418    const ACCEPTS: &str = "capsule step input must be a string or { \"objective\": \"<text>\" }";
1419    const NO_INPUT: &str = "capsule step has no input; its input is the task text the child \
1420                            receives, given as a string or as { \"objective\": \"<text>\" }";
1421
1422    match input {
1423        Value::String(text) => Ok(text.clone()),
1424        Value::Object(map) => {
1425            // serde_json's map is a BTreeMap or an IndexMap depending on the `preserve_order`
1426            // feature, so the listed keys are sorted here rather than taken in iteration order.
1427            let mut unknown = map
1428                .keys()
1429                .filter(|key| key.as_str() != "objective")
1430                .map(String::as_str)
1431                .collect::<Vec<_>>();
1432            unknown.sort_unstable();
1433            if !unknown.is_empty() {
1434                return Err(format!(
1435                    "capsule step input has keys the child cannot receive: {}; nothing on the \
1436                     receiving side parses a task envelope, so put the whole instruction in \
1437                     \"objective\"",
1438                    unknown.join(", ")
1439                ));
1440            }
1441
1442            match map.get("objective") {
1443                Some(Value::String(text)) => Ok(text.clone()),
1444                Some(_) => Err("capsule step input \"objective\" must be a string".to_string()),
1445                None => Err(ACCEPTS.to_string()),
1446            }
1447        }
1448        Value::Null => Err(NO_INPUT.to_string()),
1449        Value::Bool(_) => Err(format!("{ACCEPTS}, not a boolean")),
1450        Value::Number(_) => Err(format!("{ACCEPTS}, not a number")),
1451        Value::Array(_) => Err(format!("{ACCEPTS}, not an array")),
1452    }
1453}
1454
1455fn interpolate_value(
1456    value: &mut Value,
1457    results: &HashMap<String, StepResult>,
1458) -> Result<(), String> {
1459    match value {
1460        Value::String(text) if text.starts_with('$') => {
1461            *text = condition::resolve_reference(text, results)?;
1462        }
1463        Value::Array(values) => {
1464            for value in values {
1465                interpolate_value(value, results)?;
1466            }
1467        }
1468        Value::Object(map) => {
1469            for value in map.values_mut() {
1470                interpolate_value(value, results)?;
1471            }
1472        }
1473        _ => {}
1474    }
1475
1476    Ok(())
1477}
1478
1479fn infer_kind(step: &StepDef) -> Result<StepKind, String> {
1480    let count =
1481        step.tool.is_some() as u8 + step.shell.is_some() as u8 + step.capsule.is_some() as u8;
1482    match count {
1483        1 if step.tool.is_some() => Ok(StepKind::Tool),
1484        1 if step.shell.is_some() => Ok(StepKind::Shell),
1485        1 if step.capsule.is_some() => Ok(StepKind::Spawn),
1486        0 => Err("step must declare exactly one of tool, shell, or capsule".to_string()),
1487        _ => Err("step declares more than one of tool, shell, or capsule".to_string()),
1488    }
1489}
1490
1491fn default_on_error() -> String {
1492    "fail".to_string()
1493}
1494
1495fn failed(step_id: &str, error: impl Into<String>) -> StepResult {
1496    StepResult {
1497        step_id: step_id.to_string(),
1498        status: StepStatus::Failed,
1499        output: None,
1500        error: Some(error.into()),
1501    }
1502}
1503
1504fn insert_result(
1505    results: &mut HashMap<String, StepResult>,
1506    order: &mut Vec<String>,
1507    result: StepResult,
1508) {
1509    order.push(result.step_id.clone());
1510    results.insert(result.step_id.clone(), result);
1511}
1512
1513fn shell_binary(command: &str) -> Option<String> {
1514    parse_shell_command(command).map(|(binary, _)| binary)
1515}
1516
1517fn parse_shell_command(command: &str) -> Option<(String, Vec<String>)> {
1518    let parts = split_shell_words(command);
1519    let (binary, args) = parts.split_first()?;
1520    Some((binary.clone(), args.to_vec()))
1521}
1522
1523fn parse_reference(reference: &str) -> Option<(&str, &str)> {
1524    reference.strip_prefix('$')?.rsplit_once('.')
1525}
1526
1527/// The output of a tool step that passed: in-band `data` when the tool sent it, otherwise the
1528/// file the tool named in `data_path`.
1529///
1530/// `data_path` is the escape hatch for a result too large to carry in band — the system prompt
1531/// tells the model to read it when `truncated` is set — so a path the host cannot honour is
1532/// resolved against the guest's own boundary rather than dropped from the interface. The
1533/// resolution is [`resource_plane::read_file_beneath_root`] against `workdir`, the directory the
1534/// tool's `.` means, and a refusal fails the step: a step whose declared output could not be read
1535/// did not succeed.
1536///
1537/// The symlink policy is [`SymlinkPolicy::Refuse`] whatever containment class the session
1538/// achieved. `resource_plane::symlink_policy` follows symlinks under `sealed` and `advisory`
1539/// because a capsule reading its own export root has no outside to name; this read runs in the
1540/// host process, which sits outside every class's boundary with ambient filesystem authority, so
1541/// a symlink under the workdir pointing at `/etc/shadow` would be followed successfully at any
1542/// class.
1543fn tool_step_output(
1544    tool: &str,
1545    workdir: &Path,
1546    data: Option<String>,
1547    data_path: Option<&str>,
1548) -> Result<Option<String>, String> {
1549    if let Some(data) = data {
1550        return Ok(Some(data));
1551    }
1552    let Some(relpath) = data_path else {
1553        return Ok(None);
1554    };
1555    match resource_plane::read_file_beneath_root(
1556        workdir,
1557        relpath,
1558        DEFAULT_EXPORT_MAX_BYTES,
1559        SymlinkPolicy::Refuse,
1560    ) {
1561        Ok(response) => Ok(Some(String::from_utf8_lossy(&response.bytes).into_owned())),
1562        Err(error) => Err(RuntimeError::ToolDataPathRefused {
1563            tool: tool.to_string(),
1564            path: relpath.to_string(),
1565            code: error.code().to_string(),
1566            detail: error.message(),
1567        }
1568        .to_string()),
1569    }
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574    use std::{fs, path::PathBuf};
1575    use std::{
1576        sync::{
1577            atomic::{AtomicUsize, Ordering},
1578            Arc, Condvar, Mutex,
1579        },
1580        time::Duration,
1581    };
1582
1583    use serde_json::json;
1584    use tempfile::tempdir;
1585
1586    use super::*;
1587
1588    /// The `shell_allow` grant below is what makes every test built on this context need a
1589    /// delegated cgroup v2 scope: `execute` bounds the plan's whole subprocess tree before
1590    /// running a step, and fails closed when it cannot.
1591    fn test_ctx<'a>(
1592        workdir: PathBuf,
1593        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1594    ) -> SchedulerContext<'a> {
1595        test_ctx_gated(workdir, invoke_tool, &UNGATED)
1596    }
1597
1598    /// A decision point that refuses nothing, which is what a session with neither a policy hook
1599    /// nor a `capabilities.filesystem.read_only` declaration presents.
1600    const UNGATED: fn(&PlannedCall<'_>) -> Option<String> = |_| None;
1601
1602    fn test_ctx_gated<'a>(
1603        workdir: PathBuf,
1604        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1605        gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1606    ) -> SchedulerContext<'a> {
1607        SchedulerContext {
1608            accessible_workdir: WorkdirRoot::open(&workdir).unwrap(),
1609            runtime_dir: crate::runtime_dir::RuntimeDirRoots::new(
1610                WorkdirRoot::open(&workdir).unwrap(),
1611                WorkdirRoot::open(&workdir).unwrap(),
1612                WorkdirRoot::open(&workdir).unwrap(),
1613            )
1614            // Kept past the test: the context's clones may outlive it.
1615            .with_home(WorkdirRoot::open(&tempfile::tempdir().unwrap().keep()).unwrap()),
1616            capability_policy: CapabilityPolicy {
1617                shell_allow: vec!["bash".to_string()],
1618                spawn_allow: vec!["worker".to_string()],
1619                ..CapabilityPolicy::default()
1620            },
1621            installed_tools: HashSet::from([
1622                "ok".to_string(),
1623                "fail".to_string(),
1624                "slow".to_string(),
1625                "a".to_string(),
1626                "b".to_string(),
1627            ]),
1628            capsule_versions: HashMap::new(),
1629            current_session_id: None,
1630            current_context_id: None,
1631            // Nothing in these cases delegates, so no artifact is ever resolved through it.
1632            registry: std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
1633                tempdir().unwrap().keep(),
1634            )),
1635            spawn_credential: None,
1636            formation_id: None,
1637            deadline: None,
1638            trace: None,
1639            gate_step,
1640            invoke_tool,
1641            task: None,
1642        }
1643    }
1644
1645    fn tool_result(status: ToolStatus, data: Option<String>) -> ToolResult {
1646        ToolResult {
1647            status,
1648            summary: None,
1649            data,
1650            data_path: None,
1651            truncated: false,
1652            metadata: Vec::new(),
1653            attachments: Vec::new(),
1654        }
1655    }
1656
1657    /// A `capsule` step's plane copies what the session's own `delegate-task` plane copies: the
1658    /// session's `capabilities.spawn.workdir_snapshot`, its work folder, and never its runtime or
1659    /// records directory.
1660    #[test]
1661    fn plan_capsule_step_snapshot_grant_is_the_sessions() {
1662        let work = tempdir().unwrap();
1663        let records = tempdir().unwrap();
1664        let invoke = |_: &str, _: ToolInput| -> Result<ToolResult, String> { unreachable!() };
1665        let mut ctx = test_ctx(work.path().to_path_buf(), &invoke);
1666        ctx.capability_policy.spawn_workdir_snapshot = vec!["local/worker".to_string()];
1667        let records_root = WorkdirRoot::open(records.path()).unwrap();
1668
1669        let grant = snapshot_grant(&ctx, &records_root);
1670
1671        assert_eq!(grant.capsules, vec!["local/worker".to_string()]);
1672        assert_eq!(grant.source.path(), work.path());
1673        assert_eq!(
1674            grant.excluded,
1675            vec![
1676                ctx.runtime_dir.runtime.identity().unwrap(),
1677                records_root.identity().unwrap()
1678            ]
1679        );
1680    }
1681
1682    fn write_plan(workdir: &Path, plan: Value) -> PathBuf {
1683        let path = workdir.join("plan.json");
1684        fs::write(&path, serde_json::to_string(&plan).unwrap()).unwrap();
1685        path
1686    }
1687
1688    /// The tools that are a turn's own decisions — switching the model, ending the task without
1689    /// an answer, pulling or removing an artifact — are refused as plan steps, before any step
1690    /// runs.
1691    #[test]
1692    fn a_turn_s_own_decisions_are_not_callable_from_a_plan() {
1693        let dir = tempdir().unwrap();
1694        let invoke = |_: &str, _: ToolInput| -> Result<ToolResult, String> {
1695            panic!("a refused plan ran a step")
1696        };
1697        let ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1698        for tool in [
1699            crate::runtime::SWITCH_DRIVER_TOOL,
1700            crate::runtime::END_WITHOUT_ANSWER_TOOL,
1701            crate::runtime::PULL_ARTIFACT_TOOL,
1702            crate::runtime::REMOVE_ARTIFACT_TOOL,
1703        ] {
1704            let plan: PlanFile = serde_json::from_value(json!({
1705                "id": "p", "steps": [{"id": "s1", "tool": tool, "input": {"reason": "none"}}],
1706            }))
1707            .unwrap();
1708            assert_eq!(
1709                validate_plan(&plan, &ctx).unwrap_err(),
1710                (
1711                    "s1".to_string(),
1712                    format!("'{tool}' is not callable from inside a plan")
1713                )
1714            );
1715        }
1716    }
1717
1718    #[test]
1719    fn capsule_task_text_delivers_a_bare_string_verbatim() {
1720        assert_eq!(
1721            capsule_task_text(&json!("do the thing")).unwrap(),
1722            "do the thing"
1723        );
1724    }
1725
1726    #[test]
1727    fn capsule_task_text_delivers_a_lone_objective_verbatim() {
1728        assert_eq!(
1729            capsule_task_text(&json!({"objective": "do the thing"})).unwrap(),
1730            "do the thing"
1731        );
1732    }
1733
1734    /// An empty task is a well-shaped one. What a child does with nothing to do is the child's
1735    /// business, not this contract's.
1736    #[test]
1737    fn capsule_task_text_accepts_empty_task_text() {
1738        assert_eq!(capsule_task_text(&json!("")).unwrap(), "");
1739        assert_eq!(capsule_task_text(&json!({"objective": ""})).unwrap(), "");
1740    }
1741
1742    #[test]
1743    fn capsule_task_text_refuses_a_task_envelope() {
1744        let error = capsule_task_text(&json!({"task": "hello"})).unwrap_err();
1745        assert!(
1746            error.contains("keys the child cannot receive: task"),
1747            "{error}"
1748        );
1749        assert!(error.contains("\"objective\""), "{error}");
1750    }
1751
1752    #[test]
1753    fn capsule_task_text_lists_unaccepted_keys_in_sorted_order() {
1754        let error =
1755            capsule_task_text(&json!({"render_as": "json", "objective": "x", "instructions": "y"}))
1756                .unwrap_err();
1757        assert!(
1758            error.contains("keys the child cannot receive: instructions, render_as"),
1759            "{error}"
1760        );
1761    }
1762
1763    #[test]
1764    fn capsule_task_text_refuses_a_non_string_objective() {
1765        for input in [json!({"objective": 7}), json!({"objective": null})] {
1766            assert_eq!(
1767                capsule_task_text(&input).unwrap_err(),
1768                "capsule step input \"objective\" must be a string"
1769            );
1770        }
1771    }
1772
1773    #[test]
1774    fn capsule_task_text_refuses_an_object_without_an_objective() {
1775        assert_eq!(
1776            capsule_task_text(&json!({})).unwrap_err(),
1777            "capsule step input must be a string or { \"objective\": \"<text>\" }"
1778        );
1779    }
1780
1781    #[test]
1782    fn capsule_task_text_names_the_type_it_was_handed() {
1783        assert!(capsule_task_text(&json!(7))
1784            .unwrap_err()
1785            .ends_with("not a number"));
1786        assert!(capsule_task_text(&json!(true))
1787            .unwrap_err()
1788            .ends_with("not a boolean"));
1789        assert!(capsule_task_text(&json!(["a"]))
1790            .unwrap_err()
1791            .ends_with("not an array"));
1792    }
1793
1794    /// `StepDef::input` defaults to `Value::Null`, so an omitted `input` and an explicit null
1795    /// are the same value and get the same refusal.
1796    #[test]
1797    fn capsule_task_text_refuses_an_absent_input() {
1798        let error = capsule_task_text(&Value::Null).unwrap_err();
1799        assert_eq!(
1800            error,
1801            "capsule step has no input; its input is the task text the child receives, given as \
1802             a string or as { \"objective\": \"<text>\" }"
1803        );
1804    }
1805
1806    /// A `$reference` is a string, so both accepted shapes survive load-time validation and are
1807    /// resolved at dispatch.
1808    #[test]
1809    fn capsule_task_text_accepts_an_uninterpolated_reference() {
1810        assert_eq!(
1811            capsule_task_text(&json!("$analyse.output")).unwrap(),
1812            "$analyse.output"
1813        );
1814        assert_eq!(
1815            capsule_task_text(&json!({"objective": "$analyse.output"})).unwrap(),
1816            "$analyse.output"
1817        );
1818    }
1819
1820    /// [`test_ctx_gated`] with no `shell` or `spawn` grant, so the run needs no delegated cgroup
1821    /// scope and holds on any host.
1822    fn test_ctx_unbounded<'a>(
1823        workdir: PathBuf,
1824        invoke_tool: &'a (dyn Fn(&str, ToolInput) -> Result<ToolResult, String> + Sync),
1825        gate_step: &'a (dyn Fn(&PlannedCall<'_>) -> Option<String> + Sync),
1826    ) -> SchedulerContext<'a> {
1827        SchedulerContext {
1828            capability_policy: CapabilityPolicy::default(),
1829            ..test_ctx_gated(workdir, invoke_tool, gate_step)
1830        }
1831    }
1832
1833    /// The decision point refuses, and the step it refused never reaches the executor.
1834    ///
1835    /// The refusal has to arrive as the step's own error rather than as an empty success, because
1836    /// a plan whose steps quietly did nothing is indistinguishable from one that worked.
1837    #[test]
1838    fn a_refused_step_fails_with_the_refusal_and_is_never_dispatched() {
1839        let dir = tempdir().unwrap();
1840        let dispatched = Arc::new(AtomicUsize::new(0));
1841        let counted = Arc::clone(&dispatched);
1842        let invoke = move |_name: &str, _input: ToolInput| {
1843            counted.fetch_add(1, Ordering::SeqCst);
1844            Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
1845        };
1846        let gate = |call: &PlannedCall<'_>| match call {
1847            PlannedCall::Tool { name: "a", .. } => Some("refused by the manifest".to_string()),
1848            _ => None,
1849        };
1850        let plan = write_plan(
1851            dir.path(),
1852            json!({
1853                "id": "p",
1854                "steps": [
1855                    {"id": "refused", "tool": "a", "on_error": "continue"},
1856                    {"id": "allowed", "tool": "b"}
1857                ]
1858            }),
1859        );
1860
1861        let report = execute(
1862            &plan,
1863            &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &gate),
1864        );
1865        let refused = report
1866            .results
1867            .iter()
1868            .find(|result| result.step_id == "refused")
1869            .expect("the refused step settled");
1870        assert_eq!(refused.status, StepStatus::Failed);
1871        assert_eq!(refused.error.as_deref(), Some("refused by the manifest"));
1872        assert_eq!(refused.output, None);
1873        // The one dispatch is the step the gate let through.
1874        assert_eq!(dispatched.load(Ordering::SeqCst), 1);
1875    }
1876
1877    /// A command that fails silently still says why. The model is handed the step's `error` as
1878    /// the whole account of what went wrong, so an empty one reports a failure with no reason.
1879    #[test]
1880    fn a_shell_step_that_fails_without_stderr_reports_its_exit_code() {
1881        if crate::cgroup::skip_without_host_support(
1882            "plan::tests::a_shell_step_that_fails_without_stderr_reports_its_exit_code",
1883        ) {
1884            return;
1885        }
1886        let dir = tempdir().unwrap();
1887        let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1888        let plan = write_plan(
1889            dir.path(),
1890            json!({
1891                "id": "p",
1892                "steps": [{"id": "quiet", "shell": "bash -c 'exit 3'"}]
1893            }),
1894        );
1895
1896        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
1897        assert!(!report.completed);
1898        assert_eq!(report.results[0].status, StepStatus::Failed);
1899        assert_eq!(report.results[0].error.as_deref(), Some("exit code 3"));
1900    }
1901
1902    /// A plan submitted inside a session runs its shell steps in that session's network
1903    /// namespace, so a step reaches a server one of the session's own commands started — and the
1904    /// plan leaves the namespace to the session when it ends.
1905    #[cfg(target_os = "linux")]
1906    #[test]
1907    #[allow(clippy::print_stdout)]
1908    fn a_plan_shell_step_joins_the_session_namespace() {
1909        if crate::cgroup::skip_without_host_support(
1910            "plan::tests::a_plan_shell_step_joins_the_session_namespace",
1911        ) {
1912            return;
1913        }
1914        let dir = tempdir().unwrap();
1915        let policy = CapabilityPolicy {
1916            shell_allow: vec!["bash".to_string(), "nc".to_string(), "sleep".to_string()],
1917            ..CapabilityPolicy::default()
1918        };
1919        let session = sandbox::ShellEnforcement::resolve_declared(
1920            &policy,
1921            murmur_artifact::ContainmentClass::Scoped,
1922            sandbox::HostProbe::probe(),
1923        )
1924        .unwrap()
1925        .with_test_session_dirs();
1926        let server = execute_shell(
1927            "bash",
1928            &[
1929                "-c",
1930                "(printf \"pong\\n\" | nc -N -l 127.0.0.1 8080) >/dev/null 2>&1 & echo $! > server.pid",
1931            ],
1932            &[],
1933            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1934            &policy,
1935            &session,
1936        )
1937        .expect("the session's server command runs");
1938        assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
1939
1940        let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1941        let mut ctx = test_ctx(dir.path().to_path_buf(), &invoke);
1942        ctx.capability_policy.shell_allow = policy.shell_allow.clone();
1943        let plan = write_plan(
1944            dir.path(),
1945            json!({
1946                "id": "p",
1947                "steps": [{
1948                    "id": "client",
1949                    "shell": "bash -c 'for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 \
1950                              2>/dev/null && break; sleep 0.1; done; read -r reply <&3; \
1951                              echo \"$reply\"'",
1952                }]
1953            }),
1954        );
1955
1956        let report = execute_in_session(&plan, &ctx, Arc::clone(&session.session_netns));
1957        println!(
1958            "plan completed: {}; step {} {:?} output {:?} error {:?}; session namespaces \
1959             created: {}",
1960            report.completed,
1961            report.results[0].step_id,
1962            report.results[0].status,
1963            report.results[0].output.as_deref().map(str::trim),
1964            report.results[0].error,
1965            session.session_netns.creations()
1966        );
1967        assert!(report.completed, "{:?}", report.results);
1968        assert_eq!(report.results[0].status, StepStatus::Success);
1969        assert_eq!(
1970            report.results[0].output.as_deref().map(str::trim),
1971            Some("pong")
1972        );
1973        assert_eq!(session.session_netns.creations(), 1);
1974
1975        // The plan did not end the session's namespace: the session's next command still runs.
1976        let after = execute_shell(
1977            "bash",
1978            &["-c", "true"],
1979            &[],
1980            &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
1981            &policy,
1982            &session,
1983        )
1984        .expect("the session outlives the plan it submitted");
1985        assert_eq!(after.exit_code, 0);
1986        session.end_session();
1987    }
1988
1989    /// A `shell` step is put to the same decision point, and a refused one starts no subprocess.
1990    #[test]
1991    fn a_refused_shell_step_starts_no_subprocess() {
1992        if crate::cgroup::skip_without_host_support(
1993            "plan::tests::a_refused_shell_step_starts_no_subprocess",
1994        ) {
1995            return;
1996        }
1997        let dir = tempdir().unwrap();
1998        let invoke = |_name: &str, _input: ToolInput| Ok(tool_result(ToolStatus::Passed, None));
1999        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2000        let recorded = Arc::clone(&seen);
2001        let gate = move |call: &PlannedCall<'_>| match call {
2002            PlannedCall::Shell { binary, argv, .. } => {
2003                recorded
2004                    .lock()
2005                    .unwrap()
2006                    .push((binary.to_string(), argv.to_vec()));
2007                Some("refused by the manifest".to_string())
2008            }
2009            _ => None,
2010        };
2011        let marker = dir.path().join("written-by-a-refused-step");
2012        let plan = write_plan(
2013            dir.path(),
2014            json!({
2015                "id": "p",
2016                "steps": [{
2017                    "id": "w",
2018                    "shell": format!("bash -c 'printf x > {}'", marker.display()),
2019                }]
2020            }),
2021        );
2022
2023        let report = execute(
2024            &plan,
2025            &test_ctx_gated(dir.path().to_path_buf(), &invoke, &gate),
2026        );
2027        assert!(!report.completed);
2028        assert_eq!(report.failed_step.as_deref(), Some("w"));
2029        assert_eq!(
2030            report.results[0].error.as_deref(),
2031            Some("refused by the manifest")
2032        );
2033        assert!(!marker.exists(), "a refused step must not have run");
2034        // The gate is shown the argv the executable would have received, not the raw command.
2035        let seen = seen.lock().unwrap();
2036        assert_eq!(seen.len(), 1);
2037        assert_eq!(seen[0].0, "bash");
2038        assert_eq!(seen[0].1[0], "-c");
2039    }
2040
2041    #[test]
2042    fn dependent_step_receives_upstream_output() {
2043        if crate::cgroup::skip_without_host_support(
2044            "plan::tests::dependent_step_receives_upstream_output",
2045        ) {
2046            return;
2047        }
2048        let dir = tempdir().unwrap();
2049        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2050        let seen_tool = Arc::clone(&seen);
2051        let invoke = move |_name: &str, input: ToolInput| {
2052            seen_tool
2053                .lock()
2054                .unwrap()
2055                .push(input.data.unwrap_or_default());
2056            Ok(tool_result(
2057                ToolStatus::Passed,
2058                Some("upstream".to_string()),
2059            ))
2060        };
2061        let plan = write_plan(
2062            dir.path(),
2063            json!({
2064                "id": "p",
2065                "steps": [
2066                    {"id": "a", "tool": "ok"},
2067                    {"id": "b", "tool": "ok", "depends_on": ["a"], "input": {"value": "$a.output"}}
2068                ]
2069            }),
2070        );
2071
2072        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2073        assert!(report.completed);
2074        assert_eq!(report.results.len(), 2);
2075        assert!(seen.lock().unwrap()[1].contains("upstream"));
2076    }
2077
2078    #[test]
2079    fn parallel_tool_steps_run_concurrently() {
2080        if crate::cgroup::skip_without_host_support(
2081            "plan::tests::parallel_tool_steps_run_concurrently",
2082        ) {
2083            return;
2084        }
2085        // How long a step waits for its sibling to be in flight. Run sequentially, the first
2086        // step waits this out alone; run concurrently, the steps meet as soon as both start.
2087        const RENDEZVOUS_GUARD: Duration = Duration::from_secs(30);
2088
2089        /// `met` latches once both steps are in flight together, so the first step to leave the
2090        /// rendezvous cannot strand the other.
2091        #[derive(Default)]
2092        struct Rendezvous {
2093            in_flight: usize,
2094            met: bool,
2095        }
2096
2097        let dir = tempdir().unwrap();
2098        let rendezvous = Arc::new((Mutex::new(Rendezvous::default()), Condvar::new()));
2099        let met_sibling = Arc::new(AtomicUsize::new(0));
2100        let invoke_rendezvous = Arc::clone(&rendezvous);
2101        let invoke_met = Arc::clone(&met_sibling);
2102        let invoke = move |_name: &str, _input: ToolInput| {
2103            let (state, changed) = &*invoke_rendezvous;
2104            let mut state = state.lock().unwrap();
2105            state.in_flight += 1;
2106            if state.in_flight == 2 {
2107                state.met = true;
2108            }
2109            changed.notify_all();
2110            let (mut state, _) = changed
2111                .wait_timeout_while(state, RENDEZVOUS_GUARD, |state| !state.met)
2112                .unwrap();
2113            if state.met {
2114                invoke_met.fetch_add(1, Ordering::SeqCst);
2115            }
2116            state.in_flight -= 1;
2117            Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2118        };
2119        let plan = write_plan(
2120            dir.path(),
2121            json!({
2122                "id": "p",
2123                "steps": [
2124                    {"id": "a", "tool": "slow"},
2125                    {"id": "b", "tool": "slow"}
2126                ]
2127            }),
2128        );
2129
2130        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2131        assert!(report.completed);
2132        assert_eq!(
2133            met_sibling.load(Ordering::SeqCst),
2134            2,
2135            "the two independent steps never ran at the same time"
2136        );
2137    }
2138
2139    #[test]
2140    fn on_error_and_retries_are_applied() {
2141        if crate::cgroup::skip_without_host_support("plan::tests::on_error_and_retries_are_applied")
2142        {
2143            return;
2144        }
2145        let dir = tempdir().unwrap();
2146        let calls = Arc::new(AtomicUsize::new(0));
2147        let invoke_calls = Arc::clone(&calls);
2148        let invoke = move |name: &str, _input: ToolInput| {
2149            invoke_calls.fetch_add(1, Ordering::SeqCst);
2150            if name == "a" {
2151                Ok(tool_result(ToolStatus::Failed, None))
2152            } else {
2153                Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2154            }
2155        };
2156        let plan = write_plan(
2157            dir.path(),
2158            json!({
2159                "id": "p",
2160                "steps": [
2161                    {"id": "a", "tool": "a", "retries": 2, "on_error": "continue"},
2162                    {"id": "b", "tool": "b", "depends_on": ["a"]}
2163                ]
2164            }),
2165        );
2166
2167        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2168        assert!(report.completed);
2169        assert_eq!(calls.load(Ordering::SeqCst), 4);
2170    }
2171
2172    #[test]
2173    fn condition_false_skips_step() {
2174        if crate::cgroup::skip_without_host_support("plan::tests::condition_false_skips_step") {
2175            return;
2176        }
2177        let dir = tempdir().unwrap();
2178        let invoke = |_name: &str, _input: ToolInput| {
2179            Ok(tool_result(ToolStatus::Passed, Some("ok".to_string())))
2180        };
2181        let plan = write_plan(
2182            dir.path(),
2183            json!({
2184                "id": "p",
2185                "steps": [
2186                    {"id": "a", "tool": "ok"},
2187                    {"id": "b", "tool": "ok", "depends_on": ["a"], "if": "$a.status == 'failed'"}
2188                ]
2189            }),
2190        );
2191
2192        let report = execute(&plan, &test_ctx(dir.path().to_path_buf(), &invoke));
2193        assert!(report.completed);
2194        assert_eq!(report.results[1].status, StepStatus::Skipped);
2195    }
2196
2197    /// A task whose cancel flag a case raises by hand. It holds no delegation set: these cases
2198    /// launch no sub-capsule.
2199    #[derive(Default)]
2200    struct FlaggedTask(std::sync::atomic::AtomicBool);
2201
2202    impl PlanTask for FlaggedTask {
2203        fn is_canceled(&self) -> bool {
2204            self.0.load(Ordering::SeqCst)
2205        }
2206        fn delegation_started(&self, _launch: &DelegationLaunch) {}
2207        fn delegation_settled(&self, _delegation_id: &str) -> bool {
2208            true
2209        }
2210    }
2211
2212    /// A step that fails after its task is cancelled is not tried again, and its `on_error:
2213    /// fail` names no failed step: the run stopped because the task did, whatever the step said.
2214    #[test]
2215    fn a_cancelled_task_tries_no_step_again() {
2216        let dir = tempdir().unwrap();
2217        let task = FlaggedTask::default();
2218        let calls = AtomicUsize::new(0);
2219        let invoke = |_name: &str, _input: ToolInput| {
2220            calls.fetch_add(1, Ordering::SeqCst);
2221            task.0.store(true, Ordering::SeqCst);
2222            Ok(tool_result(ToolStatus::Failed, None))
2223        };
2224        let plan = write_plan(
2225            dir.path(),
2226            json!({
2227                "id": "p",
2228                "steps": [
2229                    {"id": "a", "tool": "a", "retries": 2, "on_error": "fail"},
2230                    {"id": "b", "tool": "b", "depends_on": ["a"]}
2231                ]
2232            }),
2233        );
2234
2235        let ctx = SchedulerContext {
2236            task: Some(&task),
2237            ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2238        };
2239        let report = execute(&plan, &ctx);
2240
2241        assert_eq!(calls.load(Ordering::SeqCst), 1, "{report:?}");
2242        assert!(report.canceled, "{report:?}");
2243        assert!(!report.completed);
2244        assert_eq!(report.failed_step, None);
2245        assert_eq!(report.results[0].status, StepStatus::Failed);
2246        assert_eq!(report.results[1].status, StepStatus::Skipped);
2247        assert_eq!(
2248            report.results[1].error.as_deref(),
2249            Some(CANCELED_BEFORE_STEP_ERROR)
2250        );
2251    }
2252
2253    /// A task cancelled before the first wave dispatches nothing at all.
2254    #[test]
2255    fn a_task_cancelled_before_the_plan_ran_dispatches_nothing() {
2256        let dir = tempdir().unwrap();
2257        let task = FlaggedTask::default();
2258        task.0.store(true, Ordering::SeqCst);
2259        let calls = AtomicUsize::new(0);
2260        let invoke = |_name: &str, _input: ToolInput| {
2261            calls.fetch_add(1, Ordering::SeqCst);
2262            Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2263        };
2264        let plan = write_plan(
2265            dir.path(),
2266            json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2267        );
2268
2269        let ctx = SchedulerContext {
2270            task: Some(&task),
2271            ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2272        };
2273        let report = execute(&plan, &ctx);
2274
2275        assert_eq!(calls.load(Ordering::SeqCst), 0);
2276        assert!(report.canceled && !report.completed, "{report:?}");
2277        assert!(report
2278            .results
2279            .iter()
2280            .all(|result| result.status == StepStatus::Skipped));
2281    }
2282
2283    /// A run whose task is never cancelled reports exactly what a run with no task does.
2284    #[test]
2285    fn a_task_nobody_cancels_changes_nothing() {
2286        let dir = tempdir().unwrap();
2287        let task = FlaggedTask::default();
2288        let invoke = |_name: &str, _input: ToolInput| {
2289            Ok(tool_result(ToolStatus::Passed, Some("ran".to_string())))
2290        };
2291        let plan = write_plan(
2292            dir.path(),
2293            json!({"id": "p", "steps": [{"id": "a", "tool": "a"}, {"id": "b", "tool": "b"}]}),
2294        );
2295
2296        let without = execute(
2297            &plan,
2298            &test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED),
2299        );
2300        let with = execute(
2301            &plan,
2302            &SchedulerContext {
2303                task: Some(&task),
2304                ..test_ctx_unbounded(dir.path().to_path_buf(), &invoke, &UNGATED)
2305            },
2306        );
2307        assert_eq!(with, without);
2308        assert!(with.completed && !with.canceled);
2309    }
2310}