Skip to main content

capsule_runtime/
plan.rs

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