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