Skip to main content

mlua_swarm/
blueprint.rs

1//! Blueprint runner — glue that executes a flow.ir AST
2//! (`mlua_flow_ir::Node`) through the engine. Each `Step.ref` is run as a
3//! single task via `start_task` + `dispatch_attempt_with_run_ctx`, and
4//! the resulting `Pass` `Value` is written back to `Step.out`.
5//!
6//! **Fully-async chain.** Uses `mlua_flow_ir::eval_async` and
7//! `AsyncDispatcher`; `block_on` and `spawn_blocking` are never mixed in,
8//! so the whole stack stays consistent with the engine's tokio async
9//! world.
10//!
11//! # Usage
12//!
13//! ```ignore
14//! let dispatcher = EngineDispatcher::with_spawner(engine.clone(), op_token, spawner);
15//! let bp: mlua_flow_ir::Node = serde_json::from_str(BP_JSON)?;
16//! let final_ctx = mlua_flow_ir::eval_async(&bp, init_ctx, &dispatcher).await?;
17//! ```
18//!
19//! # Schema types (the IF crate)
20//!
21//! `Blueprint` / `AgentDef` / `AgentKind` and friends live in the
22//! `mlua_swarm_schema` crate and are re-exported from here.
23//! The `struct`/`enum` set that used to live directly in `src/blueprint.rs`
24//! has been moved into the IF crate to support extension discipline,
25//! versioning, and external consumers.
26
27use crate::core::config::CheckPolicy;
28use crate::core::engine::Engine;
29use crate::core::projection_placement::ProjectionPlacement;
30use crate::core::state::{wrap_skip_marker, DispatchOutcome, TaskSpec};
31use crate::core::step_naming::StepNaming;
32use crate::store::run::{LastFailure, RunContext, StepEntry};
33use crate::types::{now_unix, CapToken};
34use crate::worker::adapter::SpawnerAdapter;
35use async_trait::async_trait;
36pub mod compiler;
37pub mod loader;
38pub mod store;
39
40use mlua_flow_ir::{AsyncDispatcher, EvalError};
41use serde_json::{Map, Value};
42use std::collections::HashMap;
43use std::sync::Arc;
44
45// The schema types are owned by the IF crate (mlua-swarm-schema); we re-export them here.
46/// The schema-side `OperatorKind` (see `crate::core::ctx::OperatorKind` for the
47/// runtime duplicate consumed by `Engine`). Re-exported under an explicit
48/// alias so callers reading `Blueprint.operators[].kind` /
49/// `Blueprint.default_operator_kind` do not have to reach into
50/// `mlua_swarm_schema` directly.
51pub use mlua_swarm_schema::OperatorKind as SchemaOperatorKind;
52pub use mlua_swarm_schema::{
53    current_schema_version, default_global_agent_kind, resolve_bound_agents,
54    resolve_bound_agents_strict, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
55    AgentProviderCapability, AgentProviderManifest, AuditDef, AuditMode, BindOutcome, BindReceipt,
56    BindRequest, BindingAttestation, BindingBackend, BindingDigest, BindingDigestParseError,
57    Blueprint, BlueprintMetadata, BlueprintOrigin, BoundAgent, BoundAgentResolveError,
58    CompilerHints, CompilerStrategy, MetaDef, OperatorDef, ProjectionPlacementSpec, Runner,
59    RunnerDef, RunnerResolutionSource, RunnerResolveError, SpawnerHints, SubprocessDef,
60    SubprocessOutput, SubprocessOverrides, WorkerModel, CURRENT_SCHEMA_VERSION,
61};
62
63/// Bridges `mlua_flow_ir::AsyncDispatcher` to the engine's
64/// `start_task` + `dispatch_attempt_with_run_ctx` pair. Holds one
65/// Operator session token and one `spawner`, and spins up a fresh task
66/// per `Step.ref`, using it as the agent name.
67///
68/// Constructed via `with_spawner`; each dispatch goes through
69/// `engine.dispatch_attempt_with_run_ctx(token, tid, spawner, run_ctx)`
70/// so that when the enclosing `RunContext` carries a `replay_store` /
71/// `replay_cursor`, replay-hit skip and Ctx-snapshot append happen
72/// transparently. Nothing is stashed on engine-global state, so
73/// multiple dispatchers can drive different Blueprints against the same
74/// `Engine` in parallel without racing.
75///
76/// Optionally carries a [`RunContext`] (via [`Self::with_run`], issue #13
77/// run_id propagation): when present, every dispatched step's `run_id` is
78/// exposed to the worker through `Ctx.meta.runtime["run_id"]`, and a
79/// [`StepEntry`] is appended to `RunRecord.step_entries` once the step's
80/// outcome is known (dispatch is synchronous end-to-end here, so there is
81/// no need for a separate event/notification mechanism — the entry is
82/// written with its final status in one call).
83///
84/// Also carries the GH #21 Phase 2 named `MetaDef` pool (via
85/// [`Self::with_step_metas`]) — the Step tier's dispatch-time resolver;
86/// see [`Self::dispatch`]'s doc for the full envelope contract.
87///
88/// GH #23: optionally carries the Blueprint's [`StepNaming`] table (via
89/// [`Self::with_step_naming`], built once by
90/// `blueprint::compiler::Compiler::compile` — see that type's doc for the
91/// full addressing-space narrative). When present, [`Self::dispatch`]
92/// snapshots the same `Arc` into `EngineState.step_namings` for every
93/// dispatched task, keyed by its freshly-minted `StepId` — the storage
94/// half of the "construct once, read many" contract; `Engine::step_naming_for`
95/// is the read-back accessor later consumers (GH #23 subtask-2/3) pull
96/// from.
97///
98/// GH #27 (follow-up to #23): optionally also carries the Blueprint's
99/// [`ProjectionPlacement`] resolver (via [`Self::with_projection_placement`],
100/// built once by `Compiler::compile`) — the SAME snapshot-then-read-back
101/// contract as [`StepNaming`] above, this time read back via
102/// `Engine::projection_placement_for`.
103pub struct EngineDispatcher {
104    engine: Engine,
105    op_token: CapToken,
106    spawner: Arc<dyn SpawnerAdapter>,
107    run_ctx: Option<RunContext>,
108    step_metas: HashMap<String, Value>,
109    step_naming: Option<Arc<StepNaming>>,
110    projection_placement: Option<Arc<ProjectionPlacement>>,
111    binding_digests: HashMap<String, BindingDigest>,
112    /// The resolved `check_policy` cascade value
113    /// (`launch request > blueprint > server config`, collapsed exactly once
114    /// in `TaskLaunchService::launch`). Threaded into EVERY spawned step's
115    /// `TaskSpec.check_policy` by [`Self::dispatch`]. `None` (the default via
116    /// [`Self::with_spawner`]) preserves pre-cascade behavior byte-for-byte
117    /// — the engine's submit-time sink then falls back to
118    /// `EngineCfg.check_policy` (the server-wide default).
119    check_policy: Option<CheckPolicy>,
120    /// `AgentDef.max_concurrency` limits of the running Blueprint
121    /// (`agent name -> limit`) and the Blueprint id they are counted under.
122    /// Empty (the default) = no agent is limited.
123    concurrency: ConcurrencyDecl,
124}
125
126/// The per-agent limits [`EngineDispatcher::with_concurrency_limits`]
127/// installs.
128#[derive(Default)]
129struct ConcurrencyDecl {
130    blueprint_id: String,
131    limits: HashMap<String, u32>,
132}
133
134impl EngineDispatcher {
135    /// Build a dispatcher with no run-level tracing (`run_ctx = None`),
136    /// no named `MetaDef`s (`step_metas` empty), and no [`StepNaming`]
137    /// table — the pre-existing behavior. Use [`Self::with_run`] /
138    /// [`Self::with_step_metas`] / [`Self::with_step_naming`] to opt into
139    /// any of them.
140    pub fn with_spawner(
141        engine: Engine,
142        op_token: CapToken,
143        spawner: Arc<dyn SpawnerAdapter>,
144    ) -> Self {
145        Self {
146            engine,
147            op_token,
148            spawner,
149            run_ctx: None,
150            step_metas: HashMap::new(),
151            step_naming: None,
152            projection_placement: None,
153            binding_digests: HashMap::new(),
154            check_policy: None,
155            concurrency: ConcurrencyDecl::default(),
156        }
157    }
158
159    /// Attach the Blueprint's `AgentDef.max_concurrency` limits
160    /// (`agent name -> limit`), counted under `blueprint_id` across every
161    /// run on the engine. [`Self::dispatch`] holds one slot of an agent's
162    /// limit for the whole dispatch of each of its steps. An agent absent
163    /// from `limits` is not limited.
164    pub fn with_concurrency_limits(
165        mut self,
166        blueprint_id: impl Into<String>,
167        limits: HashMap<String, u32>,
168    ) -> Self {
169        self.concurrency = ConcurrencyDecl {
170            blueprint_id: blueprint_id.into(),
171            limits,
172        };
173        self
174    }
175
176    /// Wait for a slot of `agent`'s `max_concurrency`, when it declares
177    /// one. Returns the permit to hold for the step's dispatch and how long
178    /// the wait took; `(None, None)` for an unlimited agent.
179    async fn acquire_concurrency_slot(
180        &self,
181        agent: &str,
182    ) -> Result<(Option<tokio::sync::OwnedSemaphorePermit>, Option<u64>), EvalError> {
183        let Some(&limit) = self.concurrency.limits.get(agent) else {
184            return Ok((None, None));
185        };
186        let semaphore = self
187            .engine
188            .concurrency_slot(&self.concurrency.blueprint_id, agent, limit);
189        let waited = std::time::Instant::now();
190        let permit = semaphore
191            .acquire_owned()
192            .await
193            .map_err(|e| EvalError::DispatcherError {
194                ref_: agent.to_string(),
195                msg: format!("max_concurrency slot unavailable: {e}"),
196            })?;
197        Ok((Some(permit), Some(waited.elapsed().as_millis() as u64)))
198    }
199
200    /// Attach a [`RunContext`] (builder style) so every dispatched step is
201    /// traced into `RunRecord.step_entries` and exposes its `run_id` via
202    /// `Ctx.meta.runtime`.
203    pub fn with_run(mut self, run_ctx: RunContext) -> Self {
204        self.run_ctx = Some(run_ctx);
205        self
206    }
207
208    /// GH #21 Phase 2: attach the named `MetaDef` pool (`Blueprint.metas`,
209    /// resolved by `service::task_launch::derive_step_metas` into a
210    /// `name -> ctx` map) that [`Self::dispatch`] resolves `$step_meta.ref`
211    /// envelopes against. Unconditional to call — an empty map (the
212    /// pre-#21-Phase-2 default) makes every `$step_meta.ref` lookup miss
213    /// loudly, same as a Blueprint that never declares `Blueprint.metas`.
214    pub fn with_step_metas(mut self, step_metas: HashMap<String, Value>) -> Self {
215        self.step_metas = step_metas;
216        self
217    }
218
219    /// Attach the immutable `AgentDef.name -> BoundAgent.binding_digest`
220    /// table used to correlate persisted step traces with launch bindings.
221    pub fn with_binding_digests(mut self, binding_digests: HashMap<String, BindingDigest>) -> Self {
222        self.binding_digests = binding_digests;
223        self
224    }
225
226    /// GH #23: attach the Blueprint's [`StepNaming`] table (built once by
227    /// `blueprint::compiler::Compiler::compile`). `None` (the default via
228    /// [`Self::with_spawner`]) preserves pre-GH-#23 behavior byte-for-byte
229    /// — [`Self::dispatch`] simply skips the `EngineState.step_namings`
230    /// snapshot for every caller that never opts in (e.g. tests that build
231    /// an `EngineDispatcher` directly instead of going through
232    /// `service::task_launch::TaskLaunchService::launch`).
233    pub fn with_step_naming(mut self, step_naming: Arc<StepNaming>) -> Self {
234        self.step_naming = Some(step_naming);
235        self
236    }
237
238    /// GH #27 (follow-up to #23): attach the Blueprint's
239    /// [`ProjectionPlacement`] resolver (built once by
240    /// `blueprint::compiler::Compiler::compile`). `None` (the default via
241    /// [`Self::with_spawner`]) preserves pre-GH-#27 behavior byte-for-byte
242    /// — [`Self::dispatch`] simply skips the
243    /// `EngineState.projection_placements` snapshot for every caller that
244    /// never opts in, mirroring [`Self::with_step_naming`]'s contract.
245    pub fn with_projection_placement(
246        mut self,
247        projection_placement: Arc<ProjectionPlacement>,
248    ) -> Self {
249        self.projection_placement = Some(projection_placement);
250        self
251    }
252
253    /// Attach the resolved `check_policy` cascade value
254    /// (`launch request > blueprint > server config`, collapsed exactly once
255    /// by `TaskLaunchService::launch`). Every step [`Self::dispatch`] spawns
256    /// gets this value stamped onto its `TaskSpec.check_policy`, so a
257    /// Blueprint- or launch-declared policy reaches the engine's submit-time
258    /// sink for ALL steps (not just the first). `None` (the default via
259    /// [`Self::with_spawner`]) is a no-op — the sink then falls back to
260    /// `EngineCfg.check_policy` (server-wide default), byte-for-byte the
261    /// pre-cascade behavior.
262    pub fn with_check_policy(mut self, check_policy: Option<CheckPolicy>) -> Self {
263        self.check_policy = check_policy;
264        self
265    }
266}
267
268/// GH #21 Phase 2: resolve a `$step_meta` envelope embedded in a Step's
269/// evaluated `in` value into `(initial_directive, step_ctx)` — the Step
270/// tier's dispatch-time entry point, called from [`EngineDispatcher::dispatch`]
271/// BEFORE `Engine::start_task` (critical: `start_task` seeds
272/// `EngineState.prompts[(tid, 1)]` from `TaskSpec.initial_directive`, so
273/// stripping the envelope any later would leak `$step_meta` into the
274/// worker prompt AND the WS `Spawn.directive` text).
275///
276/// Contract:
277///
278/// - `input` is not a JSON `Object`, or is an `Object` with no
279///   `"$step_meta"` key → passthrough unchanged, `step_ctx = None`
280///   (pre-#21-Phase-2 Blueprints are byte-identical through this path).
281/// - `input` IS an `Object` with a `"$step_meta"` key: the key is always
282///   stripped (never reaches the returned directive). Everything past
283///   this point is loud — an error names the offending step (`ref_`) and,
284///   for an unresolved `ref`, the defined `step_metas` names:
285///   - the envelope itself must be an `Object` shaped
286///     `{"ref": Option<String>, "inline": Option<Object>}`; any other
287///     shape is a malformed-envelope error;
288///   - `ref` (when present and non-null) is looked up in `step_metas`; an
289///     unknown name is an error (no silent skip). The resolved `MetaDef`
290///     ctx must itself be an `Object` (or the lookup is treated as
291///     malformed);
292///   - `inline` (when present and non-null) must be an `Object`;
293///   - the resolved Step-tier ctx = the `ref`-resolved ctx shallow-merged
294///     with `inline`, **`inline` wins** key collisions.
295/// - Directive rule (applied to the remaining `Object`, after
296///   `"$step_meta"` is stripped): if it still contains an `"$in"` key,
297///   that value becomes the returned directive (other sibling keys are
298///   ignored for the directive — envelope-only input, e.g. one final
299///   `$step_meta` key, therefore never becomes an empty directive by
300///   accident just because more keys existed alongside it). Otherwise
301///   the whole remainder becomes the directive; an empty remainder
302///   becomes `Value::String(String::new())`.
303fn resolve_step_envelope(
304    step_metas: &HashMap<String, Value>,
305    ref_: &str,
306    input: Value,
307) -> Result<(Value, Option<Value>), EvalError> {
308    let mut obj = match input {
309        Value::Object(obj) => obj,
310        other => return Ok((other, None)),
311    };
312    let Some(envelope) = obj.remove("$step_meta") else {
313        return Ok((Value::Object(obj), None));
314    };
315    let envelope = match envelope {
316        Value::Object(map) => map,
317        other => {
318            return Err(EvalError::DispatcherError {
319                ref_: ref_.to_string(),
320                msg: format!(
321                    "malformed $step_meta envelope for step '{ref_}': expected an object, got {other}"
322                ),
323            });
324        }
325    };
326
327    let ref_ctx: Option<Map<String, Value>> = match envelope.get("ref") {
328        None | Some(Value::Null) => None,
329        Some(Value::String(name)) => {
330            let resolved = step_metas.get(name).cloned().ok_or_else(|| {
331                EvalError::DispatcherError {
332                    ref_: ref_.to_string(),
333                    msg: format!(
334                        "$step_meta.ref '{name}' (step '{ref_}') is not a defined Blueprint.metas entry (defined: {:?})",
335                        step_metas.keys().collect::<Vec<_>>()
336                    ),
337                }
338            })?;
339            match resolved {
340                Value::Object(map) => Some(map),
341                other => {
342                    return Err(EvalError::DispatcherError {
343                        ref_: ref_.to_string(),
344                        msg: format!(
345                            "malformed $step_meta: MetaDef '{name}'.ctx must be an object, got {other}"
346                        ),
347                    });
348                }
349            }
350        }
351        Some(other) => {
352            return Err(EvalError::DispatcherError {
353                ref_: ref_.to_string(),
354                msg: format!(
355                    "malformed $step_meta.ref (step '{ref_}'): expected a string, got {other}"
356                ),
357            });
358        }
359    };
360
361    let inline: Option<Map<String, Value>> = match envelope.get("inline") {
362        None | Some(Value::Null) => None,
363        Some(Value::Object(map)) => Some(map.clone()),
364        Some(other) => {
365            return Err(EvalError::DispatcherError {
366                ref_: ref_.to_string(),
367                msg: format!(
368                    "malformed $step_meta.inline (step '{ref_}'): expected an object, got {other}"
369                ),
370            });
371        }
372    };
373
374    let step_ctx = match (ref_ctx, inline) {
375        (None, None) => None,
376        (Some(base), None) => Some(Value::Object(base)),
377        (None, Some(inline)) => Some(Value::Object(inline)),
378        (Some(mut base), Some(inline)) => {
379            for (k, v) in inline {
380                base.insert(k, v);
381            }
382            Some(Value::Object(base))
383        }
384    };
385
386    // Directive rule — only reached once a `$step_meta` envelope was
387    // present in `input`.
388    let initial_directive = if let Some(in_value) = obj.remove("$in") {
389        in_value
390    } else if obj.is_empty() {
391        Value::String(String::new())
392    } else {
393        Value::Object(obj)
394    };
395
396    Ok((initial_directive, step_ctx))
397}
398
399#[async_trait]
400impl AsyncDispatcher for EngineDispatcher {
401    async fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
402        // issue #18: the evaluated Step.in value passes straight through
403        // as `TaskSpec.initial_directive` — no premature `Value → String`
404        // coercion here. Consumers that need a rendered `String` do so at
405        // their own late boundary: `Engine::start_task` /
406        // `Engine::dispatch_attempt_with_run_ctx` render it into the
407        // `EngineState.prompts` table for the Worker HTTP path
408        // (`/v1/worker/prompt`), and
409        // `operator_ws::session::default_spawn_directive_with_task_directive`
410        // renders it into the WS `Spawn.directive` reminder text.
411        //
412        // GH #21 Phase 2: BEFORE that pass-through, resolve_step_envelope
413        // strips + resolves any `$step_meta` envelope — see its doc for
414        // the full contract. Inputs without one flow through unchanged.
415        let (initial_directive, step_ctx) = resolve_step_envelope(&self.step_metas, ref_, input)?;
416        let tid = self
417            .engine
418            .start_task(
419                &self.op_token,
420                TaskSpec {
421                    agent: ref_.to_string(),
422                    initial_directive,
423                    step_ctx,
424                    // The resolved cascade value (collapsed
425                    // once in `TaskLaunchService::launch`), threaded onto
426                    // every spawned step's spec. `None` falls back to
427                    // `EngineCfg.check_policy` at the submit-time sink.
428                    check_policy: self.check_policy,
429                },
430            )
431            .await
432            .map_err(|e| EvalError::DispatcherError {
433                ref_: ref_.to_string(),
434                msg: format!("start_task: {e}"),
435            })?;
436
437        // GH #23: snapshot the (already-built, Blueprint-wide) StepNaming
438        // table into `EngineState.step_namings` keyed by this dispatch's
439        // freshly-minted `tid` — the storage half of the "construct once
440        // (`Compiler::compile`), read many (`Engine::step_naming_for`)"
441        // contract. `None` (no `with_step_naming` call) is a no-op, same
442        // fail-open convention as the `run_ctx` step_entry append below:
443        // a secondary-persistence failure here must never mask the
444        // primary dispatch outcome.
445        if let Some(step_naming) = self.step_naming.clone() {
446            let tid_for_naming = tid.clone();
447            if let Err(e) = self
448                .engine
449                .with_state("EngineDispatcher::dispatch.step_naming", move |s| {
450                    s.step_namings.insert(tid_for_naming, step_naming);
451                })
452                .await
453            {
454                tracing::warn!(
455                    task_id = %tid,
456                    error = %e,
457                    "EngineDispatcher::dispatch: failed to snapshot StepNaming into EngineState"
458                );
459            }
460        }
461
462        // GH #27 (follow-up to #23): same snapshot pattern as StepNaming
463        // above — stash the (already-built, Blueprint-wide)
464        // ProjectionPlacement resolver into `EngineState.projection_placements`
465        // keyed by this dispatch's `tid`. `None` (no
466        // `with_projection_placement` call) is a no-op, same fail-open
467        // convention as the `step_naming` snapshot: a secondary-persistence
468        // failure here must never mask the primary dispatch outcome.
469        if let Some(projection_placement) = self.projection_placement.clone() {
470            let tid_for_placement = tid.clone();
471            if let Err(e) = self
472                .engine
473                .with_state(
474                    "EngineDispatcher::dispatch.projection_placement",
475                    move |s| {
476                        s.projection_placements
477                            .insert(tid_for_placement, projection_placement);
478                    },
479                )
480                .await
481            {
482                tracing::warn!(
483                    task_id = %tid,
484                    error = %e,
485                    "EngineDispatcher::dispatch: failed to snapshot ProjectionPlacement into EngineState"
486                );
487            }
488        }
489
490        // `AgentDef.max_concurrency`: wait for a slot of this agent's limit
491        // before anything below starts, so the wait is neither part of the
492        // step's measured duration nor of a worker's own timeout. The
493        // permit is held until this function returns. A cancelled run
494        // drops this future while it waits, which takes it out of the
495        // queue without consuming a slot.
496        let (_concurrency_permit, queued_ms) = self.acquire_concurrency_slot(ref_).await?;
497
498        // Trace rail: when the RunContext carries a TraceHandle, register
499        // it with the engine for the duration of this step (the
500        // pervasive-insertion port middlewares/workers read via
501        // `Engine::trace_handle`) and mark dispatch start on the stream.
502        // The dispatcher also measures wall-clock timing here — the one
503        // worker-kind-independent place both endpoints of the dispatch
504        // are visible.
505        let trace = self.run_ctx.as_ref().and_then(|rc| rc.trace.clone());
506        let started_at_ms = crate::store::trace::now_unix_ms();
507        let started = std::time::Instant::now();
508        if let Some(handle) = &trace {
509            self.engine
510                .set_trace_handle(&tid, Some(handle.clone()))
511                .await;
512            handle
513                .append(
514                    crate::store::trace::kind::STEP_DISPATCHED,
515                    Some(ref_),
516                    None,
517                    serde_json::json!({ "step_id": tid.to_string() }),
518                )
519                .await;
520        }
521
522        // Route dispatch through the replay-aware sibling. When
523        // `run_ctx` carries a `replay_cursor` populated by the caller
524        // (`POST /v1/runs/:id/resume`), a matching row short-circuits
525        // to `DispatchOutcome::Pass` without touching the spawner; when
526        // `run_ctx.replay_store` is `Some`, every fresh Pass appends
527        // one Ctx-snapshot row so a later resume can replay it. With
528        // `run_ctx = None` this collapses to the same behavior as the
529        // legacy `dispatch_attempt_with(..., None)` call.
530        let outcome = self
531            .engine
532            .dispatch_attempt_with_run_ctx(
533                &self.op_token,
534                &tid,
535                &self.spawner,
536                self.run_ctx.as_ref(),
537            )
538            .await;
539
540        let completed_at_ms = crate::store::trace::now_unix_ms();
541        let duration_ms = started.elapsed().as_millis() as u64;
542        // Drain boundary-reported worker stats UNCONDITIONALLY (even when
543        // no RunContext consumes them) so retried/undrained attempts never
544        // accumulate in EngineState for the process lifetime.
545        let worker_stats = self.engine.take_worker_stats(&tid).await;
546        if trace.is_some() {
547            self.engine.set_trace_handle(&tid, None).await;
548        }
549
550        // issue #13 run_id propagation: append one step_entry per dispatched
551        // step (`RunStore.append_step_entry` is append-only — there is no
552        // in-place update — so the entry is written once here, after the
553        // outcome is known, carrying its final status). Secondary
554        // persistence failures are logged and swallowed, matching
555        // `mse-server`'s `finalize_run` convention: they must not mask the
556        // primary dispatch outcome the flow eval already has in hand.
557        if let Some(rc) = &self.run_ctx {
558            let status = match &outcome {
559                Ok(DispatchOutcome::Pass(_)) => "passed",
560                Ok(DispatchOutcome::Blocked(_)) => "blocked",
561                // GH #76 Skip tier: Skip tier StepEntry status. Distinct from
562                // "passed" so post-run inspection of `RunRecord.step_entries`
563                // can distinguish flow-continuation-with-write from
564                // flow-continuation-without-write.
565                Ok(DispatchOutcome::Skip(_)) => "skipped",
566                Ok(DispatchOutcome::Suspended(_)) => "suspended",
567                Ok(DispatchOutcome::Cancelled) => "cancelled",
568                Ok(DispatchOutcome::Timeout) => "timeout",
569                Err(_) => "failed",
570            };
571            let mut entry = StepEntry::basic(
572                tid.clone(),
573                Some(ref_.to_string()),
574                Some(status.to_string()),
575                self.binding_digests.get(ref_).cloned(),
576                now_unix(),
577            );
578            entry.started_at_ms = Some(started_at_ms);
579            entry.completed_at_ms = Some(completed_at_ms);
580            entry.duration_ms = Some(duration_ms);
581            entry.queued_ms = queued_ms;
582            // `attempt` from worker_stats when a boundary reported one;
583            // fall back to the engine's own attempt counter so
584            // non-stats-carrying workers (RustFn / Lua) still populate
585            // the field — the "every StepEntry has attempt" invariant.
586            // `task_attempt` failure is swallowed (the row is deleted
587            // between the outcome and this lookup, or the caller drove
588            // dispatch without a task record) — the field stays None,
589            // same fail-open convention as the append itself.
590            let attempt = if let Some((a, _)) = &worker_stats {
591                Some(*a)
592            } else {
593                self.engine.task_attempt(&tid).await.ok()
594            };
595            entry.attempt = attempt;
596            if let Some((_, stats)) = worker_stats {
597                entry = entry.with_worker_stats(stats);
598            }
599            if let Err(e) = rc.run_store.append_step_entry(&rc.run_id, entry).await {
600                tracing::warn!(
601                    run_id = %rc.run_id,
602                    step_id = %tid,
603                    error = %e,
604                    "EngineDispatcher::dispatch: append_step_entry failed"
605                );
606            }
607            if let Some(handle) = &trace {
608                handle
609                    .append(
610                        crate::store::trace::kind::STEP_COMPLETED,
611                        Some(ref_),
612                        attempt,
613                        serde_json::json!({
614                            "status": status,
615                            "duration_ms": duration_ms,
616                        }),
617                    )
618                    .await;
619            }
620        }
621
622        match outcome {
623            Ok(DispatchOutcome::Pass(v)) => Ok(v),
624            // GH #76 Skip tier: Skip tier is flow-continuation, not error. Map
625            // to `Ok(wrap_skip_marker(v))` — the sentinel Value the
626            // downstream binding-write path recognizes via
627            // [`crate::core::state::is_skip_marker`] to short-circuit the
628            // `$.<step_id>` write (short-circuit itself lands in a
629            // separate follow-up; the sentinel is the wire that carries
630            // the signal across the flow-ir boundary). MUST precede the
631            // wildcard `Ok(other) =>` arm below or Skip would be routed
632            // to `EvalError::DispatcherError` (the non-terminal fallback)
633            // and abort the flow — the exact failure mode this tier is
634            // meant to prevent.
635            Ok(DispatchOutcome::Skip(v)) => Ok(wrap_skip_marker(v)),
636            Ok(DispatchOutcome::Blocked(v)) => {
637                // GH #76 error surface: single-slot breadcrumb the surrounding
638                // `TaskLaunchService::launch` `map_err` closure reads to
639                // populate `TaskLaunchError::FlowEval { failed_step,
640                // verdict_value, .. }`. Written last-write-wins BEFORE the
641                // `EvalError::DispatcherError` return so flow-ir sees the
642                // exact same error the pre-error surface world raised — the
643                // breadcrumb is side-channel observability, never
644                // load-bearing on the abort itself. `run_ctx = None`
645                // (dispatchers built without `with_run`) is a no-op:
646                // there is nowhere to write, and every consumer already
647                // treats `partial_ctx: None` / `failed_step: None` as
648                // "not available".
649                if let Some(rc) = &self.run_ctx {
650                    rc.set_last_failure(LastFailure {
651                        step_id: tid.clone(),
652                        step_ref: Some(ref_.to_string()),
653                        verdict_value: v.clone(),
654                    });
655                }
656                Err(EvalError::DispatcherError {
657                    ref_: ref_.to_string(),
658                    msg: format!("blocked: {v}"),
659                })
660            }
661            Ok(other) => Err(EvalError::DispatcherError {
662                ref_: ref_.to_string(),
663                msg: format!("non-terminal outcome: {:?}", other),
664            }),
665            Err(e) => Err(EvalError::DispatcherError {
666                ref_: ref_.to_string(),
667                msg: format!("dispatch_attempt: {e}"),
668            }),
669        }
670    }
671}
672
673// ──────────────────────────────────────────────────────────────────────────
674// issue #21 Phase 2: `resolve_step_envelope` unit tests + a dispatch-level
675// end-to-end leak-proof test
676// ──────────────────────────────────────────────────────────────────────────
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use serde_json::json;
682
683    fn metas(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
684        pairs
685            .iter()
686            .map(|(k, v)| (k.to_string(), v.clone()))
687            .collect()
688    }
689
690    #[test]
691    fn no_envelope_string_input_passes_through_unchanged() {
692        let (directive, step_ctx) =
693            resolve_step_envelope(&HashMap::new(), "scout", json!("plain string")).unwrap();
694        assert_eq!(directive, json!("plain string"));
695        assert_eq!(step_ctx, None);
696    }
697
698    #[test]
699    fn no_envelope_plain_object_input_passes_through_unchanged() {
700        let input = json!({ "foo": "bar" });
701        let (directive, step_ctx) =
702            resolve_step_envelope(&HashMap::new(), "scout", input.clone()).unwrap();
703        assert_eq!(directive, input);
704        assert_eq!(step_ctx, None);
705    }
706
707    #[test]
708    fn envelope_with_only_ref_resolves_that_metadef_ctx() {
709        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
710        let input = json!({ "$step_meta": { "ref": "heavy-scan" }, "$in": "go" });
711        let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
712        assert_eq!(directive, json!("go"));
713        assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
714    }
715
716    #[test]
717    fn envelope_with_only_inline_uses_inline_verbatim() {
718        let input = json!({
719            "$step_meta": { "inline": { "work_dir": "/inline-only" } },
720            "$in": "go"
721        });
722        let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
723        assert_eq!(directive, json!("go"));
724        assert_eq!(step_ctx, Some(json!({ "work_dir": "/inline-only" })));
725    }
726
727    #[test]
728    fn inline_wins_over_ref_on_key_collision() {
729        let step_metas = metas(&[(
730            "heavy-scan",
731            json!({ "work_dir": "/ref", "extra": "from-ref" }),
732        )]);
733        let input = json!({
734            "$step_meta": {
735                "ref": "heavy-scan",
736                "inline": { "work_dir": "/inline-wins" }
737            },
738            "$in": "go"
739        });
740        let (_, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
741        assert_eq!(
742            step_ctx,
743            Some(json!({ "work_dir": "/inline-wins", "extra": "from-ref" })),
744            "inline must win the collided key while ref-only keys survive the merge"
745        );
746    }
747
748    #[test]
749    fn dollar_in_rule_extracts_directive_and_ignores_other_sibling_keys() {
750        let input = json!({
751            "$step_meta": { "inline": { "k": "v" } },
752            "$in": "the real directive",
753            "unrelated_sibling": "ignored"
754        });
755        let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
756        assert_eq!(directive, json!("the real directive"));
757        assert_eq!(step_ctx, Some(json!({ "k": "v" })));
758    }
759
760    #[test]
761    fn no_dollar_in_remainder_becomes_the_directive() {
762        let input = json!({
763            "$step_meta": { "inline": { "k": "v" } },
764            "other_key": "other_value"
765        });
766        let (directive, _) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
767        assert_eq!(directive, json!({ "other_key": "other_value" }));
768    }
769
770    #[test]
771    fn empty_remainder_becomes_empty_string_directive() {
772        let input = json!({ "$step_meta": { "ref": "heavy-scan" } });
773        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
774        let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
775        assert_eq!(directive, Value::String(String::new()));
776        assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
777    }
778
779    #[test]
780    fn unresolved_ref_is_a_loud_dispatcher_error_naming_ref_and_defined() {
781        let step_metas = metas(&[("known", json!({}))]);
782        let input = json!({ "$step_meta": { "ref": "unknown" }, "$in": "go" });
783        let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
784        match err {
785            EvalError::DispatcherError { ref_, msg } => {
786                assert_eq!(ref_, "scout");
787                assert!(
788                    msg.contains("unknown"),
789                    "message must name the unresolved ref: {msg}"
790                );
791                assert!(
792                    msg.contains("known"),
793                    "message must list defined names: {msg}"
794                );
795            }
796            other => panic!("expected DispatcherError, got {other:?}"),
797        }
798    }
799
800    #[test]
801    fn malformed_step_meta_not_an_object_is_a_loud_error() {
802        let input = json!({ "$step_meta": "not-an-object" });
803        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
804        assert!(matches!(err, EvalError::DispatcherError { .. }));
805    }
806
807    #[test]
808    fn malformed_ref_non_string_is_a_loud_error() {
809        let input = json!({ "$step_meta": { "ref": 42 } });
810        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
811        assert!(matches!(err, EvalError::DispatcherError { .. }));
812    }
813
814    #[test]
815    fn malformed_inline_non_object_is_a_loud_error() {
816        let input = json!({ "$step_meta": { "inline": "not-an-object" } });
817        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
818        assert!(matches!(err, EvalError::DispatcherError { .. }));
819    }
820
821    #[test]
822    fn ref_resolved_metadef_ctx_non_object_is_a_loud_error() {
823        let step_metas = metas(&[("bad", json!("not-an-object"))]);
824        let input = json!({ "$step_meta": { "ref": "bad" } });
825        let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
826        assert!(matches!(err, EvalError::DispatcherError { .. }));
827    }
828
829    /// End-to-end proof (issue #21 Phase 2 Done Criteria #5): a `$step_meta`
830    /// envelope must never reach `EngineState.prompts[(tid, 1)]` — the
831    /// resolve step runs BEFORE `start_task` seeds that table.
832    #[tokio::test]
833    async fn dispatch_step_meta_envelope_never_leaks_into_stored_prompt() {
834        use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
835        use crate::core::config::EngineCfg;
836        use crate::types::{Role, StepId};
837        use crate::worker::adapter::WorkerResult;
838        use std::sync::Mutex as StdMutex;
839        use std::time::Duration;
840
841        let captured_tid: Arc<StdMutex<Option<StepId>>> = Arc::new(StdMutex::new(None));
842        let captured_tid_for_fn = captured_tid.clone();
843        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
844            let captured_tid = captured_tid_for_fn.clone();
845            async move {
846                *captured_tid.lock().unwrap() = Some(inv.task_id.clone());
847                Ok(WorkerResult {
848                    value: json!({ "ok": true }),
849                    ok: true,
850                    stats: None,
851                })
852            }
853        });
854        let def = AgentDef {
855            name: "scout".into(),
856            kind: AgentKind::RustFn,
857            spec: json!({ "fn_id": "echo" }),
858            profile: None,
859            meta: None,
860            runner: None,
861            runner_ref: None,
862            verdict: None,
863            lints: None,
864            max_concurrency: None,
865        };
866        let spawner = factory.build(&def, None).expect("build");
867
868        let engine = Engine::new(EngineCfg::default());
869        let token = engine
870            .attach("ut-op", Role::Operator, Duration::from_secs(30))
871            .await
872            .expect("attach");
873        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
874        let dispatcher = EngineDispatcher::with_spawner(engine.clone(), token, spawner)
875            .with_step_metas(step_metas);
876
877        let input = json!({
878            "$step_meta": { "ref": "heavy-scan" },
879            "$in": "do the thing"
880        });
881        let out = dispatcher
882            .dispatch("scout", input)
883            .await
884            .expect("dispatch ok");
885        assert_eq!(out, json!({ "ok": true }));
886
887        let tid = captured_tid
888            .lock()
889            .unwrap()
890            .clone()
891            .expect("task_id captured");
892        let stored_prompt = engine
893            .with_state("test.read_prompt", move |s| {
894                s.prompts.get(&(tid, 1)).cloned()
895            })
896            .await
897            .expect("with_state")
898            .expect("prompt recorded for attempt 1");
899        assert_eq!(
900            stored_prompt,
901            json!("do the thing"),
902            "the stored prompt must be the post-envelope directive, with no $step_meta leakage"
903        );
904    }
905
906    /// GH #76 error surface: the dispatcher's Blocked arm writes the
907    /// `RunContext.last_failure` breadcrumb (step_id + step_ref +
908    /// verdict_value) BEFORE returning `EvalError::DispatcherError`. This
909    /// test drives a `WorkerResult { ok: false }` through the dispatcher
910    /// and asserts every breadcrumb field, including that step_ref matches
911    /// the dispatched Blueprint ref and verdict_value carries the full
912    /// value the worker returned (not a stringified summary).
913    #[tokio::test]
914    async fn dispatcher_blocked_records_last_failure_breadcrumb() {
915        use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
916        use crate::core::config::EngineCfg;
917        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
918        use crate::types::{Role, RunId, TaskId};
919        use crate::worker::adapter::WorkerResult;
920        use std::time::Duration;
921
922        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |_inv| async move {
923            Ok(WorkerResult {
924                value: json!({ "verdict": "BLOCKED", "reason": "not-applicable" }),
925                ok: false,
926                stats: None,
927            })
928        });
929        let def = AgentDef {
930            name: "gate".into(),
931            kind: AgentKind::RustFn,
932            spec: json!({ "fn_id": "echo" }),
933            profile: None,
934            meta: None,
935            runner: None,
936            runner_ref: None,
937            verdict: None,
938            lints: None,
939            max_concurrency: None,
940        };
941        let spawner = factory.build(&def, None).expect("build");
942
943        let engine = Engine::new(EngineCfg::default());
944        let token = engine
945            .attach("ut-op", Role::Operator, Duration::from_secs(30))
946            .await
947            .expect("attach");
948
949        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
950        let run_id = RunId::new();
951        run_store
952            .create(RunRecord {
953                id: run_id.clone(),
954                task_id: TaskId::new(),
955                status: RunStatus::Running,
956                step_entries: Vec::new(),
957                degradations: Vec::new(),
958                operator_sid: None,
959                current: Default::default(),
960                next_generation: 0,
961                result_ref: None,
962                input_json: Some("{}".to_string()),
963                created_at: 0,
964                updated_at: 0,
965            })
966            .await
967            .expect("seed RunRecord");
968
969        let run_ctx = RunContext::new(run_id, run_store);
970        let dispatcher =
971            EngineDispatcher::with_spawner(engine, token, spawner).with_run(run_ctx.clone());
972
973        let err = dispatcher
974            .dispatch("gate", json!("go"))
975            .await
976            .expect_err("expected DispatcherError for Blocked outcome");
977        // The public `EvalError` surface is unchanged — same
978        // `DispatcherError` variant with the same `ref_` + `msg` shape.
979        assert!(
980            err.to_string().contains("blocked"),
981            "expected EvalError to mention blocked, got: {err}"
982        );
983
984        // Breadcrumb is populated by the same match arm that raised the
985        // error — reading it via the shared `Arc<Mutex<Option<_>>>` must
986        // succeed.
987        let breadcrumb = run_ctx
988            .last_failure
989            .lock()
990            .expect("last_failure mutex not poisoned")
991            .clone()
992            .expect("Blocked arm must have written LastFailure");
993        assert_eq!(
994            breadcrumb.step_ref,
995            Some("gate".to_string()),
996            "step_ref must be the Blueprint ref this dispatch was routed to"
997        );
998        assert_eq!(
999            breadcrumb.verdict_value,
1000            json!({ "verdict": "BLOCKED", "reason": "not-applicable" }),
1001            "verdict_value must be the exact value the worker returned"
1002        );
1003        // step_id is the freshly minted dispatch-time tid — its exact
1004        // value is opaque, but it must be non-empty (StepId::to_string()
1005        // never yields an empty string for a valid mint).
1006        assert!(!breadcrumb.step_id.to_string().is_empty());
1007    }
1008
1009    /// GH #76 error surface: `RunContext::snapshot_partial_ctx` reads the persisted
1010    /// step_entry log and reconstructs a JSON `{ "steps": { <step_id>:
1011    /// { step_ref, status, at, .. } } }` shape — metadata-level, not
1012    /// value-level. Regression test for the reconstructor itself
1013    /// (independent of the map_err closure).
1014    #[tokio::test]
1015    async fn run_context_snapshot_partial_ctx_reconstructs_step_entry_log() {
1016        use crate::store::run::{
1017            InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, StepEntry,
1018        };
1019        use crate::types::{now_unix, RunId, StepId, TaskId};
1020
1021        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1022        let run_id = RunId::new();
1023        run_store
1024            .create(RunRecord {
1025                id: run_id.clone(),
1026                task_id: TaskId::new(),
1027                status: RunStatus::Running,
1028                step_entries: Vec::new(),
1029                degradations: Vec::new(),
1030                operator_sid: None,
1031                current: Default::default(),
1032                next_generation: 0,
1033                result_ref: None,
1034                input_json: Some("{}".to_string()),
1035                created_at: 0,
1036                updated_at: 0,
1037            })
1038            .await
1039            .expect("seed RunRecord");
1040        let sid1 = StepId::new();
1041        let sid2 = StepId::new();
1042        run_store
1043            .append_step_entry(
1044                &run_id,
1045                StepEntry::basic(
1046                    sid1.clone(),
1047                    Some("stage-1".to_string()),
1048                    Some("passed".to_string()),
1049                    None,
1050                    now_unix(),
1051                ),
1052            )
1053            .await
1054            .expect("append 1");
1055        run_store
1056            .append_step_entry(
1057                &run_id,
1058                StepEntry::basic(
1059                    sid2.clone(),
1060                    Some("stage-2".to_string()),
1061                    Some("blocked".to_string()),
1062                    None,
1063                    now_unix(),
1064                ),
1065            )
1066            .await
1067            .expect("append 2");
1068
1069        let run_ctx = RunContext::new(run_id, run_store);
1070        let snap = run_ctx.snapshot_partial_ctx().await;
1071        let steps = snap
1072            .get("steps")
1073            .and_then(|v| v.as_object())
1074            .expect("steps object");
1075        assert_eq!(steps.len(), 2);
1076        assert_eq!(steps[&sid1.to_string()]["step_ref"], json!("stage-1"));
1077        assert_eq!(steps[&sid1.to_string()]["status"], json!("passed"));
1078        assert_eq!(steps[&sid2.to_string()]["step_ref"], json!("stage-2"));
1079        assert_eq!(steps[&sid2.to_string()]["status"], json!("blocked"));
1080    }
1081}