Skip to main content

capsule_runtime/
plan.rs

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