Skip to main content

mlua_swarm/service/
task_launch.rs

1//! `TaskLaunchService` — the domain service that runs a Blueprint flow
2//! to completion through the engine.
3//!
4//! Responsibilities:
5//! 1. Compile the Blueprint and link it into a `SpawnerAdapter` (via
6//!    `service::linker::link`, wrapped by `EngineDispatcher::with_spawner`).
7//! 2. Acquire an Operator session (via `engine.attach`).
8//! 3. Run flow.ir's `eval_async_externs` through an `EngineDispatcher`
9//!    (threading the service-held `call_extern` registry) and return
10//!    the final `ctx`.
11//! 4. If any step fails (dispatcher error), the eval errors and
12//!    the failure propagates as-is.
13//!
14//! Callers on the Application layer never touch the engine directly —
15//! `bind`, `start_task`, and `eval_async` all stay inside the Service.
16//!
17//! A single-task-spawn API (calling `start_task` directly) is
18//! deliberately absent here: a single spawn can be modeled as a
19//! one-Step flow, and we do not want two interfaces for the same
20//! shape.
21
22use crate::blueprint::compiler::{CompileError, Compiler};
23use crate::blueprint::{AuditDef, Blueprint, EngineDispatcher};
24use crate::core::agent_context::ContextPolicy;
25use crate::core::config::CheckPolicy;
26use crate::core::ctx::OperatorKind;
27use crate::core::engine::Engine;
28use crate::core::errors::EngineError;
29use crate::middleware::agent_context::AgentContextMiddleware;
30use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
31use crate::middleware::task_input::TaskInputMiddleware;
32use crate::middleware::worker_binding::WorkerBindingMiddleware;
33use crate::middleware::{AfterRunAuditMiddleware, SpawnerStack};
34use crate::operator::WorkerBinding;
35use crate::service::linker;
36use crate::store::run::RunContext;
37use crate::types::{CapToken, Role};
38use mlua_flow_ir::{Externs, NoExterns};
39use serde::{Deserialize, Serialize};
40use serde_json::Value;
41use std::collections::HashMap;
42use std::sync::Arc;
43use std::time::Duration;
44use thiserror::Error;
45
46/// Derive the "BP Agent-level" tier of the `OperatorKind` cascade from a
47/// Blueprint: for every `AgentDef` whose `spec.operator_ref` resolves to an
48/// `OperatorDef` with a `Some` `kind`, map `AgentDef.name -> OperatorKind`.
49///
50/// Deliberately **not** filtered by `AgentDef.kind == AgentKind::Operator`:
51/// the `OperatorKind` cascade is a middleware-level cross-cutting concern
52/// (spawn_hook / senior_bridge / operator-delegate gating via `Ctx.operator`),
53/// orthogonal to the Worker IMPL axis that `AgentKind` expresses (see the
54/// crate root doc, "Operator is delivered as a cross-cutting overlay through
55/// `Ctx` plus middleware"). A `RustFn` / `Lua` / `Subprocess` agent can
56/// equally declare `spec.operator_ref` to opt into a BP-declared
57/// `OperatorKind` without changing its Worker IMPL. Agents without an
58/// `operator_ref`, an unresolved `operator_ref`, or an `OperatorDef.kind =
59/// None` are simply absent from the map (= that tier falls through for
60/// them). This is a separate, independent consumer of `Blueprint.operators`
61/// from the design-time `operator_ref` validation in
62/// `blueprint::compiler::Compiler::compile` (issue: `OperatorDef`
63/// first-class treatment), which only checks the reference resolves for
64/// `AgentKind::Operator` agents and is unaffected by this function.
65/// Build the `agent name → WorkerBinding` map from
66/// `Blueprint.agents[].profile.worker_binding` — the launch-time sibling of
67/// the compile-time resolution in `OperatorSpawnerFactory::build`. Consumed
68/// by `WorkerBindingMiddleware` so the delegate axis
69/// (`OperatorDelegateMiddleware`) can resolve the binding via `ctx.agent`
70/// like every other agent-keyed table (`CompiledAgentTable.routes` idiom).
71/// Agents without a declared binding are simply absent (no silent default).
72pub(crate) fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
73    blueprint
74        .agents
75        .iter()
76        .filter_map(|ad| {
77            let profile = ad.profile.as_ref()?;
78            let variant = profile.worker_binding.as_ref()?;
79            Some((
80                ad.name.clone(),
81                WorkerBinding {
82                    variant: variant.clone(),
83                    tools: profile.tools.clone(),
84                },
85            ))
86        })
87        .collect()
88}
89
90/// GH #34 — extract the Blueprint-declared after-run audit hooks
91/// (`Blueprint.audits`), the launch-time input to `AfterRunAuditMiddleware`.
92/// Trivial extraction (unlike [`derive_worker_bindings`] / the agent-context
93/// derivers below, no per-agent lookup is needed — `AuditDef.agent` is a
94/// plain agent-name string already validated against `Blueprint.agents` at
95/// `Compiler::compile` time). `[]` (every pre-#34 Blueprint) means "no
96/// audit layer at all" — see the conditional `.layer(...)` wiring in
97/// [`TaskLaunchService::launch`] (invariant #4: byte-identical behavior).
98fn derive_audits(blueprint: &Blueprint) -> Vec<AuditDef> {
99    blueprint.audits.clone()
100}
101
102/// Issue #21 Phase 1: build the agent-context supply axis's "BP Global" +
103/// "BP Agent-level" context tiers from a Blueprint — the launch-time
104/// sibling of [`derive_worker_bindings`] (same "no silent default"
105/// discipline: an agent's entry is present only when it declares one).
106/// Consumed by `AgentContextMiddleware`, which shallow-merges the two
107/// tiers per spawn (agent wins) and inserts the result into
108/// `ctx.meta.runtime` only-if-absent (see
109/// `crate::middleware::agent_context`'s module doc for the full merge +
110/// precedence narrative).
111///
112/// - `.0` (global) = [`Blueprint::default_agent_ctx`], unchanged.
113/// - `.1` (per-agent) = `AgentDef.name -> AgentMeta.ctx`, entry present
114///   only for agents whose `meta` is `Some` and who declare a `ctx`
115///   (directly via `meta.ctx`, and/or indirectly via
116///   [`AgentMeta::meta_ref`] — GH #21 Phase 2, see below).
117///
118/// # GH #21 Phase 2: `AgentMeta.meta_ref` resolution
119///
120/// When an agent declares `meta.meta_ref`, it is resolved against
121/// [`derive_step_metas`]'s pool and used as the BASE layer UNDER the
122/// agent's own inline `meta.ctx` (inline wins on key collision, shallow
123/// merge — see [`shallow_merge_inline_wins`]). An unresolved `meta_ref`
124/// at this point means the caller launched a Blueprint that bypassed
125/// `Compiler::compile`'s validation (the loud gate for this case, see
126/// `blueprint::compiler::Compiler::compile`'s `UnresolvedMetaRef` check);
127/// this function stays defensive and never panics — it logs a warning and
128/// skips the base layer, letting the agent's own inline `ctx` (if any)
129/// stand alone.
130pub(crate) fn derive_agent_ctx(blueprint: &Blueprint) -> (Option<Value>, HashMap<String, Value>) {
131    let global = blueprint.default_agent_ctx.clone();
132    let meta_pool = derive_step_metas(blueprint);
133    let per_agent = blueprint
134        .agents
135        .iter()
136        .filter_map(|ad| {
137            let meta = ad.meta.as_ref()?;
138            let inline = meta.ctx.clone();
139            let base = meta.meta_ref.as_ref().and_then(|name| {
140                let resolved = meta_pool.get(name).cloned();
141                if resolved.is_none() {
142                    tracing::warn!(
143                        agent = %ad.name,
144                        meta_ref = %name,
145                        "derive_agent_ctx: AgentMeta.meta_ref names an undefined Blueprint.metas entry; skipping the base layer"
146                    );
147                }
148                resolved
149            });
150            let merged = match (base, inline) {
151                (None, None) => None,
152                (Some(base), None) => Some(base),
153                (None, Some(inline)) => Some(inline),
154                (Some(base), Some(inline)) => Some(shallow_merge_inline_wins(base, inline)),
155            };
156            merged.map(|ctx| (ad.name.clone(), ctx))
157        })
158        .collect();
159    (global, per_agent)
160}
161
162/// GH #21 Phase 2: shallow-merge `base` with `inline`, `inline` winning
163/// key collisions. Both sides being JSON `Object`s is the meaningful case
164/// (per-key merge); a non-`Object` `inline` is used as-is (it "wins"
165/// entirely — the malformed-shape case is left to
166/// `AgentContextMiddleware`'s own tier merge, which already warns + skips
167/// a non-`Object` tier value downstream, never failing the spawn).
168pub(crate) fn shallow_merge_inline_wins(base: Value, inline: Value) -> Value {
169    match (base, inline) {
170        (Value::Object(mut base), Value::Object(inline)) => {
171            for (k, v) in inline {
172                base.insert(k, v);
173            }
174            Value::Object(base)
175        }
176        (_, inline) => inline,
177    }
178}
179
180/// GH #21 Phase 2: build the `Blueprint.metas` named pool (`MetaDef.name
181/// -> MetaDef.ctx`) — the launch-time sibling of [`derive_worker_bindings`]
182/// / [`derive_agent_ctx`], resolving the Step tier's shared pool instead
183/// of a per-agent map. Consumed by `EngineDispatcher::with_step_metas`
184/// (the Step tier's `$step_meta.ref` resolver) and, indirectly, by
185/// [`derive_agent_ctx`]'s `AgentMeta.meta_ref` resolution (the Agent
186/// tier shares the same pool).
187fn derive_step_metas(blueprint: &Blueprint) -> HashMap<String, Value> {
188    blueprint
189        .metas
190        .iter()
191        .map(|m| (m.name.clone(), m.ctx.clone()))
192        .collect()
193}
194
195/// Issue #21 Phase 1: build the [`ContextPolicy`] cascade's "BP Global" +
196/// "BP Agent-level" tiers from a Blueprint — same shape and discipline as
197/// [`derive_agent_ctx`], from `Blueprint.default_context_policy` /
198/// `AgentMeta.context_policy` instead. Consumed by
199/// `AgentContextMiddleware`, which resolves the effective policy per spawn
200/// (per-agent tier outranks the BP-global one; pass-all when neither is
201/// declared for the dispatching agent).
202fn derive_context_policies(
203    blueprint: &Blueprint,
204) -> (Option<ContextPolicy>, HashMap<String, ContextPolicy>) {
205    let default_policy = blueprint.default_context_policy.clone();
206    let per_agent = blueprint
207        .agents
208        .iter()
209        .filter_map(|ad| {
210            let meta = ad.meta.as_ref()?;
211            let policy = meta.context_policy.clone()?;
212            Some((ad.name.clone(), policy))
213        })
214        .collect();
215    (default_policy, per_agent)
216}
217
218/// Issue #19 ST3: shallow-merge the "BP Global" default `init_ctx`
219/// (`Blueprint.default_init_ctx`) with the Task-level `init_ctx` — the
220/// second layer of the (eventual 4-layer) init-ctx cascade, following the
221/// same "BP default, Task overrides" shape as the `OperatorKind` cascade
222/// (see `derive_bp_agent_kinds` / `TaskLaunchInput::operator_kind`).
223///
224/// Semantics (deliberately a single rule, no deep merge / JSON Patch):
225///
226/// - `bp_default = None` → `task_init_ctx` passes through unchanged
227///   (pre-#19 Blueprints keep today's exact behavior).
228/// - Both sides are `Value::Object` → shallow key-wise merge, Task wins
229///   on collision (`task_init_ctx`'s keys are applied last).
230/// - `task_init_ctx` is present but not an `Object` (`Null` / `String` /
231///   `Array` / `Number` / `Bool`) → Task fully replaces the BP default;
232///   the caller's non-Object seed is respected as-is.
233fn merge_init_ctx(bp_default: Option<&Value>, task_init_ctx: &Value) -> Value {
234    match (bp_default, task_init_ctx) {
235        (Some(Value::Object(bp_map)), Value::Object(task_map)) => {
236            let mut merged = bp_map.clone();
237            for (k, v) in task_map {
238                merged.insert(k.clone(), v.clone());
239            }
240            Value::Object(merged)
241        }
242        (None, _) => task_init_ctx.clone(),
243        (_, task) => task.clone(),
244    }
245}
246
247/// Issue #19 ST4: 3-layer shallow-merge of the init-ctx cascade — BP
248/// default → Task → Run (lowest to highest priority). Built by chaining
249/// [`merge_init_ctx`] twice rather than introducing a distinct 3-way merge
250/// algorithm, so the Run layer inherits exactly the same "shallow Object
251/// merge, non-Object fully replaces" rule [`merge_init_ctx`] already
252/// established for the BP/Task pair (see its doc for the full semantics).
253///
254/// - `run_override: None` is a no-op — the BP+Task merge passes through
255///   unchanged, so `POST /v1/tasks/:id/runs` with no body (or a body that
256///   omits `init_ctx_override`) preserves today's rekick behavior
257///   byte-for-byte.
258/// - `run_override: Some(_)` layers on top exactly like `task_init_ctx`
259///   layers on top of `bp_default` above: both `Object` → shallow
260///   key-wise merge with Run winning collisions; Run non-`Object` →
261///   fully replaces the BP+Task merge.
262pub fn merge_init_ctx_3layer(
263    bp_default: Option<&Value>,
264    task_init_ctx: &Value,
265    run_override: Option<&Value>,
266) -> Value {
267    let bp_task = merge_init_ctx(bp_default, task_init_ctx);
268    match run_override {
269        Some(run) => merge_init_ctx(Some(&bp_task), run),
270        None => bp_task,
271    }
272}
273
274fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
275    let mut out = HashMap::new();
276    if blueprint.operators.is_empty() {
277        return out;
278    }
279    for agent in &blueprint.agents {
280        let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
281            continue;
282        };
283        let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
284            continue;
285        };
286        if let Some(kind) = op_def.kind {
287            out.insert(agent.name.clone(), OperatorKind::from(kind));
288        }
289    }
290    out
291}
292
293/// Failure modes of [`TaskLaunchService::launch`].
294#[derive(Debug, Error)]
295pub enum TaskLaunchError {
296    /// `Compiler::compile` rejected the Blueprint.
297    #[error("compile: {0}")]
298    Compile(#[from] CompileError),
299    /// `Engine::attach_with_ids` failed.
300    #[error("engine: {0}")]
301    Engine(#[from] EngineError),
302    /// A `Step` inside `flow.ir`'s `eval_async` produced a dispatcher
303    /// error, or a sub-flow raised.
304    #[error("flow eval: {0}")]
305    FlowEval(String),
306    /// Pre-dispatch validation failed: the launch was rejected before any
307    /// step was dispatched. Raised when the effective check_policy
308    /// (launch request > blueprint > server config) is Strict and the
309    /// launch supplied neither project_root nor work_dir — a strict task
310    /// would deterministically fail at its first submit-time file
311    /// materialize, so the launch fails fast instead.
312    #[error("pre-dispatch: {0}")]
313    PreDispatch(String),
314}
315
316/// Canonical bag of Task-level fields (`project_root` / `work_dir` /
317/// `task_metadata`) — [`TaskLaunchInput::task_input`]'s type.
318///
319/// Issue #19 ST2: replaces the ST1 `resolve_task_level_init_ctx`
320/// fold-back-into-`init_ctx` bridge (removed from
321/// `mlua-swarm-server`'s `run_flow_form`). Callers resolve these three
322/// fields once at the wire boundary — sibling body field first, falling
323/// back to the legacy shape (same three keys nested directly inside
324/// `init_ctx`) only there — and hand the result straight through here;
325/// `init_ctx` itself is no longer mutated to carry them, so it stays a
326/// pure flow-ir eval seed identical to whatever the caller sent.
327///
328/// Each field is independently optional — see
329/// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`],
330/// which this is built for.
331///
332/// Issue #19 ST4: also `Serialize`/`Deserialize` so it can travel over the
333/// wire as `RunKickRequest.task_input_override` (`mlua-swarm-server`'s
334/// `tasks` module) and be snapshotted into `TaskRecord.task_input_spec`
335/// (JSON) for rekick to resolve back out of. Every field is
336/// `#[serde(default)]` so a caller may omit any subset (or send `{}`) and
337/// still deserialize.
338#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
339pub struct TaskInputSpec {
340    /// Task-level project root path.
341    #[serde(default)]
342    pub project_root: Option<String>,
343    /// Task-level working directory path.
344    #[serde(default)]
345    pub work_dir: Option<String>,
346    /// Task-level arbitrary metadata bag (a JSON object, or `None`).
347    #[serde(default)]
348    #[schemars(with = "Option<Value>")]
349    pub task_metadata: Option<Value>,
350}
351
352/// Input to [`TaskLaunchService::launch`].
353#[derive(Debug, Clone)]
354pub struct TaskLaunchInput {
355    /// The Blueprint to compile, link, and run.
356    pub blueprint: Blueprint,
357    /// Caller-supplied id for the Operator that owns this run.
358    pub operator_id: String,
359    /// The Operator's role for this run.
360    pub role: Role,
361    /// How long the attached session is allowed to live.
362    pub ttl: Duration,
363    /// "Runtime Global" tier of the `OperatorKind` cascade. `Some(_)` is
364    /// always an explicit request — including `Some(OperatorKind::Automate)`
365    /// — that outranks the BP-level tiers (`OperatorDef.kind` /
366    /// `Blueprint.default_operator_kind`); `None` leaves it unspecified so
367    /// those tiers / the final default decide. Under `MainAi` or
368    /// `Composite`, `MainAIMiddleware`'s `spawn_hook` before/after
369    /// callbacks become effective. See
370    /// `crate::core::ctx::collapse_operator_kind`.
371    pub operator_kind: Option<OperatorKind>,
372    /// `SeniorBridge` registry ID. `None` — no bridge; `Some(id)` —
373    /// attach a bridge previously registered via
374    /// `engine.register_senior_bridge`.
375    pub bridge_id: Option<String>,
376    /// `SpawnHook` registry ID. Same shape as above, via
377    /// `engine.register_spawn_hook`.
378    pub hook_id: Option<String>,
379    /// Operator registry ID — used on the path that hands the whole
380    /// spawn off to an external Operator. Name previously registered
381    /// with `engine.register_operator`; resolved by
382    /// `OperatorDelegateMiddleware`, which — for `kind = MainAi` or
383    /// `Composite` — bypasses `inner.spawn` and calls
384    /// `operator.execute`.
385    pub operator_backend_id: Option<String>,
386    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
387    /// cascade — per-agent override, keyed by `AgentDef.name`. Empty by
388    /// default (no override for any agent). See
389    /// `crate::core::ctx::collapse_operator_kind` for the full tier list.
390    pub operator_kind_overrides: HashMap<String, OperatorKind>,
391    /// The initial `ctx` (JSON `Value`) that flow.ir's `eval_async`
392    /// starts from. Every `Step.in` `$.<path>` reference reads from
393    /// here. Issue #19 ST2: a pure flow-ir eval seed — no Task-level
394    /// field is folded into it anymore; see [`Self::task_input`].
395    pub init_ctx: Value,
396    /// Task-level canonical fields (issue #19 ST2). `Some` layers a
397    /// [`crate::middleware::task_input::TaskInputMiddleware`] (built via
398    /// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`])
399    /// onto the spawner stack just before spawn; `None` is a no-op,
400    /// identical to today's behavior for callers with no Task-level
401    /// fields to propagate.
402    pub task_input: Option<TaskInputSpec>,
403    /// Issue #13 run_id propagation: when `Some`, every step this launch
404    /// dispatches is traced into `RunRecord.step_entries` and exposes its
405    /// `run_id` via `Ctx.meta.runtime["run_id"]` (see
406    /// `EngineDispatcher::with_run`). `None` (the default via
407    /// [`Self::automate`]) preserves the pre-existing behavior — no run
408    /// tracing.
409    pub run_ctx: Option<RunContext>,
410    /// The "launch request" tier (tier 1, highest
411    /// priority) of the `check_policy` cascade
412    /// (`launch request > blueprint > server config`).
413    /// [`TaskLaunchService::launch`]
414    /// collapses `check_policy.or(blueprint.check_policy)` exactly once and
415    /// threads the result into every spawned step's `TaskSpec.check_policy`.
416    /// `None` (the default via [`Self::automate`]) leaves this tier
417    /// unspecified so the Blueprint tier / server-wide default decide —
418    /// backward-compat with every pre-cascade caller.
419    ///
420    /// [`TaskLaunchService::launch`] also collapses this same cascade one
421    /// step further
422    /// (adding the server-wide `EngineCfg.check_policy` tier) into a
423    /// pre-dispatch guard: when the resulting effective policy is
424    /// [`CheckPolicy::Strict`] and neither [`Self::task_input`]'s
425    /// `project_root` nor `work_dir` is set, the launch is rejected with
426    /// `TaskLaunchError::PreDispatch` before any step is dispatched — a
427    /// strict task with no resolvable root would deterministically fail
428    /// at its first submit-time file materialize anyway. Setting this
429    /// field to `Some(CheckPolicy::Warn)` on the launch-request tier is
430    /// the escape hatch: it outranks a Blueprint- or server-declared
431    /// Strict and lets the guard pass.
432    pub check_policy: Option<CheckPolicy>,
433}
434
435impl TaskLaunchInput {
436    /// Helper for existing callers on the default path — no hooks and no
437    /// per-agent `OperatorKind` overrides. Leaves the "Runtime Global" tier
438    /// unspecified (`None`), so the BP-level tiers / final default
439    /// (`OperatorKind::Automate`) decide — this preserves today's
440    /// behaviour for every existing caller without silently forcing
441    /// `Automate` as an explicit override that would outrank a BP-declared
442    /// `MainAi`/`Composite` kind. `run_ctx` and `task_input` both default
443    /// to `None` (no run tracing, no Task-level fields); construct the
444    /// struct literal directly to set either.
445    pub fn automate(
446        blueprint: Blueprint,
447        operator_id: impl Into<String>,
448        role: Role,
449        ttl: Duration,
450        init_ctx: Value,
451    ) -> Self {
452        Self {
453            blueprint,
454            operator_id: operator_id.into(),
455            role,
456            ttl,
457            operator_kind: None,
458            bridge_id: None,
459            hook_id: None,
460            operator_backend_id: None,
461            operator_kind_overrides: HashMap::new(),
462            init_ctx,
463            task_input: None,
464            run_ctx: None,
465            check_policy: None,
466        }
467    }
468}
469
470/// Result of a successful [`TaskLaunchService::launch`] call.
471#[derive(Debug, Clone)]
472pub struct TaskLaunchOutput {
473    /// The capability token for the attached session.
474    pub token: CapToken,
475    /// The final `ctx` after the flow ran — every `Step.out` has
476    /// been written. Application-layer callers pull the outcome out
477    /// of this `Value` and fold it into a domain status.
478    pub final_ctx: Value,
479}
480
481/// Domain service that compiles, links, and runs a Blueprint's flow to
482/// completion through the [`Engine`]. See the module doc for the full
483/// responsibility list.
484pub struct TaskLaunchService {
485    engine: Engine,
486    compiler: Compiler,
487    /// `call_extern` registry threaded into flow eval. Defaults to
488    /// [`NoExterns`] (= every `call_extern` in a Blueprint raises
489    /// `ExternError`); hosts opt in via [`Self::with_externs`] with an
490    /// `ExternMap` of pure value-shape functions.
491    externs: Arc<dyn Externs + Send + Sync>,
492}
493
494impl TaskLaunchService {
495    /// Build a service bound to one `Engine` and one `Compiler`.
496    pub fn new(engine: Engine, compiler: Compiler) -> Self {
497        Self {
498            engine,
499            compiler,
500            externs: Arc::new(NoExterns),
501        }
502    }
503
504    /// Replace the `call_extern` registry (builder style). Entries MUST be
505    /// pure functions — no side effects, no flow control; effectful work
506    /// belongs to `Step` / agents, not externs (flow-ir canonical contract).
507    pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
508        self.externs = externs;
509        self
510    }
511
512    /// The bound `Engine`.
513    pub fn engine(&self) -> &Engine {
514        &self.engine
515    }
516
517    /// The bound `Compiler`.
518    pub fn compiler(&self) -> &Compiler {
519        &self.compiler
520    }
521
522    /// Run the Blueprint's flow to completion and return the final
523    /// `ctx`.
524    ///
525    /// Failure paths:
526    ///
527    /// - `compiler.compile` failure → `TaskLaunchError::Compile`.
528    /// - `engine.attach` failure → `TaskLaunchError::Engine`.
529    /// - A `Step` inside `flow eval` producing a dispatcher error, or
530    ///   a sub-flow raising, → `TaskLaunchError::FlowEval`. There is
531    ///   no silent partial-success completion; failures always
532    ///   propagate.
533    pub async fn launch(
534        &self,
535        input: TaskLaunchInput,
536    ) -> Result<TaskLaunchOutput, TaskLaunchError> {
537        // After the stateless-executor refactor, the
538        // caller (Service) does compile + link +
539        // `EngineDispatcher::with_spawner` itself; the engine no longer
540        // holds any global spawner state to touch. The link path (base
541        // `SpawnerAdapter` +
542        // `LayerRegistry` resolution + `SpawnerStack` wrapping) is
543        // concentrated inside `service::linker::link` — Service
544        // scatter is intentionally prevented.
545        let compiled = self.compiler.compile(&input.blueprint)?;
546        // GH #50 (Subtask 2 follow-up): merge this Blueprint's compiled
547        // `AgentDef.verdict` contracts into the engine's runtime registry —
548        // see `Engine::register_verdict_contracts`'s doc for the additive
549        // (last-write-wins per agent name) semantics. This is the ONLY
550        // production call site; every other consumer
551        // (`Engine::verdict_contract_for_task`, and through it
552        // `mlua-swarm-server`'s `worker_submit` / `worker_artifact`
553        // submit-time gate) reads from what this line populates.
554        self.engine
555            .register_verdict_contracts(compiled.router.verdict_contracts.clone());
556        let spawner = linker::link(
557            compiled.router.clone(),
558            &input.blueprint.spawner_hints.layers,
559            &self.engine,
560        );
561        // GH #20 Contract C: materialize an `AgentContextView` exactly
562        // once per spawn, innermost relative to every other layer below
563        // (alias / worker-binding / task-input all insert `ctx.meta.runtime`
564        // keys this layer must observe, so it is added FIRST — later
565        // `.layer()` calls become outer, see `middleware::SpawnerStack`).
566        // Unconditional (always layered): every Blueprint gets this layer
567        // even when it declares no agent-context supply tiers at all
568        // (`derive_agent_ctx` / `derive_context_policies` both return
569        // empty state then, matching the pre-#21 `AgentContextMiddleware`
570        // `Default` behavior byte-for-byte). GH #21 Phase 1: the
571        // receptacle named in the #20 comment above is now wired —
572        // `Blueprint.default_agent_ctx` / `default_context_policy` and
573        // `AgentMeta.ctx` / `context_policy` feed this layer's merge +
574        // policy resolution (see `middleware::agent_context`'s module doc
575        // for the full narrative).
576        let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
577        let (context_policy_default, context_policy_per_agent) =
578            derive_context_policies(&input.blueprint);
579        let spawner = SpawnerStack::new(spawner)
580            .layer(AgentContextMiddleware::new(
581                agent_ctx_global,
582                agent_ctx_per_agent,
583                context_policy_default,
584                context_policy_per_agent,
585            ))
586            .build();
587        // When `Blueprint.metadata.project_name_alias` is Some, layer a
588        // `ProjectNameAliasMiddleware` on top of the stack that injects the
589        // alias into `Ctx.meta.runtime.project_name_alias` just before spawn.
590        // Downstream operators (for example, the server crate's
591        // `Operator.execute`) read `ctx.meta.runtime.get("project_name_alias")`
592        // and expand it into the Spawn directive prompt body.
593        let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
594            SpawnerStack::new(spawner)
595                .layer(ProjectNameAliasMiddleware::new(alias))
596                .build()
597        } else {
598            spawner
599        };
600        // Layer the Blueprint-baked worker bindings (same ctx.meta.runtime
601        // inject shape as the alias layer above) so the delegate axis can
602        // resolve per-agent variants — see `derive_worker_bindings`.
603        let worker_bindings = derive_worker_bindings(&input.blueprint);
604        let spawner = if worker_bindings.is_empty() {
605            spawner
606        } else {
607            SpawnerStack::new(spawner)
608                .layer(WorkerBindingMiddleware::new(worker_bindings))
609                .build()
610        };
611        // GH #34: Blueprint-declared after-run audit hooks — same
612        // conditional-layering shape as the alias / worker-binding blocks
613        // above. Empty `Blueprint.audits` (every pre-#34 Blueprint) means
614        // no layer at all (invariant #4: byte-identical behavior). The
615        // router handle handed to `AfterRunAuditMiddleware` is
616        // `compiled.router` — the raw name→adapter table `Compiler::compile`
617        // built (NOT this progressively-wrapped `spawner`) — so an audit
618        // agent's own dispatch never re-enters this same layer (see
619        // `AfterRunAuditMiddleware`'s module doc, Recursion guard section).
620        let audit_defs = derive_audits(&input.blueprint);
621        let spawner = if audit_defs.is_empty() {
622            spawner
623        } else {
624            SpawnerStack::new(spawner)
625                .layer(AfterRunAuditMiddleware::new(
626                    audit_defs,
627                    compiled.router.clone(),
628                ))
629                .build()
630        };
631
632        // Task-level execution context (`project_root` / `work_dir` /
633        // `task_metadata`) — same conditional-layering shape as the alias /
634        // worker-binding blocks above. Issue #19 ST2: read directly off
635        // `input.task_input` (already resolved by the caller) instead of
636        // extracting it back out of `input.init_ctx` — `init_ctx` is a pure
637        // flow-ir eval seed now, never folded with these keys.
638        let spawner = match input.task_input.as_ref().and_then(|spec| {
639            TaskInputMiddleware::new_from_fields(
640                spec.project_root.clone(),
641                spec.work_dir.clone(),
642                spec.task_metadata.clone(),
643            )
644        }) {
645            Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
646            None => spawner,
647        };
648
649        // "BP Agent-level" (`OperatorDef.kind` via `operator_ref`) + "BP
650        // Global" (`Blueprint.default_operator_kind`) tiers of the
651        // `OperatorKind` cascade, baked here (the only point that has both
652        // the resolved Blueprint and the launch-time overrides in scope).
653        let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
654        let bp_global_kind = input
655            .blueprint
656            .default_operator_kind
657            .map(OperatorKind::from);
658
659        let token = self
660            .engine
661            .attach_with_ids(
662                input.operator_id,
663                input.role,
664                input.ttl,
665                input.operator_kind,
666                input.bridge_id,
667                input.hook_id,
668                input.operator_backend_id,
669                input.operator_kind_overrides,
670                bp_agent_kinds,
671                bp_global_kind,
672            )
673            .await?;
674        // Collapse the `check_policy` cascade EXACTLY ONCE
675        // here: `launch request > blueprint > server config` (highest to
676        // lowest priority). `input.check_policy` is the launch-request tier;
677        // `input.blueprint.check_policy` is the Blueprint tier; a `None`
678        // result leaves the engine's submit-time sink to fall back to the
679        // server-wide `EngineCfg.check_policy` (tier 3) on its own — the
680        // engine's existing `task_policy.unwrap_or(server_policy)` resolution
681        // is deliberately NOT duplicated here (no double resolution). The
682        // resolved value is threaded (via `with_check_policy`) into EVERY
683        // spawned step's `TaskSpec`, not just the first.
684        let resolved_check_policy = input.check_policy.or(input.blueprint.check_policy);
685        // Pre-dispatch guard: collapse the same cascade one step further
686        // (adding the server tier, `EngineCfg.check_policy`, via
687        // `self.engine.cfg()`) into a SEPARATE local used only for this
688        // check — `resolved_check_policy` above (the Option stamped onto
689        // every dispatched step's `TaskSpec`) is left untouched, so the
690        // "TaskSpec = None -> engine falls back to server default at the
691        // submit-time sink" contract (cascade test case 4) keeps holding.
692        // When the effective policy is Strict and the launch supplied
693        // neither `project_root` nor `work_dir`, a strict task would
694        // deterministically fail at its first submit-time file
695        // materialize — fail the launch fast instead of dispatching a
696        // step that can only ever hit that wall. `check_policy: "warn"` on
697        // the launch-request tier is the escape hatch (it wins the
698        // cascade before this fallback ever applies).
699        let effective_check_policy =
700            resolved_check_policy.unwrap_or(self.engine.cfg().check_policy);
701        if effective_check_policy == CheckPolicy::Strict {
702            let roots_missing = input
703                .task_input
704                .as_ref()
705                .map(|t| t.project_root.is_none() && t.work_dir.is_none())
706                .unwrap_or(true);
707            if roots_missing {
708                return Err(TaskLaunchError::PreDispatch(
709                    "check_policy=strict requires project_root or work_dir, but the launch \
710                     supplied neither"
711                        .to_string(),
712                ));
713            }
714        }
715        let dispatcher =
716            EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
717        let dispatcher = dispatcher.with_check_policy(resolved_check_policy);
718        let dispatcher = match input.run_ctx {
719            Some(run_ctx) => dispatcher.with_run(run_ctx),
720            None => dispatcher,
721        };
722        // GH #21 Phase 2: attach the Step tier's named `MetaDef` pool.
723        // Unconditional — an empty map (every pre-#21-Phase-2 Blueprint)
724        // is a no-op, matching `EngineDispatcher::with_spawner`'s default.
725        let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
726        // GH #23: attach the `StepNaming` table `Compiler::compile` already
727        // built once for this Blueprint (the sole construction site — see
728        // `core::step_naming::StepNaming::from_blueprint`'s doc).
729        // Unconditional — every compile produces one, undeclared Blueprints
730        // included (canonical falls back to `Step.ref` byte-for-byte).
731        let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
732        // GH #27 (follow-up to #23): attach the `ProjectionPlacement`
733        // resolver `Compiler::compile` already built once for this
734        // Blueprint (the sole construction site — see
735        // `core::projection_placement::ProjectionPlacement::from_spec`'s
736        // doc). Unconditional — every compile produces one, undeclared
737        // Blueprints included (resolves to `ProjectionPlacement::default()`).
738        let dispatcher =
739            dispatcher.with_projection_placement(compiled.projection_placement.clone());
740        // Issue #19 ST3: BP default + Task init_ctx → merged init_ctx (the
741        // 2-layer slice of the eventual 4-layer cascade; Run override is
742        // ST4 carry). `input.blueprint.default_init_ctx` is `None` for
743        // every pre-#19 Blueprint, so `merge_init_ctx` is a no-op then and
744        // this preserves today's behavior byte-for-byte.
745        let merged_init_ctx =
746            merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
747        let final_ctx = mlua_flow_ir::eval_async_externs(
748            &input.blueprint.flow,
749            merged_init_ctx,
750            &dispatcher,
751            &*self.externs,
752        )
753        .await
754        .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
755        Ok(TaskLaunchOutput { token, final_ctx })
756    }
757}
758
759// ──────────────────────────────────────────────────────────────────────────
760// UT
761// ──────────────────────────────────────────────────────────────────────────
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
767    use crate::blueprint::{
768        current_schema_version, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
769        BlueprintMetadata, CompilerHints, CompilerStrategy, MetaDef, Runner,
770    };
771    use crate::core::config::EngineCfg;
772    use crate::worker::adapter::{WorkerError, WorkerResult};
773    use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
774    use serde_json::json;
775    use std::sync::Arc;
776
777    fn path(s: &str) -> Expr {
778        Expr::Path {
779            at: s.parse().expect("literal test path"),
780        }
781    }
782    fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
783        FlowNode::Step {
784            ref_: ref_.to_string(),
785            in_,
786            out,
787        }
788    }
789
790    fn agent(name: &str, fn_id: &str) -> AgentDef {
791        AgentDef {
792            name: name.to_string(),
793            kind: AgentKind::RustFn,
794            spec: json!({ "fn_id": fn_id }),
795            profile: None,
796            meta: Some(AgentMeta::default()),
797            runner: None,
798            runner_ref: None,
799            verdict: None,
800        }
801    }
802
803    fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
804        let engine = Engine::new(EngineCfg::default());
805        let mut reg = SpawnerRegistry::new();
806        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
807        let compiler = Compiler::new(reg);
808        TaskLaunchService::new(engine, compiler)
809    }
810
811    /// Same as [`build_service`] but with a caller-supplied [`EngineCfg`] —
812    /// used by the pre-dispatch guard's server-tier test (T4), which needs
813    /// a non-default `EngineCfg.check_policy`.
814    fn build_service_with_cfg(
815        factory: RustFnInProcessSpawnerFactory,
816        cfg: EngineCfg,
817    ) -> TaskLaunchService {
818        let engine = Engine::new(cfg);
819        let mut reg = SpawnerRegistry::new();
820        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
821        let compiler = Compiler::new(reg);
822        TaskLaunchService::new(engine, compiler)
823    }
824
825    fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
826        Blueprint {
827            schema_version: current_schema_version(),
828            id: "ut".into(),
829            flow,
830            agents,
831            operators: vec![],
832            metas: vec![],
833            hints: CompilerHints::default(),
834            strategy: CompilerStrategy::default(),
835            metadata: BlueprintMetadata::default(),
836            spawner_hints: Default::default(),
837            default_agent_kind: AgentKind::Operator,
838            default_operator_kind: None,
839            default_init_ctx: None,
840            default_agent_ctx: None,
841            default_context_policy: None,
842            projection_placement: None,
843            audits: vec![],
844            degradation_policy: None,
845            runners: vec![],
846            default_runner: None,
847            check_policy: None,
848            blueprint_ref_includes: Vec::new(),
849        }
850    }
851
852    fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
853        TaskLaunchInput::automate(
854            blueprint,
855            "ut-op",
856            Role::Operator,
857            Duration::from_secs(30),
858            init_ctx,
859        )
860    }
861
862    // ──────────────────────────────────────────────────────────────
863    // GH #34: `derive_audits` + the conditional `AfterRunAuditMiddleware`
864    // `.layer(...)` wiring in `TaskLaunchService::launch`
865    // ──────────────────────────────────────────────────────────────
866
867    #[test]
868    fn derive_audits_empty_by_default() {
869        let blueprint = bp(
870            step("echo", path("$.input"), path("$.out")),
871            vec![agent("echo", "echo")],
872        );
873        assert!(
874            derive_audits(&blueprint).is_empty(),
875            "audits_absent_no_layer: an undeclared audits Vec must stay empty"
876        );
877    }
878
879    #[test]
880    fn derive_audits_returns_blueprint_audits_verbatim() {
881        let mut blueprint = bp(
882            step("echo", path("$.input"), path("$.out")),
883            vec![agent("echo", "echo")],
884        );
885        blueprint.audits = vec![crate::blueprint::AuditDef {
886            agent: "auditor".to_string(),
887            steps: None,
888            mode: crate::blueprint::AuditMode::Async,
889        }];
890        let got = derive_audits(&blueprint);
891        assert_eq!(got.len(), 1);
892        assert_eq!(got[0].agent, "auditor");
893    }
894
895    #[tokio::test]
896    async fn launch_appends_audit_artifact_when_audits_declared() {
897        use crate::blueprint::{AuditDef, AuditMode};
898
899        let factory = RustFnInProcessSpawnerFactory::new()
900            .register_fn("echo", |inv| async move {
901                Ok(WorkerResult {
902                    value: json!({ "echoed": inv.prompt }),
903                    ok: true,
904                })
905            })
906            .register_fn("audit-fn", |_inv| async move {
907                Ok(WorkerResult {
908                    value: json!({ "finding": "clean" }),
909                    ok: true,
910                })
911            });
912        let svc = build_service(factory);
913        let mut blueprint = bp(
914            step("echo", path("$.input"), path("$.out")),
915            vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
916        );
917        blueprint.audits = vec![AuditDef {
918            agent: "auditor".to_string(),
919            steps: None,
920            mode: AuditMode::Sync,
921        }];
922        let out = svc
923            .launch(launch_input(blueprint, json!({ "input": "hi" })))
924            .await
925            .expect("launch ok — audits must never alter the audited step's outcome");
926        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
927
928        let audited_task_id = svc
929            .engine()
930            .with_state("test.find_audited_task", |s| {
931                s.tasks
932                    .iter()
933                    .find(|(_, t)| t.spec.agent == "echo")
934                    .map(|(id, _)| id.clone())
935            })
936            .await
937            .expect("with_state")
938            .expect("the echo task must exist");
939        let tail = svc.engine().output_tail(&audited_task_id, 1).await;
940        let found = tail.iter().any(|ev| {
941            matches!(
942                ev,
943                crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
944            )
945        });
946        assert!(
947            found,
948            "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
949        );
950    }
951
952    #[tokio::test]
953    async fn launch_single_step_writes_out_path() {
954        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
955            Ok(WorkerResult {
956                value: json!({ "echoed": inv.prompt }),
957                ok: true,
958            })
959        });
960        let svc = build_service(factory);
961        let blueprint = bp(
962            step("echo", path("$.input"), path("$.out")),
963            vec![agent("echo", "echo")],
964        );
965        let out = svc
966            .launch(launch_input(blueprint, json!({ "input": "hi" })))
967            .await
968            .expect("launch ok");
969        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
970    }
971
972    // ──────────────────────────────────────────────────────────────
973    // check_policy cascade (launch > blueprint > server)
974    // T2 (cascade 4-case) / T3 (end-to-end strict) / T4 (backward compat)
975    // ──────────────────────────────────────────────────────────────
976
977    /// Launch a single-echo Blueprint with the given launch- and
978    /// Blueprint-tier `check_policy`, then read back the `check_policy` that
979    /// the dispatcher stamped onto the dispatched step's `TaskSpec`. The
980    /// launch may complete (Silent / Warn / None → fail-open) — the in-process
981    /// RustFn worker fire-and-forgets its submit — so the task and its
982    /// resolved spec exist regardless of the launch outcome.
983    ///
984    /// `task_input` carries a `work_dir` unconditionally (a dummy path, not
985    /// resolved on disk) so the pre-dispatch guard (a strict effective
986    /// policy with no roots supplied rejects before dispatch) never fires
987    /// here — this helper's whole point is "reach dispatch and read back
988    /// the stamp", so every case (including the two whose
989    /// `bp_policy`/`launch_policy` alone resolve to Strict) must dispatch
990    /// uniformly. The guard's own rejection behavior is proven separately
991    /// (T3/T4 and `strict_blueprint_without_roots_is_rejected_pre_dispatch`).
992    async fn dispatched_check_policy(
993        launch_policy: Option<CheckPolicy>,
994        bp_policy: Option<CheckPolicy>,
995    ) -> Option<CheckPolicy> {
996        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
997            Ok(WorkerResult {
998                value: json!({ "echoed": inv.prompt }),
999                ok: true,
1000            })
1001        });
1002        let svc = build_service(factory);
1003        let mut blueprint = bp(
1004            step("echo", path("$.input"), path("$.out")),
1005            vec![agent("echo", "echo")],
1006        );
1007        blueprint.check_policy = bp_policy;
1008        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1009        input.check_policy = launch_policy;
1010        input.task_input = Some(TaskInputSpec {
1011            project_root: None,
1012            work_dir: Some("/dispatched-check-policy-test-root".to_string()),
1013            task_metadata: None,
1014        });
1015        let _ = svc.launch(input).await;
1016        svc.engine()
1017            .with_state("test.read_dispatched_check_policy", |s| {
1018                s.tasks
1019                    .values()
1020                    .find(|t| t.spec.agent == "echo")
1021                    .and_then(|t| t.spec.check_policy)
1022            })
1023            .await
1024            .expect("with_state")
1025    }
1026
1027    /// T2 case 1: launch `Some(Silent)` + BP `Some(Strict)` → TaskSpec
1028    /// `Some(Silent)` (the launch-request tier outranks the Blueprint tier).
1029    #[tokio::test]
1030    async fn cascade_launch_tier_wins_over_blueprint_tier() {
1031        assert_eq!(
1032            dispatched_check_policy(Some(CheckPolicy::Silent), Some(CheckPolicy::Strict)).await,
1033            Some(CheckPolicy::Silent),
1034        );
1035    }
1036
1037    /// T2 case 2: launch `None` + BP `Some(Strict)` → TaskSpec `Some(Strict)`
1038    /// (the Blueprint tier takes effect when the launch tier is unset).
1039    #[tokio::test]
1040    async fn cascade_blueprint_tier_used_when_launch_absent() {
1041        assert_eq!(
1042            dispatched_check_policy(None, Some(CheckPolicy::Strict)).await,
1043            Some(CheckPolicy::Strict),
1044        );
1045    }
1046
1047    /// T2 case 3: launch `Some(Strict)` + BP `None` → TaskSpec `Some(Strict)`
1048    /// (the launch tier alone resolves when the Blueprint tier is unset).
1049    #[tokio::test]
1050    async fn cascade_launch_tier_alone_when_blueprint_absent() {
1051        assert_eq!(
1052            dispatched_check_policy(Some(CheckPolicy::Strict), None).await,
1053            Some(CheckPolicy::Strict),
1054        );
1055    }
1056
1057    /// T2 case 4: launch `None` + BP `None` → TaskSpec `None`. NOT omitted as
1058    /// "trivial": this is the backward-compat proof — the server-fallback
1059    /// path (`EngineCfg.check_policy` decides at the submit-time sink) is
1060    /// preserved byte-for-byte because the carrier stays `None`.
1061    #[tokio::test]
1062    async fn cascade_both_none_preserves_server_fallback() {
1063        assert_eq!(dispatched_check_policy(None, None).await, None);
1064    }
1065
1066    /// Repurposed 2026-07-16 for the pre-dispatch guard's new contract
1067    /// (the launch-time validation stage of the check_policy cascade
1068    /// work). This test used
1069    /// to prove a strict + no-roots launch dispatched a step that then hit
1070    /// `EngineError::CheckPolicyStrict` at submit time — exactly the path
1071    /// the pre-dispatch guard now forecloses (a strict launch with no
1072    /// resolvable root is rejected BEFORE dispatch instead, see
1073    /// [`TaskLaunchService::launch`]'s guard). The two sub-assertions this
1074    /// test used to make are independently covered elsewhere: the
1075    /// cascade-resolved Strict reaching the dispatched `TaskSpec` is
1076    /// covered by the `cascade_*` tests above; the submit-time sink
1077    /// surfacing `CheckPolicyStrict` on an unresolved root is covered by
1078    /// `crate::core::engine::tests::submit_output_final_check_policy_strict_surfaces_error_when_root_unresolved`
1079    /// (seeds the task directly at the engine layer, bypassing `launch`).
1080    /// This test now asserts the NEW contract directly.
1081    #[tokio::test]
1082    async fn strict_blueprint_without_roots_is_rejected_pre_dispatch() {
1083        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1084            Ok(WorkerResult {
1085                value: json!({ "echoed": inv.prompt }),
1086                ok: true,
1087            })
1088        });
1089        let svc = build_service(factory);
1090        let mut blueprint = bp(
1091            step("echo", path("$.input"), path("$.out")),
1092            vec![agent("echo", "echo")],
1093        );
1094        blueprint.check_policy = Some(CheckPolicy::Strict);
1095        // No task_input → no work_dir/project_root ever resolves.
1096        let err = svc
1097            .launch(launch_input(blueprint, json!({ "input": "hi" })))
1098            .await
1099            .expect_err("strict check_policy + no roots must be rejected before dispatch");
1100        match err {
1101            TaskLaunchError::PreDispatch(message) => {
1102                assert!(
1103                    message.contains("strict"),
1104                    "message must identify the strict-requires-roots condition: {message}"
1105                );
1106            }
1107            other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1108        }
1109
1110        // No step was ever dispatched — the guard fires after
1111        // `engine.attach_with_ids` (the token mint) but before the
1112        // dispatcher is ever built / `eval_async_externs` runs.
1113        let dispatched = svc
1114            .engine()
1115            .with_state("test.no_echo_task_dispatched", |s| {
1116                s.tasks.values().any(|t| t.spec.agent == "echo")
1117            })
1118            .await
1119            .expect("with_state");
1120        assert!(
1121            !dispatched,
1122            "the pre-dispatch guard must reject before any step is dispatched"
1123        );
1124    }
1125
1126    /// T4 (cascade backward-compat): backward compat — with NO check_policy
1127    /// anywhere (BP tier + launch tier both `None`), the launch resolves to
1128    /// the server default (Warn) and completes fail-open exactly as before
1129    /// this change (the warn-mode materialize skip never turns a
1130    /// successful submit into a failure).
1131    ///
1132    /// This is ALSO the pre-dispatch guard's backward-compat case (T5):
1133    /// `task_input` is `None` via [`launch_input`]/[`TaskLaunchInput::automate`],
1134    /// so the guard's effective policy resolves to `Warn` (server default,
1135    /// [`EngineCfg::default`]) and never fires — the guard changes nothing
1136    /// about this pre-existing default-path behavior.
1137    #[tokio::test]
1138    async fn launch_without_any_check_policy_completes_fail_open() {
1139        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1140            Ok(WorkerResult {
1141                value: json!({ "echoed": inv.prompt }),
1142                ok: true,
1143            })
1144        });
1145        let svc = build_service(factory);
1146        let blueprint = bp(
1147            step("echo", path("$.input"), path("$.out")),
1148            vec![agent("echo", "echo")],
1149        );
1150        assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1151        let out = svc
1152            .launch(launch_input(blueprint, json!({ "input": "hi" })))
1153            .await
1154            .expect("warn-mode fail-open must let the launch complete");
1155        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1156    }
1157
1158    // ──────────────────────────────────────────────────────────────────
1159    // pre-dispatch validation guard:
1160    // `TaskLaunchService::launch` rejects BEFORE dispatch when the
1161    // effective check_policy is Strict and neither `project_root` nor
1162    // `work_dir` is supplied. T3/T4/T6 live here (T1/T2 are
1163    // handler-level, in `mlua-swarm-server`'s `projection.rs`; T5 is the
1164    // `launch_without_any_check_policy_completes_fail_open` test above;
1165    // the guard-rejection end-to-end case is
1166    // `strict_blueprint_without_roots_is_rejected_pre_dispatch` above,
1167    // Option A's repurpose of the former stage-1 T3).
1168    // ──────────────────────────────────────────────────────────────────
1169
1170    /// T3 (Crux 3, escape hatch): a Blueprint declaring `check_policy:
1171    /// strict` is overridden by the launch-request tier's `check_policy:
1172    /// Some(Warn)` — tier 1 wins the cascade before the guard's
1173    /// effective-policy fallback ever applies, so the guard passes and the
1174    /// launch dispatches normally even though `task_input` is `None` (no
1175    /// project_root/work_dir at all). Regression guard against a future
1176    /// "the guard judges by the BP tier alone, not the effective/cascaded
1177    /// value" narrowing.
1178    #[tokio::test]
1179    async fn strict_blueprint_with_launch_warn_override_bypasses_pre_dispatch_guard() {
1180        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1181            Ok(WorkerResult {
1182                value: json!({ "echoed": inv.prompt }),
1183                ok: true,
1184            })
1185        });
1186        let svc = build_service(factory);
1187        let mut blueprint = bp(
1188            step("echo", path("$.input"), path("$.out")),
1189            vec![agent("echo", "echo")],
1190        );
1191        blueprint.check_policy = Some(CheckPolicy::Strict);
1192        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1193        input.check_policy = Some(CheckPolicy::Warn);
1194        assert!(input.task_input.is_none(), "no roots supplied at all");
1195        let out = svc
1196            .launch(input)
1197            .await
1198            .expect("launch-tier warn override must bypass the pre-dispatch guard");
1199        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1200    }
1201
1202    /// T4 (Crux 2, server tier): with BOTH the launch- and Blueprint-tier
1203    /// `check_policy` unset, the server-wide `EngineCfg.check_policy` (the
1204    /// third cascade tier, read via `self.engine.cfg()`) alone must drive
1205    /// the guard — proof the guard does not stop at the "BP/launch 2-tier"
1206    /// shortcut Crux 2 forbids.
1207    #[tokio::test]
1208    async fn server_tier_strict_alone_triggers_pre_dispatch_guard() {
1209        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1210            Ok(WorkerResult {
1211                value: json!({ "echoed": inv.prompt }),
1212                ok: true,
1213            })
1214        });
1215        let svc = build_service_with_cfg(
1216            factory,
1217            EngineCfg {
1218                check_policy: CheckPolicy::Strict,
1219                ..EngineCfg::default()
1220            },
1221        );
1222        let blueprint = bp(
1223            step("echo", path("$.input"), path("$.out")),
1224            vec![agent("echo", "echo")],
1225        );
1226        assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1227        let input = launch_input(blueprint, json!({ "input": "hi" }));
1228        assert!(input.check_policy.is_none(), "launch tier must be unset");
1229        assert!(input.task_input.is_none(), "no roots supplied");
1230        let err = svc.launch(input).await.expect_err(
1231            "server-tier Strict alone (BP/launch tiers both unset) must trigger the guard",
1232        );
1233        match err {
1234            TaskLaunchError::PreDispatch(message) => {
1235                assert!(
1236                    message.contains("strict"),
1237                    "expected the strict-requires-roots message, got: {message}"
1238                );
1239            }
1240            other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1241        }
1242    }
1243
1244    /// T6 (guard condition, branch 2 of 3): `task_input: Some(_)` with
1245    /// BOTH `project_root` and `work_dir` absent is still `roots_missing`
1246    /// — the outer `Some` alone must not short-circuit the check (branch 1,
1247    /// `task_input: None`, is covered by
1248    /// `strict_blueprint_without_roots_is_rejected_pre_dispatch` above).
1249    #[tokio::test]
1250    async fn pre_dispatch_guard_rejects_when_task_input_present_but_roots_both_none() {
1251        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1252            Ok(WorkerResult {
1253                value: json!({ "echoed": inv.prompt }),
1254                ok: true,
1255            })
1256        });
1257        let svc = build_service(factory);
1258        let mut blueprint = bp(
1259            step("echo", path("$.input"), path("$.out")),
1260            vec![agent("echo", "echo")],
1261        );
1262        blueprint.check_policy = Some(CheckPolicy::Strict);
1263        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1264        input.task_input = Some(TaskInputSpec {
1265            project_root: None,
1266            work_dir: None,
1267            task_metadata: Some(json!({ "unrelated": true })),
1268        });
1269        let err = svc
1270            .launch(input)
1271            .await
1272            .expect_err("Some(TaskInputSpec) with both roots None must still be roots_missing");
1273        assert!(
1274            matches!(err, TaskLaunchError::PreDispatch(_)),
1275            "expected TaskLaunchError::PreDispatch, got {err:?}"
1276        );
1277    }
1278
1279    /// T6 (guard condition, branch 3 of 3): `work_dir: Some(_)` alone
1280    /// (with `project_root: None`) is NOT `roots_missing` — either root
1281    /// being present is sufficient, so the guard passes and the launch
1282    /// dispatches normally.
1283    #[tokio::test]
1284    async fn pre_dispatch_guard_passes_when_work_dir_present_and_project_root_absent() {
1285        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1286            Ok(WorkerResult {
1287                value: json!({ "echoed": inv.prompt }),
1288                ok: true,
1289            })
1290        });
1291        let svc = build_service(factory);
1292        let mut blueprint = bp(
1293            step("echo", path("$.input"), path("$.out")),
1294            vec![agent("echo", "echo")],
1295        );
1296        blueprint.check_policy = Some(CheckPolicy::Strict);
1297        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1298        input.task_input = Some(TaskInputSpec {
1299            project_root: None,
1300            work_dir: Some("/repo/work".to_string()),
1301            task_metadata: None,
1302        });
1303        let out = svc
1304            .launch(input)
1305            .await
1306            .expect("work_dir alone must satisfy the guard's roots_missing check");
1307        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1308    }
1309
1310    #[tokio::test]
1311    async fn launch_three_step_seq_threads_ctx_forward() {
1312        let factory = RustFnInProcessSpawnerFactory::new()
1313            .register_fn("upper", |inv| async move {
1314                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1315                Ok(WorkerResult {
1316                    value: json!(s.to_uppercase()),
1317                    ok: true,
1318                })
1319            })
1320            .register_fn("suffix", |inv| async move {
1321                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1322                Ok(WorkerResult {
1323                    value: json!(format!("{s}!")),
1324                    ok: true,
1325                })
1326            })
1327            .register_fn("wrap", |inv| async move {
1328                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1329                Ok(WorkerResult {
1330                    value: json!(format!("[{s}]")),
1331                    ok: true,
1332                })
1333            });
1334        let svc = build_service(factory);
1335        let flow = FlowNode::Seq {
1336            children: vec![
1337                step("upper", path("$.in"), path("$.s1")),
1338                step("suffix", path("$.s1"), path("$.s2")),
1339                step("wrap", path("$.s2"), path("$.s3")),
1340            ],
1341        };
1342        let blueprint = bp(
1343            flow,
1344            vec![
1345                agent("upper", "upper"),
1346                agent("suffix", "suffix"),
1347                agent("wrap", "wrap"),
1348            ],
1349        );
1350        let out = svc
1351            .launch(launch_input(blueprint, json!({ "in": "hello" })))
1352            .await
1353            .expect("launch ok");
1354        assert_eq!(out.final_ctx["s1"], "HELLO");
1355        assert_eq!(out.final_ctx["s2"], "HELLO!");
1356        assert_eq!(out.final_ctx["s3"], "[HELLO!]");
1357    }
1358
1359    #[tokio::test]
1360    async fn launch_fanout_join_all_parallel_completes() {
1361        use std::sync::atomic::{AtomicU32, Ordering};
1362        let counter = Arc::new(AtomicU32::new(0));
1363        let max_seen = Arc::new(AtomicU32::new(0));
1364        let counter_clone = counter.clone();
1365        let max_clone = max_seen.clone();
1366
1367        // Each worker bumps the inflight counter up, sleeps 50ms, then bumps it down.
1368        // When parallel execution is working, max inflight exceeds 1.
1369        let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
1370            let counter = counter_clone.clone();
1371            let max_seen = max_clone.clone();
1372            async move {
1373                let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
1374                let mut prev = max_seen.load(Ordering::SeqCst);
1375                while now > prev {
1376                    match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
1377                        Ok(_) => break,
1378                        Err(p) => prev = p,
1379                    }
1380                }
1381                tokio::time::sleep(Duration::from_millis(50)).await;
1382                counter.fetch_sub(1, Ordering::SeqCst);
1383                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1384                Ok(WorkerResult {
1385                    value: json!(format!("did:{s}")),
1386                    ok: true,
1387                })
1388            }
1389        });
1390        let svc = build_service(factory);
1391        let flow = FlowNode::Fanout {
1392            items: path("$.items"),
1393            bind: path("$.item"),
1394            body: Box::new(step("para", path("$.item"), path("$.r"))),
1395            join: JoinMode::All,
1396            out: path("$.results"),
1397        };
1398        let blueprint = bp(flow, vec![agent("para", "para")]);
1399        let out = svc
1400            .launch(launch_input(
1401                blueprint,
1402                json!({ "items": ["a", "b", "c", "d"] }),
1403            ))
1404            .await
1405            .expect("launch ok");
1406        let results = out.final_ctx["results"].as_array().expect("array");
1407        assert_eq!(results.len(), 4);
1408        for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
1409            assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
1410        }
1411        let max = max_seen.load(Ordering::SeqCst);
1412        assert!(
1413            max >= 2,
1414            "expected parallel execution (max inflight >= 2), got {max}"
1415        );
1416    }
1417
1418    #[tokio::test]
1419    async fn launch_propagates_worker_error_as_flow_eval_err() {
1420        let factory = RustFnInProcessSpawnerFactory::new()
1421            .register_fn("ok", |inv| async move {
1422                Ok(WorkerResult {
1423                    value: json!(inv.prompt),
1424                    ok: true,
1425                })
1426            })
1427            .register_fn("boom", |_inv| async move {
1428                Err(WorkerError::Failed("intentional boom".into()))
1429            });
1430        let svc = build_service(factory);
1431        let flow = FlowNode::Seq {
1432            children: vec![
1433                step("ok", path("$.input"), path("$.s1")),
1434                step("boom", path("$.s1"), path("$.s2")),
1435                step("ok", path("$.s2"), path("$.s3")),
1436            ],
1437        };
1438        let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
1439        let err = svc
1440            .launch(launch_input(blueprint, json!({ "input": "x" })))
1441            .await
1442            .expect_err("expected fail");
1443        match err {
1444            TaskLaunchError::FlowEval(msg) => {
1445                assert!(
1446                    msg.contains("boom") || msg.contains("intentional"),
1447                    "expected error to mention worker failure, got: {msg}"
1448                );
1449            }
1450            other => panic!("expected FlowEval error, got {other:?}"),
1451        }
1452    }
1453
1454    #[tokio::test]
1455    async fn launch_resolves_call_extern_via_registered_externs() {
1456        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1457            Ok(WorkerResult {
1458                value: json!({ "echoed": inv.prompt }),
1459                ok: true,
1460            })
1461        });
1462        let mut externs = mlua_flow_ir::ExternMap::new();
1463        externs.register("fmt.greet", |args: &[Value]| {
1464            let name = args[0].as_str().unwrap_or("?");
1465            Ok(json!(format!("hello, {name}")))
1466        });
1467        let svc = build_service(factory).with_externs(Arc::new(externs));
1468        let flow = step(
1469            "echo",
1470            Expr::CallExtern {
1471                ref_: "fmt.greet".into(),
1472                args: vec![path("$.who")],
1473            },
1474            path("$.out"),
1475        );
1476        let blueprint = bp(flow, vec![agent("echo", "echo")]);
1477        let out = svc
1478            .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1479            .await
1480            .expect("launch ok");
1481        assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1482    }
1483
1484    #[tokio::test]
1485    async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1486        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1487            Ok(WorkerResult {
1488                value: json!(inv.prompt),
1489                ok: true,
1490            })
1491        });
1492        let svc = build_service(factory); // default NoExterns
1493        let flow = step(
1494            "echo",
1495            Expr::CallExtern {
1496                ref_: "fmt.greet".into(),
1497                args: vec![],
1498            },
1499            path("$.out"),
1500        );
1501        let blueprint = bp(flow, vec![agent("echo", "echo")]);
1502        let err = svc
1503            .launch(launch_input(blueprint, json!({})))
1504            .await
1505            .expect_err("expected fail");
1506        match err {
1507            TaskLaunchError::FlowEval(msg) => {
1508                assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1509            }
1510            other => panic!("expected FlowEval error, got {other:?}"),
1511        }
1512    }
1513
1514    // ──────────────────────────────────────────────────────────────────
1515    // GH #50 (Subtask 2 follow-up): `TaskLaunchService::launch`'s
1516    // `compiler.compile` → `engine.register_verdict_contracts(...)` call
1517    // site — task_launch-level end-to-end (compile → register →
1518    // `Engine::verdict_contract_for_task` resolves it). The full HTTP
1519    // submit-time-422 round trip is covered separately: handler-level in
1520    // `crates/mlua-swarm-server/src/worker.rs`'s own `#[cfg(test)] mod
1521    // tests` GH #50 section (which seeds `Engine::register_verdict_contracts`
1522    // directly, bypassing this launch path since `mlua-swarm-server`
1523    // cannot depend on this crate's private test helpers) and
1524    // process-boundary-HTTP in
1525    // `crates/mlua-swarm-server/tests/verdict_contract.rs`. This test is
1526    // the missing link between those two: it exercises the REAL
1527    // `TaskLaunchService::launch` call site (not a hand-rolled duplicate
1528    // of its two lines) end-to-end through a real `Compiler::compile`,
1529    // proving the production wiring this follow-up added actually
1530    // populates the registry `Engine::verdict_contract_for_task` reads.
1531    // ──────────────────────────────────────────────────────────────────
1532
1533    #[tokio::test]
1534    async fn launch_registers_the_blueprints_verdict_contracts_into_the_engine() {
1535        let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |inv| async move {
1536            Ok(WorkerResult {
1537                value: json!(inv.prompt),
1538                ok: true,
1539            })
1540        });
1541        let svc = build_service(factory);
1542        let mut gate_agent = agent("gate", "gate");
1543        gate_agent.verdict = Some(mlua_swarm_schema::VerdictContract {
1544            channel: mlua_swarm_schema::VerdictChannel::Body,
1545            values: vec!["PASS".to_string(), "BLOCKED".to_string()],
1546        });
1547        let flow = step("gate", path("$.input"), path("$.out"));
1548        let blueprint = bp(flow, vec![gate_agent]);
1549
1550        let out = svc
1551            .launch(launch_input(blueprint, json!({ "input": "PASS" })))
1552            .await
1553            .expect("launch ok");
1554        assert_eq!(out.final_ctx["out"], json!("PASS"));
1555
1556        // `EngineDispatcher::dispatch` calls `engine.start_task` for every
1557        // dispatched Step (`TaskSpec.agent = ref_`) — this single-Step
1558        // Blueprint against a fresh per-test `Engine` (`build_service`)
1559        // leaves exactly one entry in `EngineState.tasks`.
1560        let task_id = svc
1561            .engine()
1562            .with_state("test.find_dispatched_task_id", |s| {
1563                s.tasks.keys().next().cloned()
1564            })
1565            .await
1566            .expect("with_state")
1567            .expect("launch must have dispatched exactly one Step (one TaskState)");
1568
1569        let contract = svc
1570            .engine()
1571            .verdict_contract_for_task(&task_id)
1572            .await
1573            .expect(
1574                "TaskLaunchService::launch must have merged this Blueprint's compiled \
1575                 verdict_contracts into the engine's runtime registry \
1576                 (Engine::register_verdict_contracts, called right after \
1577                 compiler.compile succeeds) — verdict_contract_for_task resolving None \
1578                 here means that production wiring regressed",
1579            );
1580        assert_eq!(contract.channel, mlua_swarm_schema::VerdictChannel::Body);
1581        assert_eq!(
1582            contract.values,
1583            vec!["PASS".to_string(), "BLOCKED".to_string()]
1584        );
1585    }
1586
1587    // ──────────────────────────────────────────────────────────────────
1588    // issue #13 run_id propagation (`TaskLaunchInput.run_ctx`)
1589    // ──────────────────────────────────────────────────────────────────
1590
1591    #[tokio::test]
1592    async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
1593        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
1594        use crate::types::{RunId, TaskId};
1595
1596        let factory = RustFnInProcessSpawnerFactory::new()
1597            .register_fn("upper", |inv| async move {
1598                Ok(WorkerResult {
1599                    value: json!(inv.prompt.to_uppercase()),
1600                    ok: true,
1601                })
1602            })
1603            .register_fn("suffix", |inv| async move {
1604                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1605                Ok(WorkerResult {
1606                    value: json!(format!("{s}!")),
1607                    ok: true,
1608                })
1609            });
1610        let svc = build_service(factory);
1611        let flow = FlowNode::Seq {
1612            children: vec![
1613                step("upper", path("$.in"), path("$.s1")),
1614                step("suffix", path("$.s1"), path("$.s2")),
1615            ],
1616        };
1617        let blueprint = bp(
1618            flow,
1619            vec![agent("upper", "upper"), agent("suffix", "suffix")],
1620        );
1621
1622        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1623        let run_id = RunId::new();
1624        run_store
1625            .create(RunRecord {
1626                id: run_id.clone(),
1627                task_id: TaskId::new(),
1628                status: RunStatus::Running,
1629                step_entries: Vec::new(),
1630                degradations: Vec::new(),
1631                operator_sid: None,
1632                result_ref: None,
1633                input_json: None,
1634                created_at: 0,
1635                updated_at: 0,
1636            })
1637            .await
1638            .expect("seed RunRecord");
1639
1640        let mut input = launch_input(blueprint, json!({ "in": "hi" }));
1641        input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
1642
1643        let out = svc.launch(input).await.expect("launch ok");
1644        assert_eq!(out.final_ctx["s2"], "HI!");
1645
1646        let run = run_store.get(&run_id).await.expect("run present");
1647        assert_eq!(
1648            run.step_entries.len(),
1649            2,
1650            "expected one step_entry per dispatched step, got {:?}",
1651            run.step_entries
1652        );
1653        assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
1654        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1655        assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
1656        assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1657    }
1658
1659    #[tokio::test]
1660    async fn launch_without_run_ctx_appends_no_step_entries() {
1661        // `run_ctx: None` (the `automate()` default) must not touch any
1662        // `RunStore` — this is the pre-existing no-tracing behavior, kept
1663        // as a regression guard alongside the `Some` case above.
1664        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1665            Ok(WorkerResult {
1666                value: json!(inv.prompt),
1667                ok: true,
1668            })
1669        });
1670        let svc = build_service(factory);
1671        let blueprint = bp(
1672            step("echo", path("$.input"), path("$.out")),
1673            vec![agent("echo", "echo")],
1674        );
1675        let input = launch_input(blueprint, json!({ "input": "hi" }));
1676        assert!(
1677            input.run_ctx.is_none(),
1678            "automate() defaults run_ctx to None"
1679        );
1680        let out = svc.launch(input).await.expect("launch ok");
1681        assert_eq!(out.final_ctx["out"], "hi");
1682    }
1683
1684    // ──────────────────────────────────────────────────────────────────
1685    // issue #19 ST2: `TaskLaunchInput.task_input` (direct-sibling-read
1686    // replacement for the ST1 `from_init_ctx(&input.init_ctx)` call)
1687    // ──────────────────────────────────────────────────────────────────
1688
1689    #[tokio::test]
1690    async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
1691        // Issue #19 ST2 invariant: `init_ctx` is a pure flow-ir eval seed —
1692        // `task_input` must not be folded into it. Regression guard for the
1693        // ST1 `resolve_task_level_init_ctx` fold-back this subtask removes:
1694        // if it ever crept back in here, `project_root` / `work_dir` /
1695        // `task_metadata` would leak into `final_ctx` as extra top-level
1696        // keys nobody wrote via a `Step.out`.
1697        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1698            Ok(WorkerResult {
1699                value: json!({ "echoed": inv.prompt }),
1700                ok: true,
1701            })
1702        });
1703        let svc = build_service(factory);
1704        let blueprint = bp(
1705            step("echo", path("$.input"), path("$.out")),
1706            vec![agent("echo", "echo")],
1707        );
1708        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1709        input.task_input = Some(TaskInputSpec {
1710            project_root: Some("/repo".to_string()),
1711            work_dir: Some("/repo/work".to_string()),
1712            task_metadata: Some(json!({ "issue": 19 })),
1713        });
1714        let out = svc.launch(input).await.expect("launch ok");
1715        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1716        assert!(
1717            out.final_ctx.get("project_root").is_none(),
1718            "task_input must not be folded into the flow-ir ctx seed, got {:?}",
1719            out.final_ctx
1720        );
1721        assert!(out.final_ctx.get("work_dir").is_none());
1722        assert!(out.final_ctx.get("task_metadata").is_none());
1723    }
1724
1725    #[tokio::test]
1726    async fn launch_with_task_input_none_is_a_no_op() {
1727        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1728            Ok(WorkerResult {
1729                value: json!(inv.prompt),
1730                ok: true,
1731            })
1732        });
1733        let svc = build_service(factory);
1734        let blueprint = bp(
1735            step("echo", path("$.input"), path("$.out")),
1736            vec![agent("echo", "echo")],
1737        );
1738        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1739        assert!(input.task_input.is_none(), "automate() defaults to None");
1740        input.task_input = None;
1741        let out = svc.launch(input).await.expect("launch ok");
1742        assert_eq!(out.final_ctx["out"], "hi");
1743    }
1744
1745    #[tokio::test]
1746    async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
1747        // `Some(TaskInputSpec::default())` — outer Some, all 3 inner fields
1748        // None — must behave identically to `task_input: None` (mirrors
1749        // `TaskInputMiddleware::new_from_fields`'s own no-op contract).
1750        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1751            Ok(WorkerResult {
1752                value: json!(inv.prompt),
1753                ok: true,
1754            })
1755        });
1756        let svc = build_service(factory);
1757        let blueprint = bp(
1758            step("echo", path("$.input"), path("$.out")),
1759            vec![agent("echo", "echo")],
1760        );
1761        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1762        input.task_input = Some(TaskInputSpec::default());
1763        let out = svc.launch(input).await.expect("launch ok");
1764        assert_eq!(out.final_ctx["out"], "hi");
1765    }
1766
1767    // ──────────────────────────────────────────────────────────────────
1768    // issue #19 ST3: `merge_init_ctx` (BP default + Task init_ctx)
1769    // ──────────────────────────────────────────────────────────────────
1770
1771    #[test]
1772    fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
1773        let bp_default = json!({ "seeded": "from-bp" });
1774        let task = json!({});
1775        let merged = merge_init_ctx(Some(&bp_default), &task);
1776        assert_eq!(merged, json!({ "seeded": "from-bp" }));
1777    }
1778
1779    #[test]
1780    fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
1781        let bp_default = json!({});
1782        let task = json!({ "seeded": "from-task" });
1783        let merged = merge_init_ctx(Some(&bp_default), &task);
1784        assert_eq!(merged, json!({ "seeded": "from-task" }));
1785    }
1786
1787    #[test]
1788    fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
1789        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1790        let task = json!({ "a": "task", "c": "task-only" });
1791        let merged = merge_init_ctx(Some(&bp_default), &task);
1792        assert_eq!(
1793            merged,
1794            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1795        );
1796    }
1797
1798    #[test]
1799    fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
1800        let bp_default = json!({ "seeded": "from-bp" });
1801        let task = json!("plain-string-seed");
1802        let merged = merge_init_ctx(Some(&bp_default), &task);
1803        assert_eq!(merged, json!("plain-string-seed"));
1804    }
1805
1806    #[test]
1807    fn merge_init_ctx_no_bp_default_is_a_no_op() {
1808        let task = json!({ "input": "hi" });
1809        let merged = merge_init_ctx(None, &task);
1810        assert_eq!(merged, task);
1811    }
1812
1813    // ──────────────────────────────────────────────────────────────────
1814    // issue #19 ST4: `merge_init_ctx_3layer` (BP default + Task + Run)
1815    // ──────────────────────────────────────────────────────────────────
1816
1817    #[test]
1818    fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
1819        // `run_override: None` must be a pure pass-through of the BP+Task
1820        // merge — this is the `POST /v1/tasks/:id/runs` no-body rekick
1821        // path, which must preserve pre-#19 behavior byte-for-byte.
1822        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1823        let task = json!({ "a": "task", "c": "task-only" });
1824        let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
1825        let two_layer = merge_init_ctx(Some(&bp_default), &task);
1826        assert_eq!(three_layer, two_layer);
1827        assert_eq!(
1828            three_layer,
1829            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1830        );
1831    }
1832
1833    #[test]
1834    fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
1835        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1836        let task = json!({ "a": "task", "c": "task-only" });
1837        let run_override = json!({ "a": "run", "d": "run-only" });
1838        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1839        assert_eq!(
1840            merged,
1841            json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
1842            "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
1843        );
1844    }
1845
1846    #[test]
1847    fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
1848        let bp_default = json!({ "seeded": "from-bp" });
1849        let task = json!({ "seeded": "from-task" });
1850        let run_override = json!("plain-string-run-seed");
1851        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1852        assert_eq!(merged, json!("plain-string-run-seed"));
1853    }
1854
1855    #[test]
1856    fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
1857        let task = json!({ "input": "hi" });
1858        let merged = merge_init_ctx_3layer(None, &task, None);
1859        assert_eq!(merged, task);
1860    }
1861
1862    #[tokio::test]
1863    async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
1864        // End-to-end guard: `Blueprint.default_init_ctx` actually reaches
1865        // `eval_async_externs` — not merely unit-tested in isolation.
1866        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1867            Ok(WorkerResult {
1868                value: json!(inv.prompt),
1869                ok: true,
1870            })
1871        });
1872        let svc = build_service(factory);
1873        let mut blueprint = bp(
1874            step("echo", path("$.greeting"), path("$.out")),
1875            vec![agent("echo", "echo")],
1876        );
1877        blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
1878        // Task supplies an empty object — BP default alone seeds `$.greeting`.
1879        let out = svc
1880            .launch(launch_input(blueprint, json!({})))
1881            .await
1882            .expect("launch ok");
1883        assert_eq!(out.final_ctx["out"], "hello from bp");
1884    }
1885
1886    // ──────────────────────────────────────────────────────────────────
1887    // issue #21 Phase 1: `derive_agent_ctx` / `derive_context_policies`
1888    // ──────────────────────────────────────────────────────────────────
1889
1890    fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
1891        AgentDef {
1892            name: name.to_string(),
1893            kind: AgentKind::RustFn,
1894            spec: json!({ "fn_id": fn_id }),
1895            profile: None,
1896            meta: Some(meta),
1897            runner: None,
1898            runner_ref: None,
1899            verdict: None,
1900        }
1901    }
1902
1903    #[test]
1904    fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
1905        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1906        let (global, per_agent) = derive_agent_ctx(&blueprint);
1907        assert_eq!(global, None);
1908        assert!(per_agent.is_empty());
1909    }
1910
1911    #[test]
1912    fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
1913        let mut blueprint = bp(
1914            step("echo", path("$.in"), path("$.out")),
1915            vec![
1916                agent_with_meta(
1917                    "with-ctx",
1918                    "echo",
1919                    AgentMeta {
1920                        ctx: Some(json!({ "org_conventions": "x" })),
1921                        ..Default::default()
1922                    },
1923                ),
1924                agent("no-ctx", "echo"),
1925            ],
1926        );
1927        blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
1928        let (global, per_agent) = derive_agent_ctx(&blueprint);
1929        assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
1930        assert_eq!(
1931            per_agent.len(),
1932            1,
1933            "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
1934        );
1935        assert_eq!(
1936            per_agent.get("with-ctx"),
1937            Some(&json!({ "org_conventions": "x" }))
1938        );
1939        assert!(!per_agent.contains_key("no-ctx"));
1940    }
1941
1942    #[test]
1943    fn derive_context_policies_empty_blueprint_yields_empty_state() {
1944        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1945        let (default_policy, per_agent) = derive_context_policies(&blueprint);
1946        assert_eq!(default_policy, None);
1947        assert!(per_agent.is_empty());
1948    }
1949
1950    #[test]
1951    fn derive_context_policies_populated_blueprint_yields_correct_maps() {
1952        let mut blueprint = bp(
1953            step("echo", path("$.in"), path("$.out")),
1954            vec![
1955                agent_with_meta(
1956                    "with-policy",
1957                    "echo",
1958                    AgentMeta {
1959                        context_policy: Some(ContextPolicy {
1960                            include: None,
1961                            exclude: vec!["work_dir".to_string()],
1962                            ..Default::default()
1963                        }),
1964                        ..Default::default()
1965                    },
1966                ),
1967                agent("no-policy", "echo"),
1968            ],
1969        );
1970        blueprint.default_context_policy = Some(ContextPolicy {
1971            include: Some(vec!["project_root".to_string()]),
1972            exclude: vec![],
1973            ..Default::default()
1974        });
1975        let (default_policy, per_agent) = derive_context_policies(&blueprint);
1976        assert_eq!(
1977            default_policy,
1978            Some(ContextPolicy {
1979                include: Some(vec!["project_root".to_string()]),
1980                exclude: vec![],
1981                ..Default::default()
1982            })
1983        );
1984        assert_eq!(per_agent.len(), 1);
1985        assert_eq!(
1986            per_agent.get("with-policy"),
1987            Some(&ContextPolicy {
1988                include: None,
1989                exclude: vec!["work_dir".to_string()],
1990                ..Default::default()
1991            })
1992        );
1993        assert!(!per_agent.contains_key("no-policy"));
1994    }
1995
1996    // ──────────────────────────────────────────────────────────────────
1997    // issue #21 Phase 2: `derive_step_metas` / `AgentMeta.meta_ref`
1998    // resolution inside `derive_agent_ctx`
1999    // ──────────────────────────────────────────────────────────────────
2000
2001    #[test]
2002    fn derive_step_metas_empty_blueprint_yields_empty_map() {
2003        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2004        assert!(derive_step_metas(&blueprint).is_empty());
2005    }
2006
2007    #[test]
2008    fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
2009        let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2010        blueprint.metas = vec![
2011            MetaDef {
2012                name: "heavy-scan".to_string(),
2013                ctx: json!({ "work_dir": "/x" }),
2014            },
2015            MetaDef {
2016                name: "light-scan".to_string(),
2017                ctx: json!({ "work_dir": "/y" }),
2018            },
2019        ];
2020        let metas = derive_step_metas(&blueprint);
2021        assert_eq!(metas.len(), 2);
2022        assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
2023        assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
2024    }
2025
2026    #[test]
2027    fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
2028        let mut blueprint = bp(
2029            step("echo", path("$.in"), path("$.out")),
2030            vec![agent_with_meta(
2031                "with-meta-ref",
2032                "echo",
2033                AgentMeta {
2034                    ctx: Some(json!({ "work_dir": "/inline-wins" })),
2035                    meta_ref: Some("shared".to_string()),
2036                    ..Default::default()
2037                },
2038            )],
2039        );
2040        blueprint.metas = vec![MetaDef {
2041            name: "shared".to_string(),
2042            ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
2043        }];
2044        let (_, per_agent) = derive_agent_ctx(&blueprint);
2045        assert_eq!(
2046            per_agent.get("with-meta-ref"),
2047            Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
2048            "inline ctx must win the collided key while pool-only keys survive the merge"
2049        );
2050    }
2051
2052    #[test]
2053    fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
2054        let mut blueprint = bp(
2055            step("echo", path("$.in"), path("$.out")),
2056            vec![agent_with_meta(
2057                "with-meta-ref-only",
2058                "echo",
2059                AgentMeta {
2060                    meta_ref: Some("shared".to_string()),
2061                    ..Default::default()
2062                },
2063            )],
2064        );
2065        blueprint.metas = vec![MetaDef {
2066            name: "shared".to_string(),
2067            ctx: json!({ "work_dir": "/base" }),
2068        }];
2069        let (_, per_agent) = derive_agent_ctx(&blueprint);
2070        assert_eq!(
2071            per_agent.get("with-meta-ref-only"),
2072            Some(&json!({ "work_dir": "/base" }))
2073        );
2074    }
2075
2076    #[test]
2077    fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
2078        let blueprint = bp(
2079            step("echo", path("$.in"), path("$.out")),
2080            vec![agent_with_meta(
2081                "with-unresolved-meta-ref",
2082                "echo",
2083                AgentMeta {
2084                    ctx: Some(json!({ "work_dir": "/inline-only" })),
2085                    meta_ref: Some("missing".to_string()),
2086                    ..Default::default()
2087                },
2088            )],
2089        );
2090        // No `blueprint.metas` entries at all — `meta_ref` unresolved.
2091        let (_, per_agent) = derive_agent_ctx(&blueprint);
2092        assert_eq!(
2093            per_agent.get("with-unresolved-meta-ref"),
2094            Some(&json!({ "work_dir": "/inline-only" })),
2095            "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
2096        );
2097    }
2098
2099    // ──────────────────────────────────────────────────────────────────
2100    // GH #46 Milestone 2 Done Criteria #3 (semantics-match): `resolve_runner`
2101    // ──────────────────────────────────────────────────────────────────
2102
2103    /// `resolve_runner` (in `mlua-swarm-schema`) must synthesize the exact
2104    /// same `(variant, tools)` pair `derive_worker_bindings` does today for
2105    /// every agent whose Runner comes solely from the legacy
2106    /// `AgentProfile.worker_binding` fallback (tier 3 of the cascade) — a
2107    /// machine-checked guard against the two paths silently drifting apart
2108    /// once a future change touches one but forgets the other, mirroring
2109    /// `crate::core::explain`'s
2110    /// `explain_agent_ctx_matches_derive_agent_ctx_semantics` drift guard.
2111    /// This is a read-only cross-check: it exercises the schema crate's
2112    /// pure resolver against real Blueprints, without touching the launch
2113    /// path itself (Milestone 3 scope).
2114    #[test]
2115    fn resolve_runner_legacy_fallback_matches_derive_worker_bindings_semantics() {
2116        fn legacy_agent(name: &str, variant: &str, tools: Vec<&str>) -> AgentDef {
2117            AgentDef {
2118                name: name.to_string(),
2119                kind: AgentKind::Operator,
2120                spec: json!({}),
2121                profile: Some(AgentProfile {
2122                    worker_binding: Some(variant.to_string()),
2123                    tools: tools.into_iter().map(str::to_string).collect(),
2124                    ..Default::default()
2125                }),
2126                meta: None,
2127                runner: None,
2128                runner_ref: None,
2129                verdict: None,
2130            }
2131        }
2132
2133        let blueprint = bp(
2134            step("planner", path("$.in"), path("$.out")),
2135            vec![
2136                legacy_agent("planner", "mse-worker-planner", vec!["Read", "Grep"]),
2137                legacy_agent("coder", "mse-worker-coder", vec![]),
2138                agent("no-binding", "echo"),
2139            ],
2140        );
2141
2142        let derived = derive_worker_bindings(&blueprint);
2143
2144        for agent_def in &blueprint.agents {
2145            let resolved = resolve_runner(&blueprint, agent_def).expect("no unresolved refs");
2146            match derived.get(&agent_def.name) {
2147                Some(binding) => {
2148                    assert_eq!(
2149                        resolved,
2150                        Some(Runner::WsClaudeCode {
2151                            variant: binding.variant.clone(),
2152                            tools: binding.tools.clone(),
2153                        }),
2154                        "resolve_runner must synthesize the same WsClaudeCode Runner \
2155                         derive_worker_bindings produces for agent '{}'",
2156                        agent_def.name
2157                    );
2158                }
2159                None => {
2160                    assert_eq!(
2161                        resolved, None,
2162                        "agent '{}' has no derive_worker_bindings entry, so resolve_runner \
2163                         must resolve to None too (no other tier declared)",
2164                        agent_def.name
2165                    );
2166                }
2167            }
2168        }
2169    }
2170}