Skip to main content

mlua_swarm/service/
task_launch.rs

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