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