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