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