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::binding::{
23    attest_bound_agents, binding_requests, validate_bound_agent_snapshots, AgentBindingProvider,
24    LegacyWorkerBindingPolicy, UnboundAgent,
25};
26use crate::blueprint::compiler::{materialize_bound_blueprint, CompileError, Compiler};
27use crate::blueprint::{
28    resolve_bound_agents, AuditDef, Blueprint, BoundAgent, EngineDispatcher, Runner,
29};
30use crate::core::agent_context::ContextPolicy;
31use crate::core::config::CheckPolicy;
32use crate::core::ctx::OperatorKind;
33use crate::core::engine::Engine;
34use crate::core::errors::EngineError;
35use crate::middleware::agent_context::AgentContextMiddleware;
36use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
37use crate::middleware::task_input::TaskInputMiddleware;
38use crate::middleware::worker_binding::WorkerBindingMiddleware;
39use crate::middleware::{AfterRunAuditMiddleware, SpawnerStack};
40use crate::operator::WorkerBinding;
41use crate::service::linker;
42use crate::store::run::{RunContext, SnapshotOrigin};
43use crate::types::{CapToken, Role};
44use mlua_flow_ir::{Externs, NoExterns};
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47use std::collections::HashMap;
48use std::sync::Arc;
49use std::time::Duration;
50use thiserror::Error;
51
52/// Derive the "BP Agent-level" tier of the `OperatorKind` cascade from a
53/// Blueprint: for every `AgentDef` whose `spec.operator_ref` resolves to an
54/// `OperatorDef` with a `Some` `kind`, map `AgentDef.name -> OperatorKind`.
55///
56/// Deliberately **not** filtered by `AgentDef.kind == AgentKind::Operator`:
57/// the `OperatorKind` cascade is a middleware-level cross-cutting concern
58/// (spawn_hook / senior_bridge / operator-delegate gating via `Ctx.operator`),
59/// orthogonal to the Worker IMPL axis that `AgentKind` expresses (see the
60/// crate root doc, "Operator is delivered as a cross-cutting overlay through
61/// `Ctx` plus middleware"). A `RustFn` / `Lua` / `Subprocess` agent can
62/// equally declare `spec.operator_ref` to opt into a BP-declared
63/// `OperatorKind` without changing its Worker IMPL. Agents without an
64/// `operator_ref`, an unresolved `operator_ref`, or an `OperatorDef.kind =
65/// None` are simply absent from the map (= that tier falls through for
66/// them). This is a separate, independent consumer of `Blueprint.operators`
67/// from the design-time `operator_ref` validation in
68/// `blueprint::compiler::Compiler::compile` (issue: `OperatorDef`
69/// first-class treatment), which only checks the reference resolves for
70/// `AgentKind::Operator` agents and is unaffected by this function.
71/// Build the `agent name → WorkerBinding` map from
72/// `Blueprint.agents[].profile.worker_binding` — the launch-time sibling of
73/// the compile-time resolution in `OperatorSpawnerFactory::build`. Consumed
74/// by `WorkerBindingMiddleware` so the delegate axis
75/// (`OperatorDelegateMiddleware`) can resolve the binding via `ctx.agent`
76/// like every other agent-keyed table (`CompiledAgentTable.routes` idiom).
77/// Agents without a declared binding are simply absent (no silent default).
78#[cfg(test)]
79pub(crate) fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
80    // Kept as a test-facing compatibility name. Production resolves once
81    // and calls `worker_bindings_from_bound_agents` with that snapshot.
82    let bound_agents = resolve_bound_agents(blueprint)
83        .expect("derive_worker_bindings requires a Blueprint with resolvable Runner refs");
84    worker_bindings_from_bound_agents(&bound_agents)
85}
86
87fn worker_bindings_from_bound_agents(
88    bound_agents: &[BoundAgent],
89) -> HashMap<String, WorkerBinding> {
90    bound_agents
91        .iter()
92        .filter_map(|bound| match &bound.runner {
93            Some(Runner::WsOperator { variant, tools })
94            | Some(Runner::WsClaudeCode { variant, tools }) => Some((
95                bound.agent.name.clone(),
96                WorkerBinding {
97                    variant: variant.clone(),
98                    tools: tools.clone(),
99                    request_digest: Some(bound.binding_digest.clone()),
100                    requested_model: bound.agent.profile.as_ref().and_then(|p| p.model.clone()),
101                },
102            )),
103            _ => None,
104        })
105        .collect()
106}
107
108/// Attest a freshly resolved snapshot (or enforce the strict-without-provider
109/// gate) exactly once, applied identically on both first-resolution paths in
110/// [`load_or_resolve_bound_agents`].
111///
112/// `strict` = [`crate::blueprint::CompilerStrategy::strict_binding`]:
113///
114/// - With a provider: every `Bound` outcome is validated and pinned; any
115///   `Unbound` agent fails the launch when `strict`, or (non-strict) is
116///   reported through a `tracing::warn!` and, if a `RunContext` is present, a
117///   `RunRecord.degradations` entry (the existing append-only channel). The
118///   agent stays `DeclarationOnly` either way.
119/// - Without a provider: a `strict` Blueprint that declares any Runner-backed
120///   agent fails fast (`PreDispatch`) because nothing can attest it; a
121///   non-strict Blueprint runs `DeclarationOnly` (the embed use case).
122async fn attest_or_gate_fresh(
123    bound_agents: &mut [BoundAgent],
124    binding_provider: Option<&dyn AgentBindingProvider>,
125    strict: bool,
126    run_ctx: Option<&RunContext>,
127) -> Result<(), TaskLaunchError> {
128    match binding_provider {
129        Some(provider) => {
130            let unbound = attest_bound_agents(provider, bound_agents, strict)
131                .await
132                .map_err(|error| TaskLaunchError::PreDispatch(error.to_string()))?;
133            for agent in &unbound {
134                record_unbound_degradation(agent, run_ctx).await;
135            }
136            Ok(())
137        }
138        None => {
139            if strict && !binding_requests(bound_agents).is_empty() {
140                return Err(TaskLaunchError::PreDispatch(format!(
141                    "strict_binding requires a binding provider but none is injected; \
142                     {} Runner-backed agent(s) cannot be attested",
143                    binding_requests(bound_agents).len()
144                )));
145            }
146            Ok(())
147        }
148    }
149}
150
151/// Record one non-strict unattested agent: a `tracing::warn!` always, plus a
152/// `RunRecord.degradations` append when a `RunContext` carries a run store.
153/// Observational only — a failed append is itself logged and never fails the
154/// launch (degradation recording must not gate a launch the strict decision
155/// already let through).
156async fn record_unbound_degradation(agent: &UnboundAgent, run_ctx: Option<&RunContext>) {
157    tracing::warn!(
158        agent = %agent.agent,
159        reason = %agent.reason,
160        "binding_unattested: agent runs DeclarationOnly (strict_binding is off)"
161    );
162    let Some(run_ctx) = run_ctx else {
163        return;
164    };
165    let entry = crate::store::run::DegradationEntry {
166        tool: "binding".to_string(),
167        error: agent.reason.clone(),
168        fallback: "DeclarationOnly".to_string(),
169        note: Some(format!(
170            "agent '{}' launched without a binding attestation (strict_binding off)",
171            agent.agent
172        )),
173        step_ref: None,
174        attempt: None,
175        at: crate::types::now_unix(),
176    };
177    if let Err(error) = run_ctx
178        .run_store
179        .append_degradation(&run_ctx.run_id, entry)
180        .await
181    {
182        tracing::warn!(
183            agent = %agent.agent,
184            %error,
185            "binding_unattested: failed to record degradation entry"
186        );
187    }
188}
189
190async fn load_or_resolve_bound_agents(
191    blueprint: &Blueprint,
192    run_ctx: Option<&RunContext>,
193    binding_provider: Option<&dyn AgentBindingProvider>,
194    legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
195) -> Result<(Vec<BoundAgent>, SnapshotOrigin), TaskLaunchError> {
196    // Strict binding is a BP-level opt-in only (server config / launch
197    // request cascade is intentionally out of scope for this change).
198    let strict = blueprint.strategy.strict_binding;
199    let resolve_fresh = || match legacy_worker_binding_policy {
200        LegacyWorkerBindingPolicy::Allow => resolve_bound_agents(blueprint),
201        LegacyWorkerBindingPolicy::Reject => {
202            crate::blueprint::resolve_bound_agents_strict(blueprint)
203        }
204    };
205    let Some(run_ctx) = run_ctx else {
206        // No Run context (embed use): nothing to persist, and no snapshot
207        // to backfill — this is an initial launch by definition.
208        let mut bound_agents = resolve_fresh().map_err(CompileError::from)?;
209        attest_or_gate_fresh(&mut bound_agents, binding_provider, strict, None).await?;
210        return Ok((bound_agents, SnapshotOrigin::Launch));
211    };
212
213    let record = run_ctx
214        .run_store
215        .get(&run_ctx.run_id)
216        .await
217        .map_err(|e| TaskLaunchError::PreDispatch(format!("load Run binding snapshot: {e}")))?;
218    if let Some(input_json) = record.input_json.as_deref() {
219        let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
220            TaskLaunchError::PreDispatch(format!("decode Run launch snapshot: {e}"))
221        })?;
222        if let Some(value) = snapshot.get("bound_agents") {
223            let bound_agents: Vec<BoundAgent> =
224                serde_json::from_value(value.clone()).map_err(|e| {
225                    TaskLaunchError::PreDispatch(format!("decode Run BoundAgent snapshot: {e}"))
226                })?;
227            validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
228                TaskLaunchError::PreDispatch(format!("validate Run BoundAgent snapshot: {error}"))
229            })?;
230            // [Crux D2-b] The fast path reads the PERSISTED origin marker
231            // rather than re-deriving it from `run_ctx.resume`, so a
232            // backfilled Run stays `resume_backfill` (and keeps its legacy
233            // replay keys) across every subsequent resume — the outcome is a
234            // function of the pinned snapshot, not of how many times the Run
235            // was resumed. An absent marker maps to `resume_backfill` (the
236            // safe side — see `SnapshotOrigin::from_snapshot`).
237            let origin = SnapshotOrigin::from_snapshot(&snapshot);
238            return Ok((bound_agents, origin));
239        }
240    }
241
242    let mut bound_agents = resolve_fresh().map_err(CompileError::from)?;
243    attest_or_gate_fresh(&mut bound_agents, binding_provider, strict, Some(run_ctx)).await?;
244    // [Crux D1-b] The origin is decided by the RunContext's explicit `resume`
245    // flag ALONE — never inferred from replay-cursor / step-entry presence,
246    // whose wiring is free to change. A resume / rerun-from re-derives the
247    // snapshot from the current Blueprint (no launch-time pin); an initial
248    // launch persists the authoritative pin.
249    let origin = if run_ctx.resume {
250        SnapshotOrigin::ResumeBackfill
251    } else {
252        SnapshotOrigin::Launch
253    };
254    if let Some(input_json) = record.input_json {
255        let mut snapshot: Value = serde_json::from_str(&input_json).map_err(|e| {
256            TaskLaunchError::PreDispatch(format!("decode Run launch snapshot: {e}"))
257        })?;
258        let object = snapshot.as_object_mut().ok_or_else(|| {
259            TaskLaunchError::PreDispatch("Run launch snapshot must be a JSON object".to_string())
260        })?;
261        // [Crux D1-a] `bound_agents` and its `bound_agents_origin` marker are
262        // written into the SAME snapshot object and persisted in the SAME
263        // `set_input_json` call — never two writes, so no intermediate state
264        // can carry one without the other.
265        object.insert(
266            "bound_agents".to_string(),
267            serde_json::to_value(&bound_agents).map_err(|e| {
268                TaskLaunchError::PreDispatch(format!("encode Run BoundAgent snapshot: {e}"))
269            })?,
270        );
271        object.insert(
272            crate::store::run::BOUND_AGENTS_ORIGIN_KEY.to_string(),
273            serde_json::to_value(origin).map_err(|e| {
274                TaskLaunchError::PreDispatch(format!("encode Run BoundAgent origin: {e}"))
275            })?,
276        );
277        run_ctx
278            .run_store
279            .set_input_json(
280                &run_ctx.run_id,
281                serde_json::to_string(&snapshot).map_err(|e| {
282                    TaskLaunchError::PreDispatch(format!("encode Run launch snapshot: {e}"))
283                })?,
284            )
285            .await
286            .map_err(|e| {
287                TaskLaunchError::PreDispatch(format!("persist Run BoundAgent snapshot: {e}"))
288            })?;
289    }
290    // A resume that had to backfill its bindings is an observed degradation
291    // (the binding identity is no longer a launch-time pin). Append-only,
292    // never fails the launch — mirrors `record_unbound_degradation`.
293    if origin == SnapshotOrigin::ResumeBackfill {
294        record_backfill_degradation(run_ctx, blueprint.id.as_str()).await;
295    }
296    Ok((bound_agents, origin))
297}
298
299/// Record one binding backfill: a pre-binding-snapshot Run was resumed (or
300/// reran) and its `bound_agents` were re-derived from the current Blueprint
301/// rather than restored from a launch-time pin. Same append-only,
302/// never-fail-the-launch posture as [`record_unbound_degradation`] — a failed
303/// append is logged and swallowed (the launch has already committed to
304/// running).
305async fn record_backfill_degradation(run_ctx: &RunContext, blueprint_id: &str) {
306    tracing::warn!(
307        run_id = %run_ctx.run_id,
308        blueprint = %blueprint_id,
309        "binding_backfill: resumed Run had no binding snapshot; bound_agents \
310         re-derived from the current Blueprint (not a launch-time pin)"
311    );
312    let entry = crate::store::run::DegradationEntry {
313        tool: "binding".to_string(),
314        error: "run resumed without a launch-pinned binding snapshot".to_string(),
315        fallback: "resume_backfill".to_string(),
316        note: Some(format!(
317            "run '{}' backfilled bound_agents from Blueprint '{}' at resume time",
318            run_ctx.run_id, blueprint_id
319        )),
320        step_ref: None,
321        attempt: None,
322        at: crate::types::now_unix(),
323    };
324    if let Err(error) = run_ctx
325        .run_store
326        .append_degradation(&run_ctx.run_id, entry)
327        .await
328    {
329        tracing::warn!(
330            run_id = %run_ctx.run_id,
331            %error,
332            "binding_backfill: failed to record degradation entry"
333        );
334    }
335}
336
337/// GH #34 — extract the Blueprint-declared after-run audit hooks
338/// (`Blueprint.audits`), the launch-time input to `AfterRunAuditMiddleware`.
339/// Trivial extraction (unlike [`derive_worker_bindings`] / the agent-context
340/// derivers below, no per-agent lookup is needed — `AuditDef.agent` is a
341/// plain agent-name string already validated against `Blueprint.agents` at
342/// `Compiler::compile` time). `[]` (every pre-#34 Blueprint) means "no
343/// audit layer at all" — see the conditional `.layer(...)` wiring in
344/// [`TaskLaunchService::launch`] (invariant #4: byte-identical behavior).
345fn derive_audits(blueprint: &Blueprint) -> Vec<AuditDef> {
346    blueprint.audits.clone()
347}
348
349/// Issue #21 Phase 1: build the agent-context supply axis's "BP Global" +
350/// "BP Agent-level" context tiers from a Blueprint — the launch-time
351/// sibling of [`derive_worker_bindings`] (same "no silent default"
352/// discipline: an agent's entry is present only when it declares one).
353/// Consumed by `AgentContextMiddleware`, which shallow-merges the two
354/// tiers per spawn (agent wins) and inserts the result into
355/// `ctx.meta.runtime` only-if-absent (see
356/// `crate::middleware::agent_context`'s module doc for the full merge +
357/// precedence narrative).
358///
359/// - `.0` (global) = [`Blueprint::default_agent_ctx`], unchanged.
360/// - `.1` (per-agent) = `AgentDef.name -> AgentMeta.ctx`, entry present
361///   only for agents whose `meta` is `Some` and who declare a `ctx`
362///   (directly via `meta.ctx`, and/or indirectly via
363///   [`AgentMeta::meta_ref`] — GH #21 Phase 2, see below).
364///
365/// # GH #21 Phase 2: `AgentMeta.meta_ref` resolution
366///
367/// When an agent declares `meta.meta_ref`, it is resolved against
368/// [`derive_step_metas`]'s pool and used as the BASE layer UNDER the
369/// agent's own inline `meta.ctx` (inline wins on key collision, shallow
370/// merge — see [`shallow_merge_inline_wins`]). An unresolved `meta_ref`
371/// at this point means the caller launched a Blueprint that bypassed
372/// `Compiler::compile`'s validation (the loud gate for this case, see
373/// `blueprint::compiler::Compiler::compile`'s `UnresolvedMetaRef` check);
374/// this function stays defensive and never panics — it logs a warning and
375/// skips the base layer, letting the agent's own inline `ctx` (if any)
376/// stand alone.
377pub(crate) fn derive_agent_ctx(blueprint: &Blueprint) -> (Option<Value>, HashMap<String, Value>) {
378    let global = blueprint.default_agent_ctx.clone();
379    let meta_pool = derive_step_metas(blueprint);
380    let per_agent = blueprint
381        .agents
382        .iter()
383        .filter_map(|ad| {
384            let meta = ad.meta.as_ref()?;
385            let inline = meta.ctx.clone();
386            let base = meta.meta_ref.as_ref().and_then(|name| {
387                let resolved = meta_pool.get(name).cloned();
388                if resolved.is_none() {
389                    tracing::warn!(
390                        agent = %ad.name,
391                        meta_ref = %name,
392                        "derive_agent_ctx: AgentMeta.meta_ref names an undefined Blueprint.metas entry; skipping the base layer"
393                    );
394                }
395                resolved
396            });
397            let merged = match (base, inline) {
398                (None, None) => None,
399                (Some(base), None) => Some(base),
400                (None, Some(inline)) => Some(inline),
401                (Some(base), Some(inline)) => Some(shallow_merge_inline_wins(base, inline)),
402            };
403            merged.map(|ctx| (ad.name.clone(), ctx))
404        })
405        .collect();
406    (global, per_agent)
407}
408
409/// GH #21 Phase 2: shallow-merge `base` with `inline`, `inline` winning
410/// key collisions. Both sides being JSON `Object`s is the meaningful case
411/// (per-key merge); a non-`Object` `inline` is used as-is (it "wins"
412/// entirely — the malformed-shape case is left to
413/// `AgentContextMiddleware`'s own tier merge, which already warns + skips
414/// a non-`Object` tier value downstream, never failing the spawn).
415pub(crate) fn shallow_merge_inline_wins(base: Value, inline: Value) -> Value {
416    match (base, inline) {
417        (Value::Object(mut base), Value::Object(inline)) => {
418            for (k, v) in inline {
419                base.insert(k, v);
420            }
421            Value::Object(base)
422        }
423        (_, inline) => inline,
424    }
425}
426
427/// GH #21 Phase 2: build the `Blueprint.metas` named pool (`MetaDef.name
428/// -> MetaDef.ctx`) — the launch-time sibling of [`derive_worker_bindings`]
429/// / [`derive_agent_ctx`], resolving the Step tier's shared pool instead
430/// of a per-agent map. Consumed by `EngineDispatcher::with_step_metas`
431/// (the Step tier's `$step_meta.ref` resolver) and, indirectly, by
432/// [`derive_agent_ctx`]'s `AgentMeta.meta_ref` resolution (the Agent
433/// tier shares the same pool).
434fn derive_step_metas(blueprint: &Blueprint) -> HashMap<String, Value> {
435    blueprint
436        .metas
437        .iter()
438        .map(|m| (m.name.clone(), m.ctx.clone()))
439        .collect()
440}
441
442/// Issue #21 Phase 1: build the [`ContextPolicy`] cascade's "BP Global" +
443/// "BP Agent-level" tiers from a Blueprint — same shape and discipline as
444/// [`derive_agent_ctx`], from `Blueprint.default_context_policy` /
445/// `AgentMeta.context_policy` instead. Consumed by
446/// `AgentContextMiddleware`, which resolves the effective policy per spawn
447/// (per-agent tier outranks the BP-global one; pass-all when neither is
448/// declared for the dispatching agent).
449fn derive_context_policies(
450    blueprint: &Blueprint,
451) -> (Option<ContextPolicy>, HashMap<String, ContextPolicy>) {
452    let default_policy = blueprint.default_context_policy.clone();
453    let per_agent = blueprint
454        .agents
455        .iter()
456        .filter_map(|ad| {
457            let meta = ad.meta.as_ref()?;
458            let policy = meta.context_policy.clone()?;
459            Some((ad.name.clone(), policy))
460        })
461        .collect();
462    (default_policy, per_agent)
463}
464
465/// Issue #19 ST3: shallow-merge the "BP Global" default `init_ctx`
466/// (`Blueprint.default_init_ctx`) with the Task-level `init_ctx` — the
467/// second layer of the (eventual 4-layer) init-ctx cascade, following the
468/// same "BP default, Task overrides" shape as the `OperatorKind` cascade
469/// (see `derive_bp_agent_kinds` / `TaskLaunchInput::operator_kind`).
470///
471/// Semantics (deliberately a single rule, no deep merge / JSON Patch):
472///
473/// - `bp_default = None` → `task_init_ctx` passes through unchanged
474///   (pre-#19 Blueprints keep today's exact behavior).
475/// - Both sides are `Value::Object` → shallow key-wise merge, Task wins
476///   on collision (`task_init_ctx`'s keys are applied last).
477/// - `task_init_ctx` is present but not an `Object` (`Null` / `String` /
478///   `Array` / `Number` / `Bool`) → Task fully replaces the BP default;
479///   the caller's non-Object seed is respected as-is.
480fn merge_init_ctx(bp_default: Option<&Value>, task_init_ctx: &Value) -> Value {
481    match (bp_default, task_init_ctx) {
482        (Some(Value::Object(bp_map)), Value::Object(task_map)) => {
483            let mut merged = bp_map.clone();
484            for (k, v) in task_map {
485                merged.insert(k.clone(), v.clone());
486            }
487            Value::Object(merged)
488        }
489        (None, _) => task_init_ctx.clone(),
490        (_, task) => task.clone(),
491    }
492}
493
494/// Issue #19 ST4: 3-layer shallow-merge of the init-ctx cascade — BP
495/// default → Task → Run (lowest to highest priority). Built by chaining
496/// [`merge_init_ctx`] twice rather than introducing a distinct 3-way merge
497/// algorithm, so the Run layer inherits exactly the same "shallow Object
498/// merge, non-Object fully replaces" rule [`merge_init_ctx`] already
499/// established for the BP/Task pair (see its doc for the full semantics).
500///
501/// - `run_override: None` is a no-op — the BP+Task merge passes through
502///   unchanged, so `POST /v1/tasks/:id/runs` with no body (or a body that
503///   omits `init_ctx_override`) preserves today's rekick behavior
504///   byte-for-byte.
505/// - `run_override: Some(_)` layers on top exactly like `task_init_ctx`
506///   layers on top of `bp_default` above: both `Object` → shallow
507///   key-wise merge with Run winning collisions; Run non-`Object` →
508///   fully replaces the BP+Task merge.
509pub fn merge_init_ctx_3layer(
510    bp_default: Option<&Value>,
511    task_init_ctx: &Value,
512    run_override: Option<&Value>,
513) -> Value {
514    let bp_task = merge_init_ctx(bp_default, task_init_ctx);
515    match run_override {
516        Some(run) => merge_init_ctx(Some(&bp_task), run),
517        None => bp_task,
518    }
519}
520
521fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
522    let mut out = HashMap::new();
523    if blueprint.operators.is_empty() {
524        return out;
525    }
526    for agent in &blueprint.agents {
527        let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
528            continue;
529        };
530        let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
531            continue;
532        };
533        if let Some(kind) = op_def.kind {
534            out.insert(agent.name.clone(), OperatorKind::from(kind));
535        }
536    }
537    out
538}
539
540/// Failure modes of [`TaskLaunchService::launch`].
541#[derive(Debug, Error)]
542pub enum TaskLaunchError {
543    /// `Compiler::compile` rejected the Blueprint.
544    #[error("compile: {0}")]
545    Compile(#[from] CompileError),
546    /// `Engine::attach_with_ids` failed.
547    #[error("engine: {0}")]
548    Engine(#[from] EngineError),
549    /// A `Step` inside `flow.ir`'s `eval_async` produced a dispatcher
550    /// error, or a sub-flow raised.
551    #[error("flow eval: {0}")]
552    FlowEval(String),
553    /// Pre-dispatch validation failed: the launch was rejected before any
554    /// step was dispatched. Raised when the effective check_policy
555    /// (launch request > blueprint > server config) is Strict and the
556    /// launch supplied neither project_root nor work_dir — a strict task
557    /// would deterministically fail at its first submit-time file
558    /// materialize, so the launch fails fast instead.
559    #[error("pre-dispatch: {0}")]
560    PreDispatch(String),
561}
562
563/// Canonical bag of Task-level fields (`project_root` / `work_dir` /
564/// `task_metadata`) — [`TaskLaunchInput::task_input`]'s type.
565///
566/// Issue #19 ST2: replaces the ST1 `resolve_task_level_init_ctx`
567/// fold-back-into-`init_ctx` bridge (removed from
568/// `mlua-swarm-server`'s `run_flow_form`). Callers resolve these three
569/// fields once at the wire boundary — sibling body field first, falling
570/// back to the legacy shape (same three keys nested directly inside
571/// `init_ctx`) only there — and hand the result straight through here;
572/// `init_ctx` itself is no longer mutated to carry them, so it stays a
573/// pure flow-ir eval seed identical to whatever the caller sent.
574///
575/// Each field is independently optional — see
576/// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`],
577/// which this is built for.
578///
579/// Issue #19 ST4: also `Serialize`/`Deserialize` so it can travel over the
580/// wire as `RunKickRequest.task_input_override` (`mlua-swarm-server`'s
581/// `tasks` module) and be snapshotted into `TaskRecord.task_input_spec`
582/// (JSON) for rekick to resolve back out of. Every field is
583/// `#[serde(default)]` so a caller may omit any subset (or send `{}`) and
584/// still deserialize.
585#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
586pub struct TaskInputSpec {
587    /// Task-level project root path.
588    #[serde(default)]
589    pub project_root: Option<String>,
590    /// Task-level working directory path.
591    #[serde(default)]
592    pub work_dir: Option<String>,
593    /// Task-level arbitrary metadata bag (a JSON object, or `None`).
594    #[serde(default)]
595    #[schemars(with = "Option<Value>")]
596    pub task_metadata: Option<Value>,
597}
598
599/// Input to [`TaskLaunchService::launch`].
600#[derive(Debug, Clone)]
601pub struct TaskLaunchInput {
602    /// The Blueprint to compile, link, and run.
603    pub blueprint: Blueprint,
604    /// Caller-supplied id for the Operator that owns this run.
605    pub operator_id: String,
606    /// The Operator's role for this run.
607    pub role: Role,
608    /// How long the attached session is allowed to live.
609    pub ttl: Duration,
610    /// "Runtime Global" tier of the `OperatorKind` cascade. `Some(_)` is
611    /// always an explicit request — including `Some(OperatorKind::Automate)`
612    /// — that outranks the BP-level tiers (`OperatorDef.kind` /
613    /// `Blueprint.default_operator_kind`); `None` leaves it unspecified so
614    /// those tiers / the final default decide. Under `MainAi` or
615    /// `Composite`, `MainAIMiddleware`'s `spawn_hook` before/after
616    /// callbacks become effective. See
617    /// `crate::core::ctx::collapse_operator_kind`.
618    pub operator_kind: Option<OperatorKind>,
619    /// `SeniorBridge` registry ID. `None` — no bridge; `Some(id)` —
620    /// attach a bridge previously registered via
621    /// `engine.register_senior_bridge`.
622    pub bridge_id: Option<String>,
623    /// `SpawnHook` registry ID. Same shape as above, via
624    /// `engine.register_spawn_hook`.
625    pub hook_id: Option<String>,
626    /// Operator registry ID — used on the path that hands the whole
627    /// spawn off to an external Operator. Name previously registered
628    /// with `engine.register_operator`; resolved by
629    /// `OperatorDelegateMiddleware`, which — for `kind = MainAi` or
630    /// `Composite` — bypasses `inner.spawn` and calls
631    /// `operator.execute`.
632    pub operator_backend_id: Option<String>,
633    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
634    /// cascade — per-agent override, keyed by `AgentDef.name`. Empty by
635    /// default (no override for any agent). See
636    /// `crate::core::ctx::collapse_operator_kind` for the full tier list.
637    pub operator_kind_overrides: HashMap<String, OperatorKind>,
638    /// The initial `ctx` (JSON `Value`) that flow.ir's `eval_async`
639    /// starts from. Every `Step.in` `$.<path>` reference reads from
640    /// here. Issue #19 ST2: a pure flow-ir eval seed — no Task-level
641    /// field is folded into it anymore; see [`Self::task_input`].
642    pub init_ctx: Value,
643    /// Task-level canonical fields (issue #19 ST2). `Some` layers a
644    /// [`crate::middleware::task_input::TaskInputMiddleware`] (built via
645    /// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`])
646    /// onto the spawner stack just before spawn; `None` is a no-op,
647    /// identical to today's behavior for callers with no Task-level
648    /// fields to propagate.
649    pub task_input: Option<TaskInputSpec>,
650    /// Issue #13 run_id propagation: when `Some`, every step this launch
651    /// dispatches is traced into `RunRecord.step_entries` and exposes its
652    /// `run_id` via `Ctx.meta.runtime["run_id"]` (see
653    /// `EngineDispatcher::with_run`). `None` (the default via
654    /// [`Self::automate`]) preserves the pre-existing behavior — no run
655    /// tracing.
656    pub run_ctx: Option<RunContext>,
657    /// The "launch request" tier (tier 1, highest
658    /// priority) of the `check_policy` cascade
659    /// (`launch request > blueprint > server config`).
660    /// [`TaskLaunchService::launch`]
661    /// collapses `check_policy.or(blueprint.check_policy)` exactly once and
662    /// threads the result into every spawned step's `TaskSpec.check_policy`.
663    /// `None` (the default via [`Self::automate`]) leaves this tier
664    /// unspecified so the Blueprint tier / server-wide default decide —
665    /// backward-compat with every pre-cascade caller.
666    ///
667    /// [`TaskLaunchService::launch`] also collapses this same cascade one
668    /// step further
669    /// (adding the server-wide `EngineCfg.check_policy` tier) into a
670    /// pre-dispatch guard: when the resulting effective policy is
671    /// [`CheckPolicy::Strict`] and neither [`Self::task_input`]'s
672    /// `project_root` nor `work_dir` is set, the launch is rejected with
673    /// `TaskLaunchError::PreDispatch` before any step is dispatched — a
674    /// strict task with no resolvable root would deterministically fail
675    /// at its first submit-time file materialize anyway. Setting this
676    /// field to `Some(CheckPolicy::Warn)` on the launch-request tier is
677    /// the escape hatch: it outranks a Blueprint- or server-declared
678    /// Strict and lets the guard pass.
679    pub check_policy: Option<CheckPolicy>,
680}
681
682impl TaskLaunchInput {
683    /// Helper for existing callers on the default path — no hooks and no
684    /// per-agent `OperatorKind` overrides. Leaves the "Runtime Global" tier
685    /// unspecified (`None`), so the BP-level tiers / final default
686    /// (`OperatorKind::Automate`) decide — this preserves today's
687    /// behaviour for every existing caller without silently forcing
688    /// `Automate` as an explicit override that would outrank a BP-declared
689    /// `MainAi`/`Composite` kind. `run_ctx` and `task_input` both default
690    /// to `None` (no run tracing, no Task-level fields); construct the
691    /// struct literal directly to set either.
692    pub fn automate(
693        blueprint: Blueprint,
694        operator_id: impl Into<String>,
695        role: Role,
696        ttl: Duration,
697        init_ctx: Value,
698    ) -> Self {
699        Self {
700            blueprint,
701            operator_id: operator_id.into(),
702            role,
703            ttl,
704            operator_kind: None,
705            bridge_id: None,
706            hook_id: None,
707            operator_backend_id: None,
708            operator_kind_overrides: HashMap::new(),
709            init_ctx,
710            task_input: None,
711            run_ctx: None,
712            check_policy: None,
713        }
714    }
715}
716
717/// Result of a successful [`TaskLaunchService::launch`] call.
718#[derive(Debug, Clone)]
719pub struct TaskLaunchOutput {
720    /// The capability token for the attached session.
721    pub token: CapToken,
722    /// The final `ctx` after the flow ran — every `Step.out` has
723    /// been written. Application-layer callers pull the outcome out
724    /// of this `Value` and fold it into a domain status.
725    pub final_ctx: Value,
726}
727
728/// Domain service that compiles, links, and runs a Blueprint's flow to
729/// completion through the [`Engine`]. See the module doc for the full
730/// responsibility list.
731pub struct TaskLaunchService {
732    engine: Engine,
733    compiler: Compiler,
734    /// `call_extern` registry threaded into flow eval. Defaults to
735    /// [`NoExterns`] (= every `call_extern` in a Blueprint raises
736    /// `ExternError`); hosts opt in via [`Self::with_externs`] with an
737    /// `ExternMap` of pure value-shape functions.
738    externs: Arc<dyn Externs + Send + Sync>,
739    /// Optional execution-environment binding implementation. When present,
740    /// fresh Run snapshots require a complete, Core-validated receipt set.
741    binding_provider: Option<Arc<dyn AgentBindingProvider>>,
742    /// Whether fresh Blueprint declarations may use the deprecated
743    /// `profile.worker_binding` Runner fallback.
744    legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
745}
746
747impl TaskLaunchService {
748    /// Build a service bound to one `Engine` and one `Compiler`.
749    pub fn new(engine: Engine, compiler: Compiler) -> Self {
750        Self {
751            engine,
752            compiler,
753            externs: Arc::new(NoExterns),
754            binding_provider: None,
755            legacy_worker_binding_policy: LegacyWorkerBindingPolicy::default(),
756        }
757    }
758
759    /// Replace the `call_extern` registry (builder style). Entries MUST be
760    /// pure functions — no side effects, no flow control; effectful work
761    /// belongs to `Step` / agents, not externs (flow-ir canonical contract).
762    pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
763        self.externs = externs;
764        self
765    }
766
767    /// Inject the execution-environment binding provider. Platform plugins
768    /// and Operator/MainAI implementations use this same interface; Core
769    /// retains receipt validation and digest ownership.
770    pub fn with_binding_provider(mut self, provider: Arc<dyn AgentBindingProvider>) -> Self {
771        self.binding_provider = Some(provider);
772        self
773    }
774
775    /// Configure the migration gate for deprecated `profile.worker_binding`.
776    pub fn with_legacy_worker_binding_policy(mut self, policy: LegacyWorkerBindingPolicy) -> Self {
777        self.legacy_worker_binding_policy = policy;
778        self
779    }
780
781    /// The bound `Engine`.
782    pub fn engine(&self) -> &Engine {
783        &self.engine
784    }
785
786    /// The bound `Compiler`.
787    pub fn compiler(&self) -> &Compiler {
788        &self.compiler
789    }
790
791    /// Run the Blueprint's flow to completion and return the final
792    /// `ctx`.
793    ///
794    /// Failure paths:
795    ///
796    /// - `compiler.compile` failure → `TaskLaunchError::Compile`.
797    /// - `engine.attach` failure → `TaskLaunchError::Engine`.
798    /// - A `Step` inside `flow eval` producing a dispatcher error, or
799    ///   a sub-flow raising, → `TaskLaunchError::FlowEval`. There is
800    ///   no silent partial-success completion; failures always
801    ///   propagate.
802    pub async fn launch(
803        &self,
804        mut input: TaskLaunchInput,
805    ) -> Result<TaskLaunchOutput, TaskLaunchError> {
806        // After the stateless-executor refactor, the
807        // caller (Service) does compile + link +
808        // `EngineDispatcher::with_spawner` itself; the engine no longer
809        // holds any global spawner state to touch. The link path (base
810        // `SpawnerAdapter` +
811        // `LayerRegistry` resolution + `SpawnerStack` wrapping) is
812        // concentrated inside `service::linker::link` — Service
813        // scatter is intentionally prevented.
814        let (bound_agents, snapshot_origin) = load_or_resolve_bound_agents(
815            &input.blueprint,
816            input.run_ctx.as_ref(),
817            self.binding_provider.as_deref(),
818            self.legacy_worker_binding_policy,
819        )
820        .await?;
821        let binding_digests: HashMap<String, crate::blueprint::BindingDigest> = bound_agents
822            .iter()
823            .map(|bound| (bound.agent.name.clone(), bound.binding_digest.clone()))
824            .collect();
825        if let Some(run_ctx) = input.run_ctx.take() {
826            // [Crux D2-a] A `launch`-origin Run attaches the binding digests to
827            // the RunContext so replay keys distinguish the same step/input run
828            // under different bindings (the property the strict-binding series
829            // introduced). A `resume_backfill`-origin Run does NOT: its
830            // pre-upgrade replay log was hashed WITHOUT binding digests, so
831            // leaving `RunContext.binding_digests` empty makes the engine's
832            // `input_hash` fall back to the legacy form (`binding_digests.get`
833            // → None, see `Engine::dispatch_attempt_with_run_ctx`) and the old
834            // log hits verbatim. This is per-Run, keyed on the snapshot's
835            // origin — it does NOT disable digest keying for launch Runs.
836            input.run_ctx = Some(match snapshot_origin {
837                SnapshotOrigin::Launch => run_ctx.with_binding_digests(binding_digests.clone()),
838                SnapshotOrigin::ResumeBackfill => run_ctx,
839            });
840        }
841        input.blueprint = materialize_bound_blueprint(&input.blueprint, &bound_agents);
842        let compiled = self
843            .compiler
844            .compile_bound(&input.blueprint, &bound_agents)?;
845        // GH #50 (Subtask 2 follow-up): merge this Blueprint's compiled
846        // `AgentDef.verdict` contracts into the engine's runtime registry —
847        // see `Engine::register_verdict_contracts`'s doc for the additive
848        // (last-write-wins per agent name) semantics. This is the ONLY
849        // production call site; every other consumer
850        // (`Engine::verdict_contract_for_task`, and through it
851        // `mlua-swarm-server`'s `worker_submit` / `worker_artifact`
852        // submit-time gate) reads from what this line populates.
853        self.engine
854            .register_verdict_contracts(compiled.router.verdict_contracts.clone());
855        let spawner = linker::link(
856            compiled.router.clone(),
857            &input.blueprint.spawner_hints.layers,
858            &self.engine,
859        );
860        // GH #20 Contract C: materialize an `AgentContextView` exactly
861        // once per spawn, innermost relative to every other layer below
862        // (alias / worker-binding / task-input all insert `ctx.meta.runtime`
863        // keys this layer must observe, so it is added FIRST — later
864        // `.layer()` calls become outer, see `middleware::SpawnerStack`).
865        // Unconditional (always layered): every Blueprint gets this layer
866        // even when it declares no agent-context supply tiers at all
867        // (`derive_agent_ctx` / `derive_context_policies` both return
868        // empty state then, matching the pre-#21 `AgentContextMiddleware`
869        // `Default` behavior byte-for-byte). GH #21 Phase 1: the
870        // receptacle named in the #20 comment above is now wired —
871        // `Blueprint.default_agent_ctx` / `default_context_policy` and
872        // `AgentMeta.ctx` / `context_policy` feed this layer's merge +
873        // policy resolution (see `middleware::agent_context`'s module doc
874        // for the full narrative).
875        let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
876        let (context_policy_default, context_policy_per_agent) =
877            derive_context_policies(&input.blueprint);
878        let spawner = SpawnerStack::new(spawner)
879            .layer(AgentContextMiddleware::new(
880                agent_ctx_global,
881                agent_ctx_per_agent,
882                context_policy_default,
883                context_policy_per_agent,
884            ))
885            .build();
886        // When `Blueprint.metadata.project_name_alias` is Some, layer a
887        // `ProjectNameAliasMiddleware` on top of the stack that injects the
888        // alias into `Ctx.meta.runtime.project_name_alias` just before spawn.
889        // Downstream operators (for example, the server crate's
890        // `Operator.execute`) read `ctx.meta.runtime.get("project_name_alias")`
891        // and expand it into the Spawn directive prompt body.
892        let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
893            SpawnerStack::new(spawner)
894                .layer(ProjectNameAliasMiddleware::new(alias))
895                .build()
896        } else {
897            spawner
898        };
899        // Layer the Blueprint-baked worker bindings (same ctx.meta.runtime
900        // inject shape as the alias layer above) so the delegate axis can
901        // resolve per-agent variants — see `derive_worker_bindings`.
902        let worker_bindings = worker_bindings_from_bound_agents(&bound_agents);
903        let spawner = if worker_bindings.is_empty() {
904            spawner
905        } else {
906            SpawnerStack::new(spawner)
907                .layer(WorkerBindingMiddleware::new(worker_bindings))
908                .build()
909        };
910        // GH #34: Blueprint-declared after-run audit hooks — same
911        // conditional-layering shape as the alias / worker-binding blocks
912        // above. Empty `Blueprint.audits` (every pre-#34 Blueprint) means
913        // no layer at all (invariant #4: byte-identical behavior). The
914        // router handle handed to `AfterRunAuditMiddleware` is
915        // `compiled.router` — the raw name→adapter table `Compiler::compile`
916        // built (NOT this progressively-wrapped `spawner`) — so an audit
917        // agent's own dispatch never re-enters this same layer (see
918        // `AfterRunAuditMiddleware`'s module doc, Recursion guard section).
919        let audit_defs = derive_audits(&input.blueprint);
920        let spawner = if audit_defs.is_empty() {
921            spawner
922        } else {
923            SpawnerStack::new(spawner)
924                .layer(AfterRunAuditMiddleware::new(
925                    audit_defs,
926                    compiled.router.clone(),
927                ))
928                .build()
929        };
930
931        // Task-level execution context (`project_root` / `work_dir` /
932        // `task_metadata`) — same conditional-layering shape as the alias /
933        // worker-binding blocks above. Issue #19 ST2: read directly off
934        // `input.task_input` (already resolved by the caller) instead of
935        // extracting it back out of `input.init_ctx` — `init_ctx` is a pure
936        // flow-ir eval seed now, never folded with these keys.
937        let spawner = match input.task_input.as_ref().and_then(|spec| {
938            TaskInputMiddleware::new_from_fields(
939                spec.project_root.clone(),
940                spec.work_dir.clone(),
941                spec.task_metadata.clone(),
942            )
943        }) {
944            Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
945            None => spawner,
946        };
947
948        // "BP Agent-level" (`OperatorDef.kind` via `operator_ref`) + "BP
949        // Global" (`Blueprint.default_operator_kind`) tiers of the
950        // `OperatorKind` cascade, baked here (the only point that has both
951        // the resolved Blueprint and the launch-time overrides in scope).
952        let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
953        let bp_global_kind = input
954            .blueprint
955            .default_operator_kind
956            .map(OperatorKind::from);
957
958        let token = self
959            .engine
960            .attach_with_ids(
961                input.operator_id,
962                input.role,
963                input.ttl,
964                input.operator_kind,
965                input.bridge_id,
966                input.hook_id,
967                input.operator_backend_id,
968                input.operator_kind_overrides,
969                bp_agent_kinds,
970                bp_global_kind,
971            )
972            .await?;
973        // Collapse the `check_policy` cascade EXACTLY ONCE
974        // here: `launch request > blueprint > server config` (highest to
975        // lowest priority). `input.check_policy` is the launch-request tier;
976        // `input.blueprint.check_policy` is the Blueprint tier; a `None`
977        // result leaves the engine's submit-time sink to fall back to the
978        // server-wide `EngineCfg.check_policy` (tier 3) on its own — the
979        // engine's existing `task_policy.unwrap_or(server_policy)` resolution
980        // is deliberately NOT duplicated here (no double resolution). The
981        // resolved value is threaded (via `with_check_policy`) into EVERY
982        // spawned step's `TaskSpec`, not just the first.
983        let resolved_check_policy = input.check_policy.or(input.blueprint.check_policy);
984        // Pre-dispatch guard: collapse the same cascade one step further
985        // (adding the server tier, `EngineCfg.check_policy`, via
986        // `self.engine.cfg()`) into a SEPARATE local used only for this
987        // check — `resolved_check_policy` above (the Option stamped onto
988        // every dispatched step's `TaskSpec`) is left untouched, so the
989        // "TaskSpec = None -> engine falls back to server default at the
990        // submit-time sink" contract (cascade test case 4) keeps holding.
991        // When the effective policy is Strict and the launch supplied
992        // neither `project_root` nor `work_dir`, a strict task would
993        // deterministically fail at its first submit-time file
994        // materialize — fail the launch fast instead of dispatching a
995        // step that can only ever hit that wall. `check_policy: "warn"` on
996        // the launch-request tier is the escape hatch (it wins the
997        // cascade before this fallback ever applies).
998        let effective_check_policy =
999            resolved_check_policy.unwrap_or(self.engine.cfg().check_policy);
1000        if effective_check_policy == CheckPolicy::Strict {
1001            let roots_missing = input
1002                .task_input
1003                .as_ref()
1004                .map(|t| t.project_root.is_none() && t.work_dir.is_none())
1005                .unwrap_or(true);
1006            if roots_missing {
1007                return Err(TaskLaunchError::PreDispatch(
1008                    "check_policy=strict requires project_root or work_dir, but the launch \
1009                     supplied neither"
1010                        .to_string(),
1011                ));
1012            }
1013        }
1014        let dispatcher =
1015            EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
1016        let dispatcher = dispatcher.with_check_policy(resolved_check_policy);
1017        let dispatcher = match input.run_ctx {
1018            Some(run_ctx) => dispatcher.with_run(run_ctx),
1019            None => dispatcher,
1020        };
1021        // GH #21 Phase 2: attach the Step tier's named `MetaDef` pool.
1022        // Unconditional — an empty map (every pre-#21-Phase-2 Blueprint)
1023        // is a no-op, matching `EngineDispatcher::with_spawner`'s default.
1024        let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
1025        let dispatcher = dispatcher.with_binding_digests(binding_digests);
1026        // GH #23: attach the `StepNaming` table `Compiler::compile` already
1027        // built once for this Blueprint (the sole construction site — see
1028        // `core::step_naming::StepNaming::from_blueprint`'s doc).
1029        // Unconditional — every compile produces one, undeclared Blueprints
1030        // included (canonical falls back to `Step.ref` byte-for-byte).
1031        let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
1032        // GH #27 (follow-up to #23): attach the `ProjectionPlacement`
1033        // resolver `Compiler::compile` already built once for this
1034        // Blueprint (the sole construction site — see
1035        // `core::projection_placement::ProjectionPlacement::from_spec`'s
1036        // doc). Unconditional — every compile produces one, undeclared
1037        // Blueprints included (resolves to `ProjectionPlacement::default()`).
1038        let dispatcher =
1039            dispatcher.with_projection_placement(compiled.projection_placement.clone());
1040        // Issue #19 ST3: BP default + Task init_ctx → merged init_ctx (the
1041        // 2-layer slice of the eventual 4-layer cascade; Run override is
1042        // ST4 carry). `input.blueprint.default_init_ctx` is `None` for
1043        // every pre-#19 Blueprint, so `merge_init_ctx` is a no-op then and
1044        // this preserves today's behavior byte-for-byte.
1045        let merged_init_ctx =
1046            merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
1047        let final_ctx = mlua_flow_ir::eval_async_externs(
1048            &input.blueprint.flow,
1049            merged_init_ctx,
1050            &dispatcher,
1051            &*self.externs,
1052        )
1053        .await
1054        .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
1055        Ok(TaskLaunchOutput { token, final_ctx })
1056    }
1057}
1058
1059// ──────────────────────────────────────────────────────────────────────────
1060// UT
1061// ──────────────────────────────────────────────────────────────────────────
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066    use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1067    use crate::blueprint::{
1068        current_schema_version, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
1069        BlueprintMetadata, CompilerHints, CompilerStrategy, MetaDef, Runner,
1070    };
1071    use crate::core::config::EngineCfg;
1072    use crate::worker::adapter::{WorkerError, WorkerResult};
1073    use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
1074    use serde_json::json;
1075    use std::sync::Arc;
1076
1077    fn path(s: &str) -> Expr {
1078        Expr::Path {
1079            at: s.parse().expect("literal test path"),
1080        }
1081    }
1082    fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
1083        FlowNode::Step {
1084            ref_: ref_.to_string(),
1085            in_,
1086            out,
1087        }
1088    }
1089
1090    fn agent(name: &str, fn_id: &str) -> AgentDef {
1091        AgentDef {
1092            name: name.to_string(),
1093            kind: AgentKind::RustFn,
1094            spec: json!({ "fn_id": fn_id }),
1095            profile: None,
1096            meta: Some(AgentMeta::default()),
1097            runner: None,
1098            runner_ref: None,
1099            verdict: None,
1100        }
1101    }
1102
1103    fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
1104        let engine = Engine::new(EngineCfg::default());
1105        let mut reg = SpawnerRegistry::new();
1106        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1107        let compiler = Compiler::new(reg);
1108        TaskLaunchService::new(engine, compiler)
1109    }
1110
1111    /// Same as [`build_service`] but with a caller-supplied [`EngineCfg`] —
1112    /// used by the pre-dispatch guard's server-tier test (T4), which needs
1113    /// a non-default `EngineCfg.check_policy`.
1114    fn build_service_with_cfg(
1115        factory: RustFnInProcessSpawnerFactory,
1116        cfg: EngineCfg,
1117    ) -> TaskLaunchService {
1118        let engine = Engine::new(cfg);
1119        let mut reg = SpawnerRegistry::new();
1120        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1121        let compiler = Compiler::new(reg);
1122        TaskLaunchService::new(engine, compiler)
1123    }
1124
1125    fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
1126        Blueprint {
1127            schema_version: current_schema_version(),
1128            id: "ut".into(),
1129            flow,
1130            agents,
1131            operators: vec![],
1132            metas: vec![],
1133            hints: CompilerHints::default(),
1134            strategy: CompilerStrategy::default(),
1135            metadata: BlueprintMetadata::default(),
1136            spawner_hints: Default::default(),
1137            default_agent_kind: AgentKind::Operator,
1138            default_operator_kind: None,
1139            default_init_ctx: None,
1140            default_agent_ctx: None,
1141            default_context_policy: None,
1142            projection_placement: None,
1143            audits: vec![],
1144            degradation_policy: None,
1145            runners: vec![],
1146            default_runner: None,
1147            check_policy: None,
1148            blueprint_ref_includes: Vec::new(),
1149        }
1150    }
1151
1152    fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
1153        TaskLaunchInput::automate(
1154            blueprint,
1155            "ut-op",
1156            Role::Operator,
1157            Duration::from_secs(30),
1158            init_ctx,
1159        )
1160    }
1161
1162    // ──────────────────────────────────────────────────────────────
1163    // GH #34: `derive_audits` + the conditional `AfterRunAuditMiddleware`
1164    // `.layer(...)` wiring in `TaskLaunchService::launch`
1165    // ──────────────────────────────────────────────────────────────
1166
1167    #[test]
1168    fn derive_audits_empty_by_default() {
1169        let blueprint = bp(
1170            step("echo", path("$.input"), path("$.out")),
1171            vec![agent("echo", "echo")],
1172        );
1173        assert!(
1174            derive_audits(&blueprint).is_empty(),
1175            "audits_absent_no_layer: an undeclared audits Vec must stay empty"
1176        );
1177    }
1178
1179    #[test]
1180    fn derive_audits_returns_blueprint_audits_verbatim() {
1181        let mut blueprint = bp(
1182            step("echo", path("$.input"), path("$.out")),
1183            vec![agent("echo", "echo")],
1184        );
1185        blueprint.audits = vec![crate::blueprint::AuditDef {
1186            agent: "auditor".to_string(),
1187            steps: None,
1188            mode: crate::blueprint::AuditMode::Async,
1189        }];
1190        let got = derive_audits(&blueprint);
1191        assert_eq!(got.len(), 1);
1192        assert_eq!(got[0].agent, "auditor");
1193    }
1194
1195    #[tokio::test]
1196    async fn launch_appends_audit_artifact_when_audits_declared() {
1197        use crate::blueprint::{AuditDef, AuditMode};
1198
1199        let factory = RustFnInProcessSpawnerFactory::new()
1200            .register_fn("echo", |inv| async move {
1201                Ok(WorkerResult {
1202                    value: json!({ "echoed": inv.prompt }),
1203                    ok: true,
1204                })
1205            })
1206            .register_fn("audit-fn", |_inv| async move {
1207                Ok(WorkerResult {
1208                    value: json!({ "finding": "clean" }),
1209                    ok: true,
1210                })
1211            });
1212        let svc = build_service(factory);
1213        let mut blueprint = bp(
1214            step("echo", path("$.input"), path("$.out")),
1215            vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
1216        );
1217        blueprint.audits = vec![AuditDef {
1218            agent: "auditor".to_string(),
1219            steps: None,
1220            mode: AuditMode::Sync,
1221        }];
1222        let out = svc
1223            .launch(launch_input(blueprint, json!({ "input": "hi" })))
1224            .await
1225            .expect("launch ok — audits must never alter the audited step's outcome");
1226        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1227
1228        let audited_task_id = svc
1229            .engine()
1230            .with_state("test.find_audited_task", |s| {
1231                s.tasks
1232                    .iter()
1233                    .find(|(_, t)| t.spec.agent == "echo")
1234                    .map(|(id, _)| id.clone())
1235            })
1236            .await
1237            .expect("with_state")
1238            .expect("the echo task must exist");
1239        let tail = svc.engine().output_tail(&audited_task_id, 1).await;
1240        let found = tail.iter().any(|ev| {
1241            matches!(
1242                ev,
1243                crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
1244            )
1245        });
1246        assert!(
1247            found,
1248            "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
1249        );
1250    }
1251
1252    #[tokio::test]
1253    async fn launch_single_step_writes_out_path() {
1254        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1255            Ok(WorkerResult {
1256                value: json!({ "echoed": inv.prompt }),
1257                ok: true,
1258            })
1259        });
1260        let svc = build_service(factory);
1261        let blueprint = bp(
1262            step("echo", path("$.input"), path("$.out")),
1263            vec![agent("echo", "echo")],
1264        );
1265        let out = svc
1266            .launch(launch_input(blueprint, json!({ "input": "hi" })))
1267            .await
1268            .expect("launch ok");
1269        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1270    }
1271
1272    // ──────────────────────────────────────────────────────────────
1273    // check_policy cascade (launch > blueprint > server)
1274    // T2 (cascade 4-case) / T3 (end-to-end strict) / T4 (backward compat)
1275    // ──────────────────────────────────────────────────────────────
1276
1277    /// Launch a single-echo Blueprint with the given launch- and
1278    /// Blueprint-tier `check_policy`, then read back the `check_policy` that
1279    /// the dispatcher stamped onto the dispatched step's `TaskSpec`. The
1280    /// launch may complete (Silent / Warn / None → fail-open) — the in-process
1281    /// RustFn worker fire-and-forgets its submit — so the task and its
1282    /// resolved spec exist regardless of the launch outcome.
1283    ///
1284    /// `task_input` carries a `work_dir` unconditionally (a dummy path, not
1285    /// resolved on disk) so the pre-dispatch guard (a strict effective
1286    /// policy with no roots supplied rejects before dispatch) never fires
1287    /// here — this helper's whole point is "reach dispatch and read back
1288    /// the stamp", so every case (including the two whose
1289    /// `bp_policy`/`launch_policy` alone resolve to Strict) must dispatch
1290    /// uniformly. The guard's own rejection behavior is proven separately
1291    /// (T3/T4 and `strict_blueprint_without_roots_is_rejected_pre_dispatch`).
1292    async fn dispatched_check_policy(
1293        launch_policy: Option<CheckPolicy>,
1294        bp_policy: Option<CheckPolicy>,
1295    ) -> Option<CheckPolicy> {
1296        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1297            Ok(WorkerResult {
1298                value: json!({ "echoed": inv.prompt }),
1299                ok: true,
1300            })
1301        });
1302        let svc = build_service(factory);
1303        let mut blueprint = bp(
1304            step("echo", path("$.input"), path("$.out")),
1305            vec![agent("echo", "echo")],
1306        );
1307        blueprint.check_policy = bp_policy;
1308        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1309        input.check_policy = launch_policy;
1310        input.task_input = Some(TaskInputSpec {
1311            project_root: None,
1312            work_dir: Some("/dispatched-check-policy-test-root".to_string()),
1313            task_metadata: None,
1314        });
1315        let _ = svc.launch(input).await;
1316        svc.engine()
1317            .with_state("test.read_dispatched_check_policy", |s| {
1318                s.tasks
1319                    .values()
1320                    .find(|t| t.spec.agent == "echo")
1321                    .and_then(|t| t.spec.check_policy)
1322            })
1323            .await
1324            .expect("with_state")
1325    }
1326
1327    /// T2 case 1: launch `Some(Silent)` + BP `Some(Strict)` → TaskSpec
1328    /// `Some(Silent)` (the launch-request tier outranks the Blueprint tier).
1329    #[tokio::test]
1330    async fn cascade_launch_tier_wins_over_blueprint_tier() {
1331        assert_eq!(
1332            dispatched_check_policy(Some(CheckPolicy::Silent), Some(CheckPolicy::Strict)).await,
1333            Some(CheckPolicy::Silent),
1334        );
1335    }
1336
1337    /// T2 case 2: launch `None` + BP `Some(Strict)` → TaskSpec `Some(Strict)`
1338    /// (the Blueprint tier takes effect when the launch tier is unset).
1339    #[tokio::test]
1340    async fn cascade_blueprint_tier_used_when_launch_absent() {
1341        assert_eq!(
1342            dispatched_check_policy(None, Some(CheckPolicy::Strict)).await,
1343            Some(CheckPolicy::Strict),
1344        );
1345    }
1346
1347    /// T2 case 3: launch `Some(Strict)` + BP `None` → TaskSpec `Some(Strict)`
1348    /// (the launch tier alone resolves when the Blueprint tier is unset).
1349    #[tokio::test]
1350    async fn cascade_launch_tier_alone_when_blueprint_absent() {
1351        assert_eq!(
1352            dispatched_check_policy(Some(CheckPolicy::Strict), None).await,
1353            Some(CheckPolicy::Strict),
1354        );
1355    }
1356
1357    /// T2 case 4: launch `None` + BP `None` → TaskSpec `None`. NOT omitted as
1358    /// "trivial": this is the backward-compat proof — the server-fallback
1359    /// path (`EngineCfg.check_policy` decides at the submit-time sink) is
1360    /// preserved byte-for-byte because the carrier stays `None`.
1361    #[tokio::test]
1362    async fn cascade_both_none_preserves_server_fallback() {
1363        assert_eq!(dispatched_check_policy(None, None).await, None);
1364    }
1365
1366    /// Repurposed 2026-07-16 for the pre-dispatch guard's new contract
1367    /// (the launch-time validation stage of the check_policy cascade
1368    /// work). This test used
1369    /// to prove a strict + no-roots launch dispatched a step that then hit
1370    /// `EngineError::CheckPolicyStrict` at submit time — exactly the path
1371    /// the pre-dispatch guard now forecloses (a strict launch with no
1372    /// resolvable root is rejected BEFORE dispatch instead, see
1373    /// [`TaskLaunchService::launch`]'s guard). The two sub-assertions this
1374    /// test used to make are independently covered elsewhere: the
1375    /// cascade-resolved Strict reaching the dispatched `TaskSpec` is
1376    /// covered by the `cascade_*` tests above; the submit-time sink
1377    /// surfacing `CheckPolicyStrict` on an unresolved root is covered by
1378    /// `crate::core::engine::tests::submit_output_final_check_policy_strict_surfaces_error_when_root_unresolved`
1379    /// (seeds the task directly at the engine layer, bypassing `launch`).
1380    /// This test now asserts the NEW contract directly.
1381    #[tokio::test]
1382    async fn strict_blueprint_without_roots_is_rejected_pre_dispatch() {
1383        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1384            Ok(WorkerResult {
1385                value: json!({ "echoed": inv.prompt }),
1386                ok: true,
1387            })
1388        });
1389        let svc = build_service(factory);
1390        let mut blueprint = bp(
1391            step("echo", path("$.input"), path("$.out")),
1392            vec![agent("echo", "echo")],
1393        );
1394        blueprint.check_policy = Some(CheckPolicy::Strict);
1395        // No task_input → no work_dir/project_root ever resolves.
1396        let err = svc
1397            .launch(launch_input(blueprint, json!({ "input": "hi" })))
1398            .await
1399            .expect_err("strict check_policy + no roots must be rejected before dispatch");
1400        match err {
1401            TaskLaunchError::PreDispatch(message) => {
1402                assert!(
1403                    message.contains("strict"),
1404                    "message must identify the strict-requires-roots condition: {message}"
1405                );
1406            }
1407            other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1408        }
1409
1410        // No step was ever dispatched — the guard fires after
1411        // `engine.attach_with_ids` (the token mint) but before the
1412        // dispatcher is ever built / `eval_async_externs` runs.
1413        let dispatched = svc
1414            .engine()
1415            .with_state("test.no_echo_task_dispatched", |s| {
1416                s.tasks.values().any(|t| t.spec.agent == "echo")
1417            })
1418            .await
1419            .expect("with_state");
1420        assert!(
1421            !dispatched,
1422            "the pre-dispatch guard must reject before any step is dispatched"
1423        );
1424    }
1425
1426    /// T4 (cascade backward-compat): backward compat — with NO check_policy
1427    /// anywhere (BP tier + launch tier both `None`), the launch resolves to
1428    /// the server default (Warn) and completes fail-open exactly as before
1429    /// this change (the warn-mode materialize skip never turns a
1430    /// successful submit into a failure).
1431    ///
1432    /// This is ALSO the pre-dispatch guard's backward-compat case (T5):
1433    /// `task_input` is `None` via [`launch_input`]/[`TaskLaunchInput::automate`],
1434    /// so the guard's effective policy resolves to `Warn` (server default,
1435    /// [`EngineCfg::default`]) and never fires — the guard changes nothing
1436    /// about this pre-existing default-path behavior.
1437    #[tokio::test]
1438    async fn launch_without_any_check_policy_completes_fail_open() {
1439        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1440            Ok(WorkerResult {
1441                value: json!({ "echoed": inv.prompt }),
1442                ok: true,
1443            })
1444        });
1445        let svc = build_service(factory);
1446        let blueprint = bp(
1447            step("echo", path("$.input"), path("$.out")),
1448            vec![agent("echo", "echo")],
1449        );
1450        assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1451        let out = svc
1452            .launch(launch_input(blueprint, json!({ "input": "hi" })))
1453            .await
1454            .expect("warn-mode fail-open must let the launch complete");
1455        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1456    }
1457
1458    // ──────────────────────────────────────────────────────────────────
1459    // pre-dispatch validation guard:
1460    // `TaskLaunchService::launch` rejects BEFORE dispatch when the
1461    // effective check_policy is Strict and neither `project_root` nor
1462    // `work_dir` is supplied. T3/T4/T6 live here (T1/T2 are
1463    // handler-level, in `mlua-swarm-server`'s `projection.rs`; T5 is the
1464    // `launch_without_any_check_policy_completes_fail_open` test above;
1465    // the guard-rejection end-to-end case is
1466    // `strict_blueprint_without_roots_is_rejected_pre_dispatch` above,
1467    // Option A's repurpose of the former stage-1 T3).
1468    // ──────────────────────────────────────────────────────────────────
1469
1470    /// T3 (Crux 3, escape hatch): a Blueprint declaring `check_policy:
1471    /// strict` is overridden by the launch-request tier's `check_policy:
1472    /// Some(Warn)` — tier 1 wins the cascade before the guard's
1473    /// effective-policy fallback ever applies, so the guard passes and the
1474    /// launch dispatches normally even though `task_input` is `None` (no
1475    /// project_root/work_dir at all). Regression guard against a future
1476    /// "the guard judges by the BP tier alone, not the effective/cascaded
1477    /// value" narrowing.
1478    #[tokio::test]
1479    async fn strict_blueprint_with_launch_warn_override_bypasses_pre_dispatch_guard() {
1480        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1481            Ok(WorkerResult {
1482                value: json!({ "echoed": inv.prompt }),
1483                ok: true,
1484            })
1485        });
1486        let svc = build_service(factory);
1487        let mut blueprint = bp(
1488            step("echo", path("$.input"), path("$.out")),
1489            vec![agent("echo", "echo")],
1490        );
1491        blueprint.check_policy = Some(CheckPolicy::Strict);
1492        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1493        input.check_policy = Some(CheckPolicy::Warn);
1494        assert!(input.task_input.is_none(), "no roots supplied at all");
1495        let out = svc
1496            .launch(input)
1497            .await
1498            .expect("launch-tier warn override must bypass the pre-dispatch guard");
1499        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1500    }
1501
1502    /// T4 (Crux 2, server tier): with BOTH the launch- and Blueprint-tier
1503    /// `check_policy` unset, the server-wide `EngineCfg.check_policy` (the
1504    /// third cascade tier, read via `self.engine.cfg()`) alone must drive
1505    /// the guard — proof the guard does not stop at the "BP/launch 2-tier"
1506    /// shortcut Crux 2 forbids.
1507    #[tokio::test]
1508    async fn server_tier_strict_alone_triggers_pre_dispatch_guard() {
1509        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1510            Ok(WorkerResult {
1511                value: json!({ "echoed": inv.prompt }),
1512                ok: true,
1513            })
1514        });
1515        let svc = build_service_with_cfg(
1516            factory,
1517            EngineCfg {
1518                check_policy: CheckPolicy::Strict,
1519                ..EngineCfg::default()
1520            },
1521        );
1522        let blueprint = bp(
1523            step("echo", path("$.input"), path("$.out")),
1524            vec![agent("echo", "echo")],
1525        );
1526        assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1527        let input = launch_input(blueprint, json!({ "input": "hi" }));
1528        assert!(input.check_policy.is_none(), "launch tier must be unset");
1529        assert!(input.task_input.is_none(), "no roots supplied");
1530        let err = svc.launch(input).await.expect_err(
1531            "server-tier Strict alone (BP/launch tiers both unset) must trigger the guard",
1532        );
1533        match err {
1534            TaskLaunchError::PreDispatch(message) => {
1535                assert!(
1536                    message.contains("strict"),
1537                    "expected the strict-requires-roots message, got: {message}"
1538                );
1539            }
1540            other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1541        }
1542    }
1543
1544    /// T6 (guard condition, branch 2 of 3): `task_input: Some(_)` with
1545    /// BOTH `project_root` and `work_dir` absent is still `roots_missing`
1546    /// — the outer `Some` alone must not short-circuit the check (branch 1,
1547    /// `task_input: None`, is covered by
1548    /// `strict_blueprint_without_roots_is_rejected_pre_dispatch` above).
1549    #[tokio::test]
1550    async fn pre_dispatch_guard_rejects_when_task_input_present_but_roots_both_none() {
1551        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1552            Ok(WorkerResult {
1553                value: json!({ "echoed": inv.prompt }),
1554                ok: true,
1555            })
1556        });
1557        let svc = build_service(factory);
1558        let mut blueprint = bp(
1559            step("echo", path("$.input"), path("$.out")),
1560            vec![agent("echo", "echo")],
1561        );
1562        blueprint.check_policy = Some(CheckPolicy::Strict);
1563        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1564        input.task_input = Some(TaskInputSpec {
1565            project_root: None,
1566            work_dir: None,
1567            task_metadata: Some(json!({ "unrelated": true })),
1568        });
1569        let err = svc
1570            .launch(input)
1571            .await
1572            .expect_err("Some(TaskInputSpec) with both roots None must still be roots_missing");
1573        assert!(
1574            matches!(err, TaskLaunchError::PreDispatch(_)),
1575            "expected TaskLaunchError::PreDispatch, got {err:?}"
1576        );
1577    }
1578
1579    /// T6 (guard condition, branch 3 of 3): `work_dir: Some(_)` alone
1580    /// (with `project_root: None`) is NOT `roots_missing` — either root
1581    /// being present is sufficient, so the guard passes and the launch
1582    /// dispatches normally.
1583    #[tokio::test]
1584    async fn pre_dispatch_guard_passes_when_work_dir_present_and_project_root_absent() {
1585        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1586            Ok(WorkerResult {
1587                value: json!({ "echoed": inv.prompt }),
1588                ok: true,
1589            })
1590        });
1591        let svc = build_service(factory);
1592        let mut blueprint = bp(
1593            step("echo", path("$.input"), path("$.out")),
1594            vec![agent("echo", "echo")],
1595        );
1596        blueprint.check_policy = Some(CheckPolicy::Strict);
1597        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1598        input.task_input = Some(TaskInputSpec {
1599            project_root: None,
1600            work_dir: Some("/repo/work".to_string()),
1601            task_metadata: None,
1602        });
1603        let out = svc
1604            .launch(input)
1605            .await
1606            .expect("work_dir alone must satisfy the guard's roots_missing check");
1607        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1608    }
1609
1610    #[tokio::test]
1611    async fn launch_three_step_seq_threads_ctx_forward() {
1612        let factory = RustFnInProcessSpawnerFactory::new()
1613            .register_fn("upper", |inv| async move {
1614                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1615                Ok(WorkerResult {
1616                    value: json!(s.to_uppercase()),
1617                    ok: true,
1618                })
1619            })
1620            .register_fn("suffix", |inv| async move {
1621                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1622                Ok(WorkerResult {
1623                    value: json!(format!("{s}!")),
1624                    ok: true,
1625                })
1626            })
1627            .register_fn("wrap", |inv| async move {
1628                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1629                Ok(WorkerResult {
1630                    value: json!(format!("[{s}]")),
1631                    ok: true,
1632                })
1633            });
1634        let svc = build_service(factory);
1635        let flow = FlowNode::Seq {
1636            children: vec![
1637                step("upper", path("$.in"), path("$.s1")),
1638                step("suffix", path("$.s1"), path("$.s2")),
1639                step("wrap", path("$.s2"), path("$.s3")),
1640            ],
1641        };
1642        let blueprint = bp(
1643            flow,
1644            vec![
1645                agent("upper", "upper"),
1646                agent("suffix", "suffix"),
1647                agent("wrap", "wrap"),
1648            ],
1649        );
1650        let out = svc
1651            .launch(launch_input(blueprint, json!({ "in": "hello" })))
1652            .await
1653            .expect("launch ok");
1654        assert_eq!(out.final_ctx["s1"], "HELLO");
1655        assert_eq!(out.final_ctx["s2"], "HELLO!");
1656        assert_eq!(out.final_ctx["s3"], "[HELLO!]");
1657    }
1658
1659    #[tokio::test]
1660    async fn launch_fanout_join_all_parallel_completes() {
1661        use std::sync::atomic::{AtomicU32, Ordering};
1662        let counter = Arc::new(AtomicU32::new(0));
1663        let max_seen = Arc::new(AtomicU32::new(0));
1664        let counter_clone = counter.clone();
1665        let max_clone = max_seen.clone();
1666
1667        // Each worker bumps the inflight counter up, sleeps 50ms, then bumps it down.
1668        // When parallel execution is working, max inflight exceeds 1.
1669        let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
1670            let counter = counter_clone.clone();
1671            let max_seen = max_clone.clone();
1672            async move {
1673                let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
1674                let mut prev = max_seen.load(Ordering::SeqCst);
1675                while now > prev {
1676                    match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
1677                        Ok(_) => break,
1678                        Err(p) => prev = p,
1679                    }
1680                }
1681                tokio::time::sleep(Duration::from_millis(50)).await;
1682                counter.fetch_sub(1, Ordering::SeqCst);
1683                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1684                Ok(WorkerResult {
1685                    value: json!(format!("did:{s}")),
1686                    ok: true,
1687                })
1688            }
1689        });
1690        let svc = build_service(factory);
1691        let flow = FlowNode::Fanout {
1692            items: path("$.items"),
1693            bind: path("$.item"),
1694            body: Box::new(step("para", path("$.item"), path("$.r"))),
1695            join: JoinMode::All,
1696            out: path("$.results"),
1697        };
1698        let blueprint = bp(flow, vec![agent("para", "para")]);
1699        let out = svc
1700            .launch(launch_input(
1701                blueprint,
1702                json!({ "items": ["a", "b", "c", "d"] }),
1703            ))
1704            .await
1705            .expect("launch ok");
1706        let results = out.final_ctx["results"].as_array().expect("array");
1707        assert_eq!(results.len(), 4);
1708        for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
1709            assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
1710        }
1711        let max = max_seen.load(Ordering::SeqCst);
1712        assert!(
1713            max >= 2,
1714            "expected parallel execution (max inflight >= 2), got {max}"
1715        );
1716    }
1717
1718    #[tokio::test]
1719    async fn launch_propagates_worker_error_as_flow_eval_err() {
1720        let factory = RustFnInProcessSpawnerFactory::new()
1721            .register_fn("ok", |inv| async move {
1722                Ok(WorkerResult {
1723                    value: json!(inv.prompt),
1724                    ok: true,
1725                })
1726            })
1727            .register_fn("boom", |_inv| async move {
1728                Err(WorkerError::Failed("intentional boom".into()))
1729            });
1730        let svc = build_service(factory);
1731        let flow = FlowNode::Seq {
1732            children: vec![
1733                step("ok", path("$.input"), path("$.s1")),
1734                step("boom", path("$.s1"), path("$.s2")),
1735                step("ok", path("$.s2"), path("$.s3")),
1736            ],
1737        };
1738        let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
1739        let err = svc
1740            .launch(launch_input(blueprint, json!({ "input": "x" })))
1741            .await
1742            .expect_err("expected fail");
1743        match err {
1744            TaskLaunchError::FlowEval(msg) => {
1745                assert!(
1746                    msg.contains("boom") || msg.contains("intentional"),
1747                    "expected error to mention worker failure, got: {msg}"
1748                );
1749            }
1750            other => panic!("expected FlowEval error, got {other:?}"),
1751        }
1752    }
1753
1754    #[tokio::test]
1755    async fn launch_resolves_call_extern_via_registered_externs() {
1756        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1757            Ok(WorkerResult {
1758                value: json!({ "echoed": inv.prompt }),
1759                ok: true,
1760            })
1761        });
1762        let mut externs = mlua_flow_ir::ExternMap::new();
1763        externs.register("fmt.greet", |args: &[Value]| {
1764            let name = args[0].as_str().unwrap_or("?");
1765            Ok(json!(format!("hello, {name}")))
1766        });
1767        let svc = build_service(factory).with_externs(Arc::new(externs));
1768        let flow = step(
1769            "echo",
1770            Expr::CallExtern {
1771                ref_: "fmt.greet".into(),
1772                args: vec![path("$.who")],
1773            },
1774            path("$.out"),
1775        );
1776        let blueprint = bp(flow, vec![agent("echo", "echo")]);
1777        let out = svc
1778            .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1779            .await
1780            .expect("launch ok");
1781        assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1782    }
1783
1784    #[tokio::test]
1785    async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1786        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1787            Ok(WorkerResult {
1788                value: json!(inv.prompt),
1789                ok: true,
1790            })
1791        });
1792        let svc = build_service(factory); // default NoExterns
1793        let flow = step(
1794            "echo",
1795            Expr::CallExtern {
1796                ref_: "fmt.greet".into(),
1797                args: vec![],
1798            },
1799            path("$.out"),
1800        );
1801        let blueprint = bp(flow, vec![agent("echo", "echo")]);
1802        let err = svc
1803            .launch(launch_input(blueprint, json!({})))
1804            .await
1805            .expect_err("expected fail");
1806        match err {
1807            TaskLaunchError::FlowEval(msg) => {
1808                assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1809            }
1810            other => panic!("expected FlowEval error, got {other:?}"),
1811        }
1812    }
1813
1814    // ──────────────────────────────────────────────────────────────────
1815    // GH #50 (Subtask 2 follow-up): `TaskLaunchService::launch`'s
1816    // `compiler.compile` → `engine.register_verdict_contracts(...)` call
1817    // site — task_launch-level end-to-end (compile → register →
1818    // `Engine::verdict_contract_for_task` resolves it). The full HTTP
1819    // submit-time-422 round trip is covered separately: handler-level in
1820    // `crates/mlua-swarm-server/src/worker.rs`'s own `#[cfg(test)] mod
1821    // tests` GH #50 section (which seeds `Engine::register_verdict_contracts`
1822    // directly, bypassing this launch path since `mlua-swarm-server`
1823    // cannot depend on this crate's private test helpers) and
1824    // process-boundary-HTTP in
1825    // `crates/mlua-swarm-server/tests/verdict_contract.rs`. This test is
1826    // the missing link between those two: it exercises the REAL
1827    // `TaskLaunchService::launch` call site (not a hand-rolled duplicate
1828    // of its two lines) end-to-end through a real `Compiler::compile`,
1829    // proving the production wiring this follow-up added actually
1830    // populates the registry `Engine::verdict_contract_for_task` reads.
1831    // ──────────────────────────────────────────────────────────────────
1832
1833    #[tokio::test]
1834    async fn launch_registers_the_blueprints_verdict_contracts_into_the_engine() {
1835        let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |inv| async move {
1836            Ok(WorkerResult {
1837                value: json!(inv.prompt),
1838                ok: true,
1839            })
1840        });
1841        let svc = build_service(factory);
1842        let mut gate_agent = agent("gate", "gate");
1843        gate_agent.verdict = Some(mlua_swarm_schema::VerdictContract {
1844            channel: mlua_swarm_schema::VerdictChannel::Body,
1845            values: vec!["PASS".to_string(), "BLOCKED".to_string()],
1846        });
1847        let flow = step("gate", path("$.input"), path("$.out"));
1848        let blueprint = bp(flow, vec![gate_agent]);
1849
1850        let out = svc
1851            .launch(launch_input(blueprint, json!({ "input": "PASS" })))
1852            .await
1853            .expect("launch ok");
1854        assert_eq!(out.final_ctx["out"], json!("PASS"));
1855
1856        // `EngineDispatcher::dispatch` calls `engine.start_task` for every
1857        // dispatched Step (`TaskSpec.agent = ref_`) — this single-Step
1858        // Blueprint against a fresh per-test `Engine` (`build_service`)
1859        // leaves exactly one entry in `EngineState.tasks`.
1860        let task_id = svc
1861            .engine()
1862            .with_state("test.find_dispatched_task_id", |s| {
1863                s.tasks.keys().next().cloned()
1864            })
1865            .await
1866            .expect("with_state")
1867            .expect("launch must have dispatched exactly one Step (one TaskState)");
1868
1869        let contract = svc
1870            .engine()
1871            .verdict_contract_for_task(&task_id)
1872            .await
1873            .expect(
1874                "TaskLaunchService::launch must have merged this Blueprint's compiled \
1875                 verdict_contracts into the engine's runtime registry \
1876                 (Engine::register_verdict_contracts, called right after \
1877                 compiler.compile succeeds) — verdict_contract_for_task resolving None \
1878                 here means that production wiring regressed",
1879            );
1880        assert_eq!(contract.channel, mlua_swarm_schema::VerdictChannel::Body);
1881        assert_eq!(
1882            contract.values,
1883            vec!["PASS".to_string(), "BLOCKED".to_string()]
1884        );
1885    }
1886
1887    // ──────────────────────────────────────────────────────────────────
1888    // issue #13 run_id propagation (`TaskLaunchInput.run_ctx`)
1889    // ──────────────────────────────────────────────────────────────────
1890
1891    #[tokio::test]
1892    async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
1893        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
1894        use crate::types::{RunId, TaskId};
1895
1896        let factory = RustFnInProcessSpawnerFactory::new()
1897            .register_fn("upper", |inv| async move {
1898                Ok(WorkerResult {
1899                    value: json!(inv.prompt.to_uppercase()),
1900                    ok: true,
1901                })
1902            })
1903            .register_fn("suffix", |inv| async move {
1904                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1905                Ok(WorkerResult {
1906                    value: json!(format!("{s}!")),
1907                    ok: true,
1908                })
1909            });
1910        let svc = build_service(factory);
1911        let flow = FlowNode::Seq {
1912            children: vec![
1913                step("upper", path("$.in"), path("$.s1")),
1914                step("suffix", path("$.s1"), path("$.s2")),
1915            ],
1916        };
1917        let blueprint = bp(
1918            flow,
1919            vec![agent("upper", "upper"), agent("suffix", "suffix")],
1920        );
1921
1922        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1923        let run_id = RunId::new();
1924        run_store
1925            .create(RunRecord {
1926                id: run_id.clone(),
1927                task_id: TaskId::new(),
1928                status: RunStatus::Running,
1929                step_entries: Vec::new(),
1930                degradations: Vec::new(),
1931                operator_sid: None,
1932                result_ref: None,
1933                input_json: Some("{}".to_string()),
1934                created_at: 0,
1935                updated_at: 0,
1936            })
1937            .await
1938            .expect("seed RunRecord");
1939
1940        let mut input = launch_input(blueprint, json!({ "in": "hi" }));
1941        input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
1942
1943        let out = svc.launch(input).await.expect("launch ok");
1944        assert_eq!(out.final_ctx["s2"], "HI!");
1945
1946        let run = run_store.get(&run_id).await.expect("run present");
1947        assert_eq!(
1948            run.step_entries.len(),
1949            2,
1950            "expected one step_entry per dispatched step, got {:?}",
1951            run.step_entries
1952        );
1953        assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
1954        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1955        assert!(run.step_entries[0].binding_digest.is_some());
1956        assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
1957        assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1958        assert!(run.step_entries[1].binding_digest.is_some());
1959        let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
1960        assert_eq!(snapshot["bound_agents"].as_array().unwrap().len(), 2);
1961    }
1962
1963    #[tokio::test]
1964    async fn run_snapshot_reuses_bound_agent_after_blueprint_mutation() {
1965        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
1966        use crate::types::{RunId, TaskId};
1967
1968        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1969        let run_id = RunId::new();
1970        run_store
1971            .create(RunRecord {
1972                id: run_id.clone(),
1973                task_id: TaskId::new(),
1974                status: RunStatus::Running,
1975                step_entries: Vec::new(),
1976                degradations: Vec::new(),
1977                operator_sid: None,
1978                result_ref: None,
1979                input_json: Some("{}".to_string()),
1980                created_at: 0,
1981                updated_at: 0,
1982            })
1983            .await
1984            .unwrap();
1985        let run_ctx = RunContext::new(run_id, run_store);
1986        let mut original_agent = agent("worker", "worker");
1987        original_agent.profile = Some(crate::blueprint::AgentProfile {
1988            system_prompt: "original role".to_string(),
1989            ..Default::default()
1990        });
1991        let mut blueprint = bp(
1992            step("worker", path("$.input"), path("$.out")),
1993            vec![original_agent],
1994        );
1995
1996        let (original, _) = load_or_resolve_bound_agents(
1997            &blueprint,
1998            Some(&run_ctx),
1999            None,
2000            LegacyWorkerBindingPolicy::Allow,
2001        )
2002        .await
2003        .unwrap();
2004        blueprint.agents[0].profile.as_mut().unwrap().system_prompt = "mutated role".to_string();
2005        let (restored, _) = load_or_resolve_bound_agents(
2006            &blueprint,
2007            Some(&run_ctx),
2008            None,
2009            LegacyWorkerBindingPolicy::Allow,
2010        )
2011        .await
2012        .unwrap();
2013
2014        assert_eq!(restored[0].binding_digest, original[0].binding_digest);
2015        assert_eq!(
2016            restored[0].agent.profile.as_ref().unwrap().system_prompt,
2017            "original role"
2018        );
2019    }
2020
2021    #[tokio::test]
2022    async fn strict_migration_policy_rejects_fresh_legacy_worker_binding() {
2023        let mut legacy_agent = agent("worker", "worker");
2024        legacy_agent.profile = Some(AgentProfile {
2025            worker_binding: Some("legacy-worker".to_string()),
2026            ..Default::default()
2027        });
2028        let blueprint = bp(
2029            step("worker", path("$.input"), path("$.out")),
2030            vec![legacy_agent],
2031        );
2032
2033        let error =
2034            load_or_resolve_bound_agents(&blueprint, None, None, LegacyWorkerBindingPolicy::Reject)
2035                .await
2036                .expect_err("strict migration policy must reject fallback");
2037        assert!(error
2038            .to_string()
2039            .contains("deprecated profile.worker_binding"));
2040    }
2041
2042    #[tokio::test]
2043    async fn run_snapshot_calls_binding_provider_only_on_first_resolution() {
2044        use crate::binding::{AgentBindingProvider, BindingProviderError};
2045        use crate::blueprint::{BindOutcome, BindReceipt, BindRequest};
2046        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2047        use crate::types::{RunId, TaskId};
2048        use std::sync::atomic::{AtomicUsize, Ordering};
2049
2050        struct CountingProvider(AtomicUsize);
2051
2052        #[async_trait::async_trait]
2053        impl AgentBindingProvider for CountingProvider {
2054            async fn bind(
2055                &self,
2056                requests: &[BindRequest],
2057            ) -> Result<Vec<BindOutcome>, BindingProviderError> {
2058                self.0.fetch_add(1, Ordering::SeqCst);
2059                Ok(requests
2060                    .iter()
2061                    .map(|request| BindOutcome::Bound {
2062                        receipt: BindReceipt {
2063                            agent: request.agent.clone(),
2064                            request_digest: request.request_digest.clone(),
2065                            provider_id: "operator-main-ai".to_string(),
2066                            provider_revision: Some("test".to_string()),
2067                            resolved_model: request.requested_model.clone(),
2068                            effective_tools: request.requested_tools.clone(),
2069                            launch_variant: request.launch_variant.clone(),
2070                            capability_snapshot_digest: None,
2071                        },
2072                    })
2073                    .collect())
2074            }
2075        }
2076
2077        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2078        let run_id = RunId::new();
2079        run_store
2080            .create(RunRecord {
2081                id: run_id.clone(),
2082                task_id: TaskId::new(),
2083                status: RunStatus::Running,
2084                step_entries: Vec::new(),
2085                degradations: Vec::new(),
2086                operator_sid: None,
2087                result_ref: None,
2088                input_json: Some("{}".to_string()),
2089                created_at: 0,
2090                updated_at: 0,
2091            })
2092            .await
2093            .unwrap();
2094        let run_ctx = RunContext::new(run_id, run_store);
2095        let mut blueprint = bp(
2096            step("worker", path("$.input"), path("$.out")),
2097            vec![agent("worker", "worker")],
2098        );
2099        blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2100            variant: "mse-worker".to_string(),
2101            tools: vec!["Read".to_string()],
2102        });
2103        let provider = CountingProvider(AtomicUsize::new(0));
2104
2105        let (first, _) = load_or_resolve_bound_agents(
2106            &blueprint,
2107            Some(&run_ctx),
2108            Some(&provider),
2109            LegacyWorkerBindingPolicy::Allow,
2110        )
2111        .await
2112        .unwrap();
2113        let (restored, _) = load_or_resolve_bound_agents(
2114            &blueprint,
2115            Some(&run_ctx),
2116            Some(&provider),
2117            LegacyWorkerBindingPolicy::Allow,
2118        )
2119        .await
2120        .unwrap();
2121
2122        assert_eq!(provider.0.load(Ordering::SeqCst), 1);
2123        assert!(first[0].attestation.is_some());
2124        assert_eq!(restored, first);
2125    }
2126
2127    // ──────────────────────────────────────────────────────────────────
2128    // D1: snapshot origin marker (`bound_agents_origin`)
2129    // ──────────────────────────────────────────────────────────────────
2130
2131    /// An initial launch (RunContext `resume == false`) that has to resolve
2132    /// fresh persists `bound_agents_origin: "launch"` alongside
2133    /// `bound_agents`, and records NO backfill degradation — a first-time
2134    /// pin is not a degradation.
2135    #[tokio::test]
2136    async fn fresh_resolve_on_launch_persists_launch_origin_no_degradation() {
2137        use crate::store::run::{
2138            InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2139        };
2140        use crate::types::{RunId, TaskId};
2141
2142        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2143        let run_id = RunId::new();
2144        run_store
2145            .create(RunRecord {
2146                id: run_id.clone(),
2147                task_id: TaskId::new(),
2148                status: RunStatus::Running,
2149                step_entries: Vec::new(),
2150                degradations: Vec::new(),
2151                operator_sid: None,
2152                result_ref: None,
2153                input_json: Some("{}".to_string()),
2154                created_at: 0,
2155                updated_at: 0,
2156            })
2157            .await
2158            .unwrap();
2159        // No `.with_resume()` — this is an initial launch.
2160        let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2161        let blueprint = bp(
2162            step("worker", path("$.input"), path("$.out")),
2163            vec![agent("worker", "worker")],
2164        );
2165
2166        load_or_resolve_bound_agents(
2167            &blueprint,
2168            Some(&run_ctx),
2169            None,
2170            LegacyWorkerBindingPolicy::Allow,
2171        )
2172        .await
2173        .expect("launch resolve ok");
2174
2175        let run = run_store.get(&run_id).await.expect("run present");
2176        let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2177        assert!(
2178            snapshot["bound_agents"].is_array(),
2179            "bound_agents must be persisted"
2180        );
2181        assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("launch"));
2182        assert_eq!(
2183            SnapshotOrigin::from_snapshot(&snapshot),
2184            SnapshotOrigin::Launch
2185        );
2186        assert!(
2187            run.degradations.is_empty(),
2188            "an initial-launch resolve is not a degradation"
2189        );
2190    }
2191
2192    /// A resume (RunContext `resume == true`) that has to backfill a
2193    /// pre-binding-snapshot Run persists `bound_agents_origin:
2194    /// "resume_backfill"` and appends exactly one backfill degradation
2195    /// (`fallback: "resume_backfill"`).
2196    #[tokio::test]
2197    async fn backfill_on_resume_persists_resume_origin_and_records_degradation() {
2198        use crate::store::run::{
2199            InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2200        };
2201        use crate::types::{RunId, TaskId};
2202
2203        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2204        let run_id = RunId::new();
2205        run_store
2206            .create(RunRecord {
2207                id: run_id.clone(),
2208                task_id: TaskId::new(),
2209                status: RunStatus::Running,
2210                step_entries: Vec::new(),
2211                degradations: Vec::new(),
2212                operator_sid: None,
2213                result_ref: None,
2214                input_json: Some("{}".to_string()),
2215                created_at: 0,
2216                updated_at: 0,
2217            })
2218            .await
2219            .unwrap();
2220        let run_ctx = RunContext::new(run_id.clone(), run_store.clone()).with_resume();
2221        let blueprint = bp(
2222            step("worker", path("$.input"), path("$.out")),
2223            vec![agent("worker", "worker")],
2224        );
2225
2226        load_or_resolve_bound_agents(
2227            &blueprint,
2228            Some(&run_ctx),
2229            None,
2230            LegacyWorkerBindingPolicy::Allow,
2231        )
2232        .await
2233        .expect("resume backfill ok");
2234
2235        let run = run_store.get(&run_id).await.expect("run present");
2236        let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2237        assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("resume_backfill"));
2238        assert_eq!(
2239            SnapshotOrigin::from_snapshot(&snapshot),
2240            SnapshotOrigin::ResumeBackfill
2241        );
2242        assert_eq!(
2243            run.degradations.len(),
2244            1,
2245            "a resume backfill must record exactly one degradation"
2246        );
2247        assert_eq!(run.degradations[0].tool, "binding");
2248        assert_eq!(run.degradations[0].fallback, "resume_backfill");
2249    }
2250
2251    // ──────────────────────────────────────────────────────────────────
2252    // C1: `strict_binding` gate + optional attestation
2253    // ──────────────────────────────────────────────────────────────────
2254
2255    /// A Runner-backed Blueprint whose single `worker` agent binds through a
2256    /// WS Operator variant `mse-worker` requiring tool `Read`.
2257    fn runner_blueprint(strict_binding: bool) -> Blueprint {
2258        let mut blueprint = bp(
2259            step("worker", path("$.input"), path("$.out")),
2260            vec![agent("worker", "worker")],
2261        );
2262        blueprint.strategy.strict_binding = strict_binding;
2263        blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2264            variant: "mse-worker".to_string(),
2265            tools: vec!["Read".to_string()],
2266        });
2267        blueprint
2268    }
2269
2270    /// Provider that leaves every request `Unbound` — models a missing /
2271    /// manifest-less execution environment.
2272    struct AlwaysUnboundProvider;
2273
2274    #[async_trait::async_trait]
2275    impl AgentBindingProvider for AlwaysUnboundProvider {
2276        async fn bind(
2277            &self,
2278            requests: &[crate::blueprint::BindRequest],
2279        ) -> Result<Vec<crate::blueprint::BindOutcome>, crate::binding::BindingProviderError>
2280        {
2281            Ok(requests
2282                .iter()
2283                .map(|request| crate::blueprint::BindOutcome::Unbound {
2284                    agent: request.agent.clone(),
2285                    reason: "no capability manifest submitted".to_string(),
2286                })
2287                .collect())
2288        }
2289    }
2290
2291    /// Non-strict + a provider that cannot attest → launch resolution
2292    /// succeeds, the agent stays `DeclarationOnly`, and the gap is recorded
2293    /// as a `RunRecord.degradations` entry.
2294    #[tokio::test]
2295    async fn non_strict_unbound_agent_runs_declaration_only_with_degradation() {
2296        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2297        use crate::types::{RunId, TaskId};
2298
2299        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2300        let run_id = RunId::new();
2301        run_store
2302            .create(RunRecord {
2303                id: run_id.clone(),
2304                task_id: TaskId::new(),
2305                status: RunStatus::Running,
2306                step_entries: Vec::new(),
2307                degradations: Vec::new(),
2308                operator_sid: None,
2309                result_ref: None,
2310                input_json: Some("{}".to_string()),
2311                created_at: 0,
2312                updated_at: 0,
2313            })
2314            .await
2315            .unwrap();
2316        let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2317
2318        let (bound, _) = load_or_resolve_bound_agents(
2319            &runner_blueprint(false),
2320            Some(&run_ctx),
2321            Some(&AlwaysUnboundProvider),
2322            LegacyWorkerBindingPolicy::Allow,
2323        )
2324        .await
2325        .expect("non-strict launch must succeed even without an attestation");
2326        assert!(
2327            bound[0].attestation.is_none(),
2328            "an unattested agent must stay DeclarationOnly"
2329        );
2330
2331        let run = run_store.get(&run_id).await.expect("run present");
2332        assert_eq!(run.degradations.len(), 1, "expected one degradation entry");
2333        assert_eq!(run.degradations[0].tool, "binding");
2334        assert_eq!(run.degradations[0].fallback, "DeclarationOnly");
2335        assert!(run.degradations[0].error.contains("no capability manifest"));
2336    }
2337
2338    /// Strict + a provider that cannot attest → launch fails, and the error
2339    /// message names the agent and its requested launch variant / tools so an
2340    /// Operator can generate a satisfying manifest.
2341    #[tokio::test]
2342    async fn strict_unbound_agent_fails_with_requirements_in_message() {
2343        let error = load_or_resolve_bound_agents(
2344            &runner_blueprint(true),
2345            None,
2346            Some(&AlwaysUnboundProvider),
2347            LegacyWorkerBindingPolicy::Allow,
2348        )
2349        .await
2350        .expect_err("strict + Unbound must reject the launch");
2351        match error {
2352            TaskLaunchError::PreDispatch(message) => {
2353                assert!(message.contains("worker"), "message: {message}");
2354                assert!(message.contains("mse-worker"), "message: {message}");
2355                assert!(message.contains("Read"), "message: {message}");
2356            }
2357            other => panic!("expected PreDispatch, got {other:?}"),
2358        }
2359    }
2360
2361    /// Strict + no provider at all → launch fails fast: nothing can attest the
2362    /// Runner-backed agent.
2363    #[tokio::test]
2364    async fn strict_without_provider_rejects_runner_backed_launch() {
2365        let error = load_or_resolve_bound_agents(
2366            &runner_blueprint(true),
2367            None,
2368            None,
2369            LegacyWorkerBindingPolicy::Allow,
2370        )
2371        .await
2372        .expect_err("strict + no provider must reject a Runner-backed launch");
2373        match error {
2374            TaskLaunchError::PreDispatch(message) => {
2375                assert!(
2376                    message.contains("strict_binding requires a binding provider"),
2377                    "message: {message}"
2378                );
2379            }
2380            other => panic!("expected PreDispatch, got {other:?}"),
2381        }
2382    }
2383
2384    /// Strict + a correct manifest → the agent is Attested (the pre-C1 pass
2385    /// path still holds under the strict gate).
2386    #[tokio::test]
2387    async fn strict_with_correct_manifest_attests_the_agent() {
2388        use crate::binding::ManifestBindingProvider;
2389        use crate::blueprint::{AgentProviderCapability, AgentProviderManifest};
2390
2391        let provider = ManifestBindingProvider::new(AgentProviderManifest {
2392            provider_id: "operator-main-ai".to_string(),
2393            provider_revision: Some("1".to_string()),
2394            capabilities: vec![AgentProviderCapability {
2395                launch_variant: Some("mse-worker".to_string()),
2396                resolved_model: None,
2397                effective_tools: vec!["Read".to_string()],
2398                capability_snapshot_digest: None,
2399            }],
2400        });
2401        let (bound, _) = load_or_resolve_bound_agents(
2402            &runner_blueprint(true),
2403            None,
2404            Some(&provider),
2405            LegacyWorkerBindingPolicy::Allow,
2406        )
2407        .await
2408        .expect("strict launch with a correct manifest must attest");
2409        assert!(
2410            bound[0].attestation.is_some(),
2411            "a correctly attested agent must carry its attestation"
2412        );
2413    }
2414
2415    // ──────────────────────────────────────────────────────────────────
2416    // C2: spawn-frame self-check inputs (request_digest / requested_model)
2417    // ──────────────────────────────────────────────────────────────────
2418
2419    /// The launch-path `WorkerBinding` map carries the requesting side's
2420    /// self-check inputs: the immutable snapshot's `binding_digest` and the
2421    /// profile's declared model, so a non-strict Operator can compare the
2422    /// spawn frame against its own environment.
2423    #[test]
2424    fn worker_bindings_carry_request_digest_and_model() {
2425        let mut blueprint = runner_blueprint(false);
2426        blueprint.agents[0].profile = Some(AgentProfile {
2427            model: Some("claude-sonnet".to_string()),
2428            ..Default::default()
2429        });
2430        let bound = resolve_bound_agents(&blueprint).expect("resolvable Runner refs");
2431        let bindings = worker_bindings_from_bound_agents(&bound);
2432
2433        let wb = bindings.get("worker").expect("worker binding present");
2434        assert_eq!(
2435            wb.request_digest.as_ref(),
2436            Some(&bound[0].binding_digest),
2437            "the spawn frame must carry the immutable snapshot digest"
2438        );
2439        assert!(wb
2440            .request_digest
2441            .as_ref()
2442            .unwrap()
2443            .as_str()
2444            .starts_with("sha256:"));
2445        assert_eq!(wb.requested_model.as_deref(), Some("claude-sonnet"));
2446    }
2447
2448    /// A Runner-backed agent whose profile declares no model leaves
2449    /// `requested_model` `None` while still carrying the digest.
2450    #[test]
2451    fn worker_bindings_omit_model_when_profile_has_none() {
2452        let bound = resolve_bound_agents(&runner_blueprint(false)).expect("resolvable Runner refs");
2453        let bindings = worker_bindings_from_bound_agents(&bound);
2454        let wb = bindings.get("worker").expect("worker binding present");
2455        assert!(wb.request_digest.is_some());
2456        assert!(wb.requested_model.is_none());
2457    }
2458
2459    #[tokio::test]
2460    async fn launch_without_run_ctx_appends_no_step_entries() {
2461        // `run_ctx: None` (the `automate()` default) must not touch any
2462        // `RunStore` — this is the pre-existing no-tracing behavior, kept
2463        // as a regression guard alongside the `Some` case above.
2464        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2465            Ok(WorkerResult {
2466                value: json!(inv.prompt),
2467                ok: true,
2468            })
2469        });
2470        let svc = build_service(factory);
2471        let blueprint = bp(
2472            step("echo", path("$.input"), path("$.out")),
2473            vec![agent("echo", "echo")],
2474        );
2475        let input = launch_input(blueprint, json!({ "input": "hi" }));
2476        assert!(
2477            input.run_ctx.is_none(),
2478            "automate() defaults run_ctx to None"
2479        );
2480        let out = svc.launch(input).await.expect("launch ok");
2481        assert_eq!(out.final_ctx["out"], "hi");
2482    }
2483
2484    // ──────────────────────────────────────────────────────────────────
2485    // issue #19 ST2: `TaskLaunchInput.task_input` (direct-sibling-read
2486    // replacement for the ST1 `from_init_ctx(&input.init_ctx)` call)
2487    // ──────────────────────────────────────────────────────────────────
2488
2489    #[tokio::test]
2490    async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
2491        // Issue #19 ST2 invariant: `init_ctx` is a pure flow-ir eval seed —
2492        // `task_input` must not be folded into it. Regression guard for the
2493        // ST1 `resolve_task_level_init_ctx` fold-back this subtask removes:
2494        // if it ever crept back in here, `project_root` / `work_dir` /
2495        // `task_metadata` would leak into `final_ctx` as extra top-level
2496        // keys nobody wrote via a `Step.out`.
2497        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2498            Ok(WorkerResult {
2499                value: json!({ "echoed": inv.prompt }),
2500                ok: true,
2501            })
2502        });
2503        let svc = build_service(factory);
2504        let blueprint = bp(
2505            step("echo", path("$.input"), path("$.out")),
2506            vec![agent("echo", "echo")],
2507        );
2508        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2509        input.task_input = Some(TaskInputSpec {
2510            project_root: Some("/repo".to_string()),
2511            work_dir: Some("/repo/work".to_string()),
2512            task_metadata: Some(json!({ "issue": 19 })),
2513        });
2514        let out = svc.launch(input).await.expect("launch ok");
2515        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
2516        assert!(
2517            out.final_ctx.get("project_root").is_none(),
2518            "task_input must not be folded into the flow-ir ctx seed, got {:?}",
2519            out.final_ctx
2520        );
2521        assert!(out.final_ctx.get("work_dir").is_none());
2522        assert!(out.final_ctx.get("task_metadata").is_none());
2523    }
2524
2525    #[tokio::test]
2526    async fn launch_with_task_input_none_is_a_no_op() {
2527        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2528            Ok(WorkerResult {
2529                value: json!(inv.prompt),
2530                ok: true,
2531            })
2532        });
2533        let svc = build_service(factory);
2534        let blueprint = bp(
2535            step("echo", path("$.input"), path("$.out")),
2536            vec![agent("echo", "echo")],
2537        );
2538        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2539        assert!(input.task_input.is_none(), "automate() defaults to None");
2540        input.task_input = None;
2541        let out = svc.launch(input).await.expect("launch ok");
2542        assert_eq!(out.final_ctx["out"], "hi");
2543    }
2544
2545    #[tokio::test]
2546    async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
2547        // `Some(TaskInputSpec::default())` — outer Some, all 3 inner fields
2548        // None — must behave identically to `task_input: None` (mirrors
2549        // `TaskInputMiddleware::new_from_fields`'s own no-op contract).
2550        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2551            Ok(WorkerResult {
2552                value: json!(inv.prompt),
2553                ok: true,
2554            })
2555        });
2556        let svc = build_service(factory);
2557        let blueprint = bp(
2558            step("echo", path("$.input"), path("$.out")),
2559            vec![agent("echo", "echo")],
2560        );
2561        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2562        input.task_input = Some(TaskInputSpec::default());
2563        let out = svc.launch(input).await.expect("launch ok");
2564        assert_eq!(out.final_ctx["out"], "hi");
2565    }
2566
2567    // ──────────────────────────────────────────────────────────────────
2568    // issue #19 ST3: `merge_init_ctx` (BP default + Task init_ctx)
2569    // ──────────────────────────────────────────────────────────────────
2570
2571    #[test]
2572    fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
2573        let bp_default = json!({ "seeded": "from-bp" });
2574        let task = json!({});
2575        let merged = merge_init_ctx(Some(&bp_default), &task);
2576        assert_eq!(merged, json!({ "seeded": "from-bp" }));
2577    }
2578
2579    #[test]
2580    fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
2581        let bp_default = json!({});
2582        let task = json!({ "seeded": "from-task" });
2583        let merged = merge_init_ctx(Some(&bp_default), &task);
2584        assert_eq!(merged, json!({ "seeded": "from-task" }));
2585    }
2586
2587    #[test]
2588    fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
2589        let bp_default = json!({ "a": "bp", "b": "bp-only" });
2590        let task = json!({ "a": "task", "c": "task-only" });
2591        let merged = merge_init_ctx(Some(&bp_default), &task);
2592        assert_eq!(
2593            merged,
2594            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2595        );
2596    }
2597
2598    #[test]
2599    fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
2600        let bp_default = json!({ "seeded": "from-bp" });
2601        let task = json!("plain-string-seed");
2602        let merged = merge_init_ctx(Some(&bp_default), &task);
2603        assert_eq!(merged, json!("plain-string-seed"));
2604    }
2605
2606    #[test]
2607    fn merge_init_ctx_no_bp_default_is_a_no_op() {
2608        let task = json!({ "input": "hi" });
2609        let merged = merge_init_ctx(None, &task);
2610        assert_eq!(merged, task);
2611    }
2612
2613    // ──────────────────────────────────────────────────────────────────
2614    // issue #19 ST4: `merge_init_ctx_3layer` (BP default + Task + Run)
2615    // ──────────────────────────────────────────────────────────────────
2616
2617    #[test]
2618    fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
2619        // `run_override: None` must be a pure pass-through of the BP+Task
2620        // merge — this is the `POST /v1/tasks/:id/runs` no-body rekick
2621        // path, which must preserve pre-#19 behavior byte-for-byte.
2622        let bp_default = json!({ "a": "bp", "b": "bp-only" });
2623        let task = json!({ "a": "task", "c": "task-only" });
2624        let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
2625        let two_layer = merge_init_ctx(Some(&bp_default), &task);
2626        assert_eq!(three_layer, two_layer);
2627        assert_eq!(
2628            three_layer,
2629            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2630        );
2631    }
2632
2633    #[test]
2634    fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
2635        let bp_default = json!({ "a": "bp", "b": "bp-only" });
2636        let task = json!({ "a": "task", "c": "task-only" });
2637        let run_override = json!({ "a": "run", "d": "run-only" });
2638        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2639        assert_eq!(
2640            merged,
2641            json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
2642            "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
2643        );
2644    }
2645
2646    #[test]
2647    fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
2648        let bp_default = json!({ "seeded": "from-bp" });
2649        let task = json!({ "seeded": "from-task" });
2650        let run_override = json!("plain-string-run-seed");
2651        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2652        assert_eq!(merged, json!("plain-string-run-seed"));
2653    }
2654
2655    #[test]
2656    fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
2657        let task = json!({ "input": "hi" });
2658        let merged = merge_init_ctx_3layer(None, &task, None);
2659        assert_eq!(merged, task);
2660    }
2661
2662    #[tokio::test]
2663    async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
2664        // End-to-end guard: `Blueprint.default_init_ctx` actually reaches
2665        // `eval_async_externs` — not merely unit-tested in isolation.
2666        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2667            Ok(WorkerResult {
2668                value: json!(inv.prompt),
2669                ok: true,
2670            })
2671        });
2672        let svc = build_service(factory);
2673        let mut blueprint = bp(
2674            step("echo", path("$.greeting"), path("$.out")),
2675            vec![agent("echo", "echo")],
2676        );
2677        blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
2678        // Task supplies an empty object — BP default alone seeds `$.greeting`.
2679        let out = svc
2680            .launch(launch_input(blueprint, json!({})))
2681            .await
2682            .expect("launch ok");
2683        assert_eq!(out.final_ctx["out"], "hello from bp");
2684    }
2685
2686    // ──────────────────────────────────────────────────────────────────
2687    // issue #21 Phase 1: `derive_agent_ctx` / `derive_context_policies`
2688    // ──────────────────────────────────────────────────────────────────
2689
2690    fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
2691        AgentDef {
2692            name: name.to_string(),
2693            kind: AgentKind::RustFn,
2694            spec: json!({ "fn_id": fn_id }),
2695            profile: None,
2696            meta: Some(meta),
2697            runner: None,
2698            runner_ref: None,
2699            verdict: None,
2700        }
2701    }
2702
2703    #[test]
2704    fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
2705        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2706        let (global, per_agent) = derive_agent_ctx(&blueprint);
2707        assert_eq!(global, None);
2708        assert!(per_agent.is_empty());
2709    }
2710
2711    #[test]
2712    fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
2713        let mut blueprint = bp(
2714            step("echo", path("$.in"), path("$.out")),
2715            vec![
2716                agent_with_meta(
2717                    "with-ctx",
2718                    "echo",
2719                    AgentMeta {
2720                        ctx: Some(json!({ "org_conventions": "x" })),
2721                        ..Default::default()
2722                    },
2723                ),
2724                agent("no-ctx", "echo"),
2725            ],
2726        );
2727        blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
2728        let (global, per_agent) = derive_agent_ctx(&blueprint);
2729        assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
2730        assert_eq!(
2731            per_agent.len(),
2732            1,
2733            "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
2734        );
2735        assert_eq!(
2736            per_agent.get("with-ctx"),
2737            Some(&json!({ "org_conventions": "x" }))
2738        );
2739        assert!(!per_agent.contains_key("no-ctx"));
2740    }
2741
2742    #[test]
2743    fn derive_context_policies_empty_blueprint_yields_empty_state() {
2744        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2745        let (default_policy, per_agent) = derive_context_policies(&blueprint);
2746        assert_eq!(default_policy, None);
2747        assert!(per_agent.is_empty());
2748    }
2749
2750    #[test]
2751    fn derive_context_policies_populated_blueprint_yields_correct_maps() {
2752        let mut blueprint = bp(
2753            step("echo", path("$.in"), path("$.out")),
2754            vec![
2755                agent_with_meta(
2756                    "with-policy",
2757                    "echo",
2758                    AgentMeta {
2759                        context_policy: Some(ContextPolicy {
2760                            include: None,
2761                            exclude: vec!["work_dir".to_string()],
2762                            ..Default::default()
2763                        }),
2764                        ..Default::default()
2765                    },
2766                ),
2767                agent("no-policy", "echo"),
2768            ],
2769        );
2770        blueprint.default_context_policy = Some(ContextPolicy {
2771            include: Some(vec!["project_root".to_string()]),
2772            exclude: vec![],
2773            ..Default::default()
2774        });
2775        let (default_policy, per_agent) = derive_context_policies(&blueprint);
2776        assert_eq!(
2777            default_policy,
2778            Some(ContextPolicy {
2779                include: Some(vec!["project_root".to_string()]),
2780                exclude: vec![],
2781                ..Default::default()
2782            })
2783        );
2784        assert_eq!(per_agent.len(), 1);
2785        assert_eq!(
2786            per_agent.get("with-policy"),
2787            Some(&ContextPolicy {
2788                include: None,
2789                exclude: vec!["work_dir".to_string()],
2790                ..Default::default()
2791            })
2792        );
2793        assert!(!per_agent.contains_key("no-policy"));
2794    }
2795
2796    // ──────────────────────────────────────────────────────────────────
2797    // issue #21 Phase 2: `derive_step_metas` / `AgentMeta.meta_ref`
2798    // resolution inside `derive_agent_ctx`
2799    // ──────────────────────────────────────────────────────────────────
2800
2801    #[test]
2802    fn derive_step_metas_empty_blueprint_yields_empty_map() {
2803        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2804        assert!(derive_step_metas(&blueprint).is_empty());
2805    }
2806
2807    #[test]
2808    fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
2809        let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2810        blueprint.metas = vec![
2811            MetaDef {
2812                name: "heavy-scan".to_string(),
2813                ctx: json!({ "work_dir": "/x" }),
2814            },
2815            MetaDef {
2816                name: "light-scan".to_string(),
2817                ctx: json!({ "work_dir": "/y" }),
2818            },
2819        ];
2820        let metas = derive_step_metas(&blueprint);
2821        assert_eq!(metas.len(), 2);
2822        assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
2823        assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
2824    }
2825
2826    #[test]
2827    fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
2828        let mut blueprint = bp(
2829            step("echo", path("$.in"), path("$.out")),
2830            vec![agent_with_meta(
2831                "with-meta-ref",
2832                "echo",
2833                AgentMeta {
2834                    ctx: Some(json!({ "work_dir": "/inline-wins" })),
2835                    meta_ref: Some("shared".to_string()),
2836                    ..Default::default()
2837                },
2838            )],
2839        );
2840        blueprint.metas = vec![MetaDef {
2841            name: "shared".to_string(),
2842            ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
2843        }];
2844        let (_, per_agent) = derive_agent_ctx(&blueprint);
2845        assert_eq!(
2846            per_agent.get("with-meta-ref"),
2847            Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
2848            "inline ctx must win the collided key while pool-only keys survive the merge"
2849        );
2850    }
2851
2852    #[test]
2853    fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
2854        let mut blueprint = bp(
2855            step("echo", path("$.in"), path("$.out")),
2856            vec![agent_with_meta(
2857                "with-meta-ref-only",
2858                "echo",
2859                AgentMeta {
2860                    meta_ref: Some("shared".to_string()),
2861                    ..Default::default()
2862                },
2863            )],
2864        );
2865        blueprint.metas = vec![MetaDef {
2866            name: "shared".to_string(),
2867            ctx: json!({ "work_dir": "/base" }),
2868        }];
2869        let (_, per_agent) = derive_agent_ctx(&blueprint);
2870        assert_eq!(
2871            per_agent.get("with-meta-ref-only"),
2872            Some(&json!({ "work_dir": "/base" }))
2873        );
2874    }
2875
2876    #[test]
2877    fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
2878        let blueprint = bp(
2879            step("echo", path("$.in"), path("$.out")),
2880            vec![agent_with_meta(
2881                "with-unresolved-meta-ref",
2882                "echo",
2883                AgentMeta {
2884                    ctx: Some(json!({ "work_dir": "/inline-only" })),
2885                    meta_ref: Some("missing".to_string()),
2886                    ..Default::default()
2887                },
2888            )],
2889        );
2890        // No `blueprint.metas` entries at all — `meta_ref` unresolved.
2891        let (_, per_agent) = derive_agent_ctx(&blueprint);
2892        assert_eq!(
2893            per_agent.get("with-unresolved-meta-ref"),
2894            Some(&json!({ "work_dir": "/inline-only" })),
2895            "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
2896        );
2897    }
2898
2899    // ──────────────────────────────────────────────────────────────────
2900    // GH #46 Milestone 2 Done Criteria #3 (semantics-match): `resolve_runner`
2901    // ──────────────────────────────────────────────────────────────────
2902
2903    /// `resolve_runner` (in `mlua-swarm-schema`) must synthesize the exact
2904    /// same `(variant, tools)` pair `derive_worker_bindings` does today for
2905    /// every agent whose Runner comes solely from the legacy
2906    /// `AgentProfile.worker_binding` fallback (tier 3 of the cascade) — a
2907    /// machine-checked guard against the two paths silently drifting apart
2908    /// once a future change touches one but forgets the other, mirroring
2909    /// `crate::core::explain`'s
2910    /// `explain_agent_ctx_matches_derive_agent_ctx_semantics` drift guard.
2911    /// This is a read-only cross-check: it exercises the schema crate's
2912    /// pure resolver against real Blueprints, without touching the launch
2913    /// path itself (Milestone 3 scope).
2914    #[test]
2915    fn resolve_runner_legacy_fallback_matches_derive_worker_bindings_semantics() {
2916        fn legacy_agent(name: &str, variant: &str, tools: Vec<&str>) -> AgentDef {
2917            AgentDef {
2918                name: name.to_string(),
2919                kind: AgentKind::Operator,
2920                spec: json!({}),
2921                profile: Some(AgentProfile {
2922                    worker_binding: Some(variant.to_string()),
2923                    tools: tools.into_iter().map(str::to_string).collect(),
2924                    ..Default::default()
2925                }),
2926                meta: None,
2927                runner: None,
2928                runner_ref: None,
2929                verdict: None,
2930            }
2931        }
2932
2933        let blueprint = bp(
2934            step("planner", path("$.in"), path("$.out")),
2935            vec![
2936                legacy_agent("planner", "mse-worker-planner", vec!["Read", "Grep"]),
2937                legacy_agent("coder", "mse-worker-coder", vec![]),
2938                agent("no-binding", "echo"),
2939            ],
2940        );
2941
2942        let derived = derive_worker_bindings(&blueprint);
2943
2944        for agent_def in &blueprint.agents {
2945            let resolved = resolve_runner(&blueprint, agent_def).expect("no unresolved refs");
2946            match derived.get(&agent_def.name) {
2947                Some(binding) => {
2948                    assert_eq!(
2949                        resolved,
2950                        Some(Runner::WsClaudeCode {
2951                            variant: binding.variant.clone(),
2952                            tools: binding.tools.clone(),
2953                        }),
2954                        "resolve_runner must synthesize the same WsClaudeCode Runner \
2955                         derive_worker_bindings produces for agent '{}'",
2956                        agent_def.name
2957                    );
2958                }
2959                None => {
2960                    assert_eq!(
2961                        resolved, None,
2962                        "agent '{}' has no derive_worker_bindings entry, so resolve_runner \
2963                         must resolve to None too (no other tier declared)",
2964                        agent_def.name
2965                    );
2966                }
2967            }
2968        }
2969    }
2970
2971    #[test]
2972    fn ws_operator_runner_projects_into_the_existing_spawn_binding() {
2973        let mut blueprint = bp(
2974            step("reviewer", path("$.in"), path("$.out")),
2975            vec![agent("reviewer", "echo")],
2976        );
2977        blueprint.agents[0].runner = Some(Runner::WsOperator {
2978            variant: "mse-reviewer".to_string(),
2979            tools: vec!["Read".to_string(), "Grep".to_string()],
2980        });
2981
2982        let derived = derive_worker_bindings(&blueprint);
2983        let binding = derived
2984            .get("reviewer")
2985            .expect("ws_operator must feed the canonical spawn binding path");
2986        assert_eq!(binding.variant, "mse-reviewer");
2987        assert_eq!(binding.tools, ["Read", "Grep"]);
2988    }
2989
2990    // ──────────────────────────────────────────────────────────────────
2991    // D2: replay compat for backfilled Runs (origin drives digest keying)
2992    // ──────────────────────────────────────────────────────────────────
2993
2994    /// Build a single-step `echo` RustFn Blueprint plus a call counter its
2995    /// worker bumps on every real dispatch. A replay HIT never reaches the
2996    /// worker, so the counter is the "was this step actually executed?"
2997    /// probe.
2998    fn counting_echo_service() -> (TaskLaunchService, Arc<std::sync::atomic::AtomicUsize>) {
2999        use std::sync::atomic::{AtomicUsize, Ordering};
3000        let calls = Arc::new(AtomicUsize::new(0));
3001        let counter = calls.clone();
3002        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
3003            let counter = counter.clone();
3004            async move {
3005                counter.fetch_add(1, Ordering::SeqCst);
3006                Ok(WorkerResult {
3007                    value: json!({ "echoed": inv.prompt }),
3008                    ok: true,
3009                })
3010            }
3011        });
3012        (build_service(factory), calls)
3013    }
3014
3015    async fn seed_legacy_run(run_store: &Arc<dyn crate::store::run::RunStore>) -> crate::RunId {
3016        use crate::store::run::{RunRecord, RunStatus};
3017        use crate::types::TaskId;
3018        let run_id = crate::RunId::new();
3019        run_store
3020            .create(RunRecord {
3021                id: run_id.clone(),
3022                task_id: TaskId::new(),
3023                status: RunStatus::Running,
3024                step_entries: Vec::new(),
3025                degradations: Vec::new(),
3026                operator_sid: None,
3027                result_ref: None,
3028                // No `bound_agents` — a pre-binding-snapshot ("pre-upgrade")
3029                // Run whose resume must backfill.
3030                input_json: Some("{}".to_string()),
3031                created_at: 0,
3032                updated_at: 0,
3033            })
3034            .await
3035            .expect("seed legacy RunRecord");
3036        run_id
3037    }
3038
3039    /// [Crux D2 items 4 + 6] A pre-upgrade Run whose replay log was hashed
3040    /// WITHOUT binding digests replays cleanly through the full launch path
3041    /// on resume, and stays consistent across a second resume (the fast path
3042    /// reads the persisted `resume_backfill` origin, so no digests are ever
3043    /// mixed into the replay key).
3044    #[tokio::test]
3045    async fn backfilled_run_replays_legacy_keys_stably_across_two_resumes() {
3046        use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3047        use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3048        use std::sync::atomic::Ordering;
3049        use std::sync::Mutex;
3050
3051        let (svc, echo_calls) = counting_echo_service();
3052        let blueprint = bp(
3053            step("echo", path("$.input"), path("$.out")),
3054            vec![agent("echo", "echo")],
3055        );
3056        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3057        let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3058        let run_id = seed_legacy_run(&run_store).await;
3059
3060        // Phase 1 — first resume backfills the snapshot AND, because the
3061        // origin is `resume_backfill`, dispatches with legacy replay keys.
3062        // This is the step that generates the pre-upgrade-shaped replay row.
3063        let rc1 = RunContext::new(run_id.clone(), run_store.clone())
3064            .with_replay_store(replay_store.clone())
3065            .with_resume();
3066        let mut input1 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3067        input1.run_ctx = Some(rc1);
3068        let out1 = svc.launch(input1).await.expect("phase-1 resume launch ok");
3069        assert_eq!(out1.final_ctx["out"]["echoed"], "hi");
3070        assert_eq!(
3071            echo_calls.load(Ordering::SeqCst),
3072            1,
3073            "phase 1 dispatches the worker once (nothing to replay yet)"
3074        );
3075        let entries = replay_store
3076            .list_by_run(&run_id)
3077            .await
3078            .expect("list replay rows");
3079        assert_eq!(
3080            entries.len(),
3081            1,
3082            "phase 1 must log exactly one legacy-hashed replay row"
3083        );
3084        let run = run_store.get(&run_id).await.expect("run present");
3085        let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3086        assert_eq!(
3087            SnapshotOrigin::from_snapshot(&snap),
3088            SnapshotOrigin::ResumeBackfill,
3089            "phase 1 must pin the snapshot as resume_backfill"
3090        );
3091
3092        // Phase 2 — second resume. The snapshot now carries bound_agents, so
3093        // load_or_resolve takes the fast path and reads the persisted
3094        // `resume_backfill` origin — digests are again withheld, the legacy
3095        // key matches, and the worker is NOT run a second time.
3096        let cursor = ReplayCursor::from_entries(entries);
3097        let rc2 = RunContext::new(run_id.clone(), run_store.clone())
3098            .with_replay_store(replay_store.clone())
3099            .with_replay_cursor(Arc::new(Mutex::new(cursor)))
3100            .with_resume();
3101        let mut input2 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3102        input2.run_ctx = Some(rc2);
3103        let out2 = svc.launch(input2).await.expect("phase-2 resume launch ok");
3104        assert_eq!(out2.final_ctx["out"]["echoed"], "hi");
3105        assert_eq!(
3106            echo_calls.load(Ordering::SeqCst),
3107            1,
3108            "phase 2 must REPLAY the legacy-hashed row — the worker must not run again"
3109        );
3110        let run2 = run_store.get(&run_id).await.expect("run present");
3111        let snap2: Value = serde_json::from_str(run2.input_json.as_deref().unwrap()).unwrap();
3112        assert_eq!(
3113            SnapshotOrigin::from_snapshot(&snap2),
3114            SnapshotOrigin::ResumeBackfill,
3115            "origin must stay resume_backfill across resumes (replay key stability)"
3116        );
3117    }
3118
3119    /// [Crux D2 item 5 / D2-a] A `launch`-origin Run mixes binding digests
3120    /// into its replay key, so a legacy-hashed (digest-free) replay row does
3121    /// NOT hit — the worker runs. Proves the fix is per-Run and does not
3122    /// disable digest keying for initial launches.
3123    #[tokio::test]
3124    async fn launch_origin_run_uses_digest_keys_and_misses_legacy_replay_row() {
3125        use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3126        use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3127        use std::sync::atomic::Ordering;
3128        use std::sync::Mutex;
3129
3130        let (svc, echo_calls) = counting_echo_service();
3131        let blueprint = bp(
3132            step("echo", path("$.input"), path("$.out")),
3133            vec![agent("echo", "echo")],
3134        );
3135        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3136        let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3137
3138        // Produce a legacy-hashed replay row via a backfill (resume) Run.
3139        let backfill_run = seed_legacy_run(&run_store).await;
3140        let rc_bf = RunContext::new(backfill_run.clone(), run_store.clone())
3141            .with_replay_store(replay_store.clone())
3142            .with_resume();
3143        let mut input_bf = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3144        input_bf.run_ctx = Some(rc_bf);
3145        svc.launch(input_bf).await.expect("backfill launch ok");
3146        assert_eq!(echo_calls.load(Ordering::SeqCst), 1);
3147        let legacy_entries = replay_store
3148            .list_by_run(&backfill_run)
3149            .await
3150            .expect("list legacy rows");
3151        assert_eq!(legacy_entries.len(), 1);
3152
3153        // A fresh, `launch`-origin Run (no `with_resume`) fed a cursor built
3154        // from those legacy rows. Its replay key mixes in the binding digest,
3155        // so the legacy (digest-free) key MISSES and the worker runs again.
3156        let launch_run = seed_legacy_run(&run_store).await;
3157        let cursor = ReplayCursor::from_entries(legacy_entries);
3158        let rc_launch = RunContext::new(launch_run.clone(), run_store.clone())
3159            .with_replay_store(replay_store.clone())
3160            .with_replay_cursor(Arc::new(Mutex::new(cursor)));
3161        let mut input_launch = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3162        input_launch.run_ctx = Some(rc_launch);
3163        svc.launch(input_launch)
3164            .await
3165            .expect("launch-origin launch ok");
3166        assert_eq!(
3167            echo_calls.load(Ordering::SeqCst),
3168            2,
3169            "a launch-origin Run keys replay by binding digest, so the \
3170             legacy-hashed row must MISS and the worker must run"
3171        );
3172        let run = run_store.get(&launch_run).await.expect("run present");
3173        let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3174        assert_eq!(SnapshotOrigin::from_snapshot(&snap), SnapshotOrigin::Launch);
3175    }
3176}