1use crate::binding::{
23 attest_bound_agents, binding_requests, validate_bound_agent_snapshots, AgentBindingProvider,
24 LegacyWorkerBindingPolicy, UnboundAgent,
25};
26use crate::blueprint::compiler::{materialize_bound_blueprint, CompileError, Compiler};
27use crate::blueprint::{
28 resolve_bound_agents, AuditDef, Blueprint, BoundAgent, EngineDispatcher, Runner,
29};
30use crate::core::agent_context::ContextPolicy;
31use crate::core::config::CheckPolicy;
32use crate::core::ctx::OperatorKind;
33use crate::core::engine::Engine;
34use crate::core::errors::EngineError;
35use crate::middleware::agent_context::AgentContextMiddleware;
36use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
37use crate::middleware::task_input::TaskInputMiddleware;
38use crate::middleware::worker_binding::WorkerBindingMiddleware;
39use crate::middleware::{AfterRunAuditMiddleware, SpawnerStack};
40use crate::operator::WorkerBinding;
41use crate::service::linker;
42use crate::store::run::{RunContext, SnapshotOrigin};
43use crate::types::{CapToken, Role};
44use mlua_flow_ir::{Externs, NoExterns};
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47use std::collections::HashMap;
48use std::sync::Arc;
49use std::time::Duration;
50use thiserror::Error;
51
52#[cfg(test)]
79pub(crate) fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
80 let bound_agents = resolve_bound_agents(blueprint)
83 .expect("derive_worker_bindings requires a Blueprint with resolvable Runner refs");
84 worker_bindings_from_bound_agents(&bound_agents)
85}
86
87fn worker_bindings_from_bound_agents(
88 bound_agents: &[BoundAgent],
89) -> HashMap<String, WorkerBinding> {
90 bound_agents
91 .iter()
92 .filter_map(|bound| match &bound.runner {
93 Some(Runner::WsOperator { variant, tools })
94 | Some(Runner::WsClaudeCode { variant, tools }) => Some((
95 bound.agent.name.clone(),
96 WorkerBinding {
97 variant: variant.clone(),
98 tools: tools.clone(),
99 request_digest: Some(bound.binding_digest.clone()),
100 requested_model: bound.agent.profile.as_ref().and_then(|p| p.model.clone()),
101 },
102 )),
103 _ => None,
104 })
105 .collect()
106}
107
108async fn attest_or_gate_fresh(
123 bound_agents: &mut [BoundAgent],
124 binding_provider: Option<&dyn AgentBindingProvider>,
125 strict: bool,
126 run_ctx: Option<&RunContext>,
127) -> Result<(), TaskLaunchError> {
128 match binding_provider {
129 Some(provider) => {
130 let unbound = attest_bound_agents(provider, bound_agents, strict)
131 .await
132 .map_err(|error| TaskLaunchError::PreDispatch(error.to_string()))?;
133 for agent in &unbound {
134 record_unbound_degradation(agent, run_ctx).await;
135 }
136 Ok(())
137 }
138 None => {
139 if strict && !binding_requests(bound_agents).is_empty() {
140 return Err(TaskLaunchError::PreDispatch(format!(
141 "strict_binding requires a binding provider but none is injected; \
142 {} Runner-backed agent(s) cannot be attested",
143 binding_requests(bound_agents).len()
144 )));
145 }
146 Ok(())
147 }
148 }
149}
150
151async fn record_unbound_degradation(agent: &UnboundAgent, run_ctx: Option<&RunContext>) {
157 tracing::warn!(
158 agent = %agent.agent,
159 reason = %agent.reason,
160 "binding_unattested: agent runs DeclarationOnly (strict_binding is off)"
161 );
162 let Some(run_ctx) = run_ctx else {
163 return;
164 };
165 let entry = crate::store::run::DegradationEntry {
166 tool: "binding".to_string(),
167 error: agent.reason.clone(),
168 fallback: "DeclarationOnly".to_string(),
169 note: Some(format!(
170 "agent '{}' launched without a binding attestation (strict_binding off)",
171 agent.agent
172 )),
173 step_ref: None,
174 attempt: None,
175 at: crate::types::now_unix(),
176 };
177 if let Err(error) = run_ctx
178 .run_store
179 .append_degradation(&run_ctx.run_id, entry)
180 .await
181 {
182 tracing::warn!(
183 agent = %agent.agent,
184 %error,
185 "binding_unattested: failed to record degradation entry"
186 );
187 }
188}
189
190async fn load_or_resolve_bound_agents(
191 blueprint: &Blueprint,
192 run_ctx: Option<&RunContext>,
193 binding_provider: Option<&dyn AgentBindingProvider>,
194 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
195) -> Result<(Vec<BoundAgent>, SnapshotOrigin), TaskLaunchError> {
196 let strict = blueprint.strategy.strict_binding;
199 let resolve_fresh = || match legacy_worker_binding_policy {
200 LegacyWorkerBindingPolicy::Allow => resolve_bound_agents(blueprint),
201 LegacyWorkerBindingPolicy::Reject => {
202 crate::blueprint::resolve_bound_agents_strict(blueprint)
203 }
204 };
205 let Some(run_ctx) = run_ctx else {
206 let mut bound_agents = resolve_fresh().map_err(CompileError::from)?;
209 attest_or_gate_fresh(&mut bound_agents, binding_provider, strict, None).await?;
210 return Ok((bound_agents, SnapshotOrigin::Launch));
211 };
212
213 let record = run_ctx
214 .run_store
215 .get(&run_ctx.run_id)
216 .await
217 .map_err(|e| TaskLaunchError::PreDispatch(format!("load Run binding snapshot: {e}")))?;
218 if let Some(input_json) = record.input_json.as_deref() {
219 let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
220 TaskLaunchError::PreDispatch(format!("decode Run launch snapshot: {e}"))
221 })?;
222 if let Some(value) = snapshot.get("bound_agents") {
223 let bound_agents: Vec<BoundAgent> =
224 serde_json::from_value(value.clone()).map_err(|e| {
225 TaskLaunchError::PreDispatch(format!("decode Run BoundAgent snapshot: {e}"))
226 })?;
227 validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
228 TaskLaunchError::PreDispatch(format!("validate Run BoundAgent snapshot: {error}"))
229 })?;
230 let origin = SnapshotOrigin::from_snapshot(&snapshot);
238 return Ok((bound_agents, origin));
239 }
240 }
241
242 let mut bound_agents = resolve_fresh().map_err(CompileError::from)?;
243 attest_or_gate_fresh(&mut bound_agents, binding_provider, strict, Some(run_ctx)).await?;
244 let origin = if run_ctx.resume {
250 SnapshotOrigin::ResumeBackfill
251 } else {
252 SnapshotOrigin::Launch
253 };
254 if let Some(input_json) = record.input_json {
255 let mut snapshot: Value = serde_json::from_str(&input_json).map_err(|e| {
256 TaskLaunchError::PreDispatch(format!("decode Run launch snapshot: {e}"))
257 })?;
258 let object = snapshot.as_object_mut().ok_or_else(|| {
259 TaskLaunchError::PreDispatch("Run launch snapshot must be a JSON object".to_string())
260 })?;
261 object.insert(
266 "bound_agents".to_string(),
267 serde_json::to_value(&bound_agents).map_err(|e| {
268 TaskLaunchError::PreDispatch(format!("encode Run BoundAgent snapshot: {e}"))
269 })?,
270 );
271 object.insert(
272 crate::store::run::BOUND_AGENTS_ORIGIN_KEY.to_string(),
273 serde_json::to_value(origin).map_err(|e| {
274 TaskLaunchError::PreDispatch(format!("encode Run BoundAgent origin: {e}"))
275 })?,
276 );
277 run_ctx
278 .run_store
279 .set_input_json(
280 &run_ctx.run_id,
281 serde_json::to_string(&snapshot).map_err(|e| {
282 TaskLaunchError::PreDispatch(format!("encode Run launch snapshot: {e}"))
283 })?,
284 )
285 .await
286 .map_err(|e| {
287 TaskLaunchError::PreDispatch(format!("persist Run BoundAgent snapshot: {e}"))
288 })?;
289 }
290 if origin == SnapshotOrigin::ResumeBackfill {
294 record_backfill_degradation(run_ctx, blueprint.id.as_str()).await;
295 }
296 Ok((bound_agents, origin))
297}
298
299async fn record_backfill_degradation(run_ctx: &RunContext, blueprint_id: &str) {
306 tracing::warn!(
307 run_id = %run_ctx.run_id,
308 blueprint = %blueprint_id,
309 "binding_backfill: resumed Run had no binding snapshot; bound_agents \
310 re-derived from the current Blueprint (not a launch-time pin)"
311 );
312 let entry = crate::store::run::DegradationEntry {
313 tool: "binding".to_string(),
314 error: "run resumed without a launch-pinned binding snapshot".to_string(),
315 fallback: "resume_backfill".to_string(),
316 note: Some(format!(
317 "run '{}' backfilled bound_agents from Blueprint '{}' at resume time",
318 run_ctx.run_id, blueprint_id
319 )),
320 step_ref: None,
321 attempt: None,
322 at: crate::types::now_unix(),
323 };
324 if let Err(error) = run_ctx
325 .run_store
326 .append_degradation(&run_ctx.run_id, entry)
327 .await
328 {
329 tracing::warn!(
330 run_id = %run_ctx.run_id,
331 %error,
332 "binding_backfill: failed to record degradation entry"
333 );
334 }
335}
336
337fn derive_audits(blueprint: &Blueprint) -> Vec<AuditDef> {
346 blueprint.audits.clone()
347}
348
349pub(crate) fn derive_agent_ctx(blueprint: &Blueprint) -> (Option<Value>, HashMap<String, Value>) {
378 let global = blueprint.default_agent_ctx.clone();
379 let meta_pool = derive_step_metas(blueprint);
380 let per_agent = blueprint
381 .agents
382 .iter()
383 .filter_map(|ad| {
384 let meta = ad.meta.as_ref()?;
385 let inline = meta.ctx.clone();
386 let base = meta.meta_ref.as_ref().and_then(|name| {
387 let resolved = meta_pool.get(name).cloned();
388 if resolved.is_none() {
389 tracing::warn!(
390 agent = %ad.name,
391 meta_ref = %name,
392 "derive_agent_ctx: AgentMeta.meta_ref names an undefined Blueprint.metas entry; skipping the base layer"
393 );
394 }
395 resolved
396 });
397 let merged = match (base, inline) {
398 (None, None) => None,
399 (Some(base), None) => Some(base),
400 (None, Some(inline)) => Some(inline),
401 (Some(base), Some(inline)) => Some(shallow_merge_inline_wins(base, inline)),
402 };
403 merged.map(|ctx| (ad.name.clone(), ctx))
404 })
405 .collect();
406 (global, per_agent)
407}
408
409pub(crate) fn shallow_merge_inline_wins(base: Value, inline: Value) -> Value {
416 match (base, inline) {
417 (Value::Object(mut base), Value::Object(inline)) => {
418 for (k, v) in inline {
419 base.insert(k, v);
420 }
421 Value::Object(base)
422 }
423 (_, inline) => inline,
424 }
425}
426
427fn derive_step_metas(blueprint: &Blueprint) -> HashMap<String, Value> {
435 blueprint
436 .metas
437 .iter()
438 .map(|m| (m.name.clone(), m.ctx.clone()))
439 .collect()
440}
441
442fn derive_context_policies(
450 blueprint: &Blueprint,
451) -> (Option<ContextPolicy>, HashMap<String, ContextPolicy>) {
452 let default_policy = blueprint.default_context_policy.clone();
453 let per_agent = blueprint
454 .agents
455 .iter()
456 .filter_map(|ad| {
457 let meta = ad.meta.as_ref()?;
458 let policy = meta.context_policy.clone()?;
459 Some((ad.name.clone(), policy))
460 })
461 .collect();
462 (default_policy, per_agent)
463}
464
465fn merge_init_ctx(bp_default: Option<&Value>, task_init_ctx: &Value) -> Value {
481 match (bp_default, task_init_ctx) {
482 (Some(Value::Object(bp_map)), Value::Object(task_map)) => {
483 let mut merged = bp_map.clone();
484 for (k, v) in task_map {
485 merged.insert(k.clone(), v.clone());
486 }
487 Value::Object(merged)
488 }
489 (None, _) => task_init_ctx.clone(),
490 (_, task) => task.clone(),
491 }
492}
493
494pub fn merge_init_ctx_3layer(
510 bp_default: Option<&Value>,
511 task_init_ctx: &Value,
512 run_override: Option<&Value>,
513) -> Value {
514 let bp_task = merge_init_ctx(bp_default, task_init_ctx);
515 match run_override {
516 Some(run) => merge_init_ctx(Some(&bp_task), run),
517 None => bp_task,
518 }
519}
520
521fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
522 let mut out = HashMap::new();
523 if blueprint.operators.is_empty() {
524 return out;
525 }
526 for agent in &blueprint.agents {
527 let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
528 continue;
529 };
530 let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
531 continue;
532 };
533 if let Some(kind) = op_def.kind {
534 out.insert(agent.name.clone(), OperatorKind::from(kind));
535 }
536 }
537 out
538}
539
540#[derive(Debug, Error)]
542pub enum TaskLaunchError {
543 #[error("compile: {0}")]
545 Compile(#[from] CompileError),
546 #[error("engine: {0}")]
548 Engine(#[from] EngineError),
549 #[error("flow eval: {message}")]
565 FlowEval {
566 message: String,
569 failed_step: Option<String>,
574 verdict_value: Option<Value>,
578 partial_ctx: Option<Value>,
586 },
587 #[error("pre-dispatch: {0}")]
594 PreDispatch(String),
595}
596
597#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
620pub struct TaskInputSpec {
621 #[serde(default)]
623 pub project_root: Option<String>,
624 #[serde(default)]
626 pub work_dir: Option<String>,
627 #[serde(default)]
629 #[schemars(with = "Option<Value>")]
630 pub task_metadata: Option<Value>,
631}
632
633#[derive(Debug, Clone)]
635pub struct TaskLaunchInput {
636 pub blueprint: Blueprint,
638 pub operator_id: String,
640 pub role: Role,
642 pub ttl: Duration,
644 pub operator_kind: Option<OperatorKind>,
653 pub bridge_id: Option<String>,
657 pub hook_id: Option<String>,
660 pub operator_backend_id: Option<String>,
667 pub operator_kind_overrides: HashMap<String, OperatorKind>,
672 pub init_ctx: Value,
677 pub task_input: Option<TaskInputSpec>,
684 pub run_ctx: Option<RunContext>,
691 pub check_policy: Option<CheckPolicy>,
714}
715
716impl TaskLaunchInput {
717 pub fn automate(
727 blueprint: Blueprint,
728 operator_id: impl Into<String>,
729 role: Role,
730 ttl: Duration,
731 init_ctx: Value,
732 ) -> Self {
733 Self {
734 blueprint,
735 operator_id: operator_id.into(),
736 role,
737 ttl,
738 operator_kind: None,
739 bridge_id: None,
740 hook_id: None,
741 operator_backend_id: None,
742 operator_kind_overrides: HashMap::new(),
743 init_ctx,
744 task_input: None,
745 run_ctx: None,
746 check_policy: None,
747 }
748 }
749}
750
751#[derive(Debug, Clone)]
753pub struct TaskLaunchOutput {
754 pub token: CapToken,
756 pub final_ctx: Value,
760}
761
762pub struct TaskLaunchService {
766 engine: Engine,
767 compiler: Compiler,
768 externs: Arc<dyn Externs + Send + Sync>,
773 binding_provider: Option<Arc<dyn AgentBindingProvider>>,
776 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
779}
780
781impl TaskLaunchService {
782 pub fn new(engine: Engine, compiler: Compiler) -> Self {
784 Self {
785 engine,
786 compiler,
787 externs: Arc::new(NoExterns),
788 binding_provider: None,
789 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::default(),
790 }
791 }
792
793 pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
797 self.externs = externs;
798 self
799 }
800
801 pub fn with_binding_provider(mut self, provider: Arc<dyn AgentBindingProvider>) -> Self {
805 self.binding_provider = Some(provider);
806 self
807 }
808
809 pub fn with_legacy_worker_binding_policy(mut self, policy: LegacyWorkerBindingPolicy) -> Self {
811 self.legacy_worker_binding_policy = policy;
812 self
813 }
814
815 pub fn engine(&self) -> &Engine {
817 &self.engine
818 }
819
820 pub fn compiler(&self) -> &Compiler {
822 &self.compiler
823 }
824
825 pub async fn launch(
837 &self,
838 mut input: TaskLaunchInput,
839 ) -> Result<TaskLaunchOutput, TaskLaunchError> {
840 let (bound_agents, snapshot_origin) = load_or_resolve_bound_agents(
849 &input.blueprint,
850 input.run_ctx.as_ref(),
851 self.binding_provider.as_deref(),
852 self.legacy_worker_binding_policy,
853 )
854 .await?;
855 let binding_digests: HashMap<String, crate::blueprint::BindingDigest> = bound_agents
856 .iter()
857 .map(|bound| (bound.agent.name.clone(), bound.binding_digest.clone()))
858 .collect();
859 if let Some(run_ctx) = input.run_ctx.take() {
860 input.run_ctx = Some(match snapshot_origin {
871 SnapshotOrigin::Launch => run_ctx.with_binding_digests(binding_digests.clone()),
872 SnapshotOrigin::ResumeBackfill => run_ctx,
873 });
874 }
875 input.blueprint = materialize_bound_blueprint(&input.blueprint, &bound_agents);
876 let compiled = self
877 .compiler
878 .compile_bound(&input.blueprint, &bound_agents)?;
879 self.engine
888 .register_verdict_contracts(compiled.router.verdict_contracts.clone());
889 let spawner = linker::link(
890 compiled.router.clone(),
891 &input.blueprint.spawner_hints.layers,
892 &self.engine,
893 );
894 let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
910 let (context_policy_default, context_policy_per_agent) =
911 derive_context_policies(&input.blueprint);
912 let spawner = SpawnerStack::new(spawner)
913 .layer(AgentContextMiddleware::new(
914 agent_ctx_global,
915 agent_ctx_per_agent,
916 context_policy_default,
917 context_policy_per_agent,
918 ))
919 .build();
920 let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
927 SpawnerStack::new(spawner)
928 .layer(ProjectNameAliasMiddleware::new(alias))
929 .build()
930 } else {
931 spawner
932 };
933 let worker_bindings = worker_bindings_from_bound_agents(&bound_agents);
937 let spawner = if worker_bindings.is_empty() {
938 spawner
939 } else {
940 SpawnerStack::new(spawner)
941 .layer(WorkerBindingMiddleware::new(worker_bindings))
942 .build()
943 };
944 let audit_defs = derive_audits(&input.blueprint);
954 let spawner = if audit_defs.is_empty() {
955 spawner
956 } else {
957 SpawnerStack::new(spawner)
958 .layer(AfterRunAuditMiddleware::new(
959 audit_defs,
960 compiled.router.clone(),
961 ))
962 .build()
963 };
964
965 let spawner = match input.task_input.as_ref().and_then(|spec| {
972 TaskInputMiddleware::new_from_fields(
973 spec.project_root.clone(),
974 spec.work_dir.clone(),
975 spec.task_metadata.clone(),
976 )
977 }) {
978 Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
979 None => spawner,
980 };
981
982 let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
987 let bp_global_kind = input
988 .blueprint
989 .default_operator_kind
990 .map(OperatorKind::from);
991
992 let token = self
993 .engine
994 .attach_with_ids(
995 input.operator_id,
996 input.role,
997 input.ttl,
998 input.operator_kind,
999 input.bridge_id,
1000 input.hook_id,
1001 input.operator_backend_id,
1002 input.operator_kind_overrides,
1003 bp_agent_kinds,
1004 bp_global_kind,
1005 )
1006 .await?;
1007 let resolved_check_policy = input.check_policy.or(input.blueprint.check_policy);
1018 let effective_check_policy =
1033 resolved_check_policy.unwrap_or(self.engine.cfg().check_policy);
1034 if effective_check_policy == CheckPolicy::Strict {
1035 let roots_missing = input
1036 .task_input
1037 .as_ref()
1038 .map(|t| t.project_root.is_none() && t.work_dir.is_none())
1039 .unwrap_or(true);
1040 if roots_missing {
1041 return Err(TaskLaunchError::PreDispatch(
1042 "check_policy=strict requires project_root or work_dir, but the launch \
1043 supplied neither"
1044 .to_string(),
1045 ));
1046 }
1047 }
1048 let dispatcher =
1049 EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
1050 let dispatcher = dispatcher.with_check_policy(resolved_check_policy);
1051 let map_err_run_ctx = input.run_ctx.clone();
1059 let dispatcher = match input.run_ctx {
1060 Some(run_ctx) => dispatcher.with_run(run_ctx),
1061 None => dispatcher,
1062 };
1063 let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
1067 let dispatcher = dispatcher.with_binding_digests(binding_digests);
1068 let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
1074 let dispatcher =
1081 dispatcher.with_projection_placement(compiled.projection_placement.clone());
1082 let merged_init_ctx =
1088 merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
1089 let eval_result = mlua_flow_ir::eval_async_externs(
1090 &input.blueprint.flow,
1091 merged_init_ctx,
1092 &dispatcher,
1093 &*self.externs,
1094 )
1095 .await;
1096 let final_ctx = match eval_result {
1097 Ok(v) => v,
1098 Err(e) => {
1099 let (failed_step, verdict_value) = match &map_err_run_ctx {
1119 Some(rc) => {
1120 let slot = rc.last_failure.lock().ok().and_then(|g| g.clone());
1121 match slot {
1122 Some(lf) => (
1123 lf.step_ref.clone().or_else(|| Some(lf.step_id.to_string())),
1124 Some(lf.verdict_value.clone()),
1125 ),
1126 None => (None, None),
1127 }
1128 }
1129 None => (None, None),
1130 };
1131 let partial_ctx = match &map_err_run_ctx {
1132 Some(rc) => Some(rc.snapshot_partial_ctx().await),
1133 None => None,
1134 };
1135 return Err(TaskLaunchError::FlowEval {
1136 message: e.to_string(),
1137 failed_step,
1138 verdict_value,
1139 partial_ctx,
1140 });
1141 }
1142 };
1143 Ok(TaskLaunchOutput { token, final_ctx })
1144 }
1145}
1146
1147#[cfg(test)]
1152mod tests {
1153 use super::*;
1154 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1155 use crate::blueprint::{
1156 current_schema_version, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
1157 BlueprintMetadata, CompilerHints, CompilerStrategy, MetaDef, Runner,
1158 };
1159 use crate::core::config::EngineCfg;
1160 use crate::worker::adapter::{WorkerError, WorkerResult};
1161 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
1162 use serde_json::json;
1163 use std::sync::Arc;
1164
1165 fn path(s: &str) -> Expr {
1166 Expr::Path {
1167 at: s.parse().expect("literal test path"),
1168 }
1169 }
1170 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
1171 FlowNode::Step {
1172 ref_: ref_.to_string(),
1173 in_,
1174 out,
1175 }
1176 }
1177
1178 fn agent(name: &str, fn_id: &str) -> AgentDef {
1179 AgentDef {
1180 name: name.to_string(),
1181 kind: AgentKind::RustFn,
1182 spec: json!({ "fn_id": fn_id }),
1183 profile: None,
1184 meta: Some(AgentMeta::default()),
1185 runner: None,
1186 runner_ref: None,
1187 verdict: None,
1188 }
1189 }
1190
1191 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
1192 let engine = Engine::new(EngineCfg::default());
1193 let mut reg = SpawnerRegistry::new();
1194 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1195 let compiler = Compiler::new(reg);
1196 TaskLaunchService::new(engine, compiler)
1197 }
1198
1199 fn build_service_with_cfg(
1203 factory: RustFnInProcessSpawnerFactory,
1204 cfg: EngineCfg,
1205 ) -> TaskLaunchService {
1206 let engine = Engine::new(cfg);
1207 let mut reg = SpawnerRegistry::new();
1208 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1209 let compiler = Compiler::new(reg);
1210 TaskLaunchService::new(engine, compiler)
1211 }
1212
1213 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
1214 Blueprint {
1215 schema_version: current_schema_version(),
1216 id: "ut".into(),
1217 flow,
1218 agents,
1219 operators: vec![],
1220 metas: vec![],
1221 hints: CompilerHints::default(),
1222 strategy: CompilerStrategy::default(),
1223 metadata: BlueprintMetadata::default(),
1224 spawner_hints: Default::default(),
1225 default_agent_kind: AgentKind::Operator,
1226 default_operator_kind: None,
1227 default_init_ctx: None,
1228 default_agent_ctx: None,
1229 default_context_policy: None,
1230 projection_placement: None,
1231 audits: vec![],
1232 degradation_policy: None,
1233 runners: vec![],
1234 default_runner: None,
1235 subprocesses: vec![],
1236 check_policy: None,
1237 blueprint_ref_includes: Vec::new(),
1238 }
1239 }
1240
1241 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
1242 TaskLaunchInput::automate(
1243 blueprint,
1244 "ut-op",
1245 Role::Operator,
1246 Duration::from_secs(30),
1247 init_ctx,
1248 )
1249 }
1250
1251 #[test]
1257 fn derive_audits_empty_by_default() {
1258 let blueprint = bp(
1259 step("echo", path("$.input"), path("$.out")),
1260 vec![agent("echo", "echo")],
1261 );
1262 assert!(
1263 derive_audits(&blueprint).is_empty(),
1264 "audits_absent_no_layer: an undeclared audits Vec must stay empty"
1265 );
1266 }
1267
1268 #[test]
1269 fn derive_audits_returns_blueprint_audits_verbatim() {
1270 let mut blueprint = bp(
1271 step("echo", path("$.input"), path("$.out")),
1272 vec![agent("echo", "echo")],
1273 );
1274 blueprint.audits = vec![crate::blueprint::AuditDef {
1275 agent: "auditor".to_string(),
1276 steps: None,
1277 mode: crate::blueprint::AuditMode::Async,
1278 }];
1279 let got = derive_audits(&blueprint);
1280 assert_eq!(got.len(), 1);
1281 assert_eq!(got[0].agent, "auditor");
1282 }
1283
1284 #[tokio::test]
1285 async fn launch_appends_audit_artifact_when_audits_declared() {
1286 use crate::blueprint::{AuditDef, AuditMode};
1287
1288 let factory = RustFnInProcessSpawnerFactory::new()
1289 .register_fn("echo", |inv| async move {
1290 Ok(WorkerResult {
1291 value: json!({ "echoed": inv.prompt }),
1292 ok: true,
1293 })
1294 })
1295 .register_fn("audit-fn", |_inv| async move {
1296 Ok(WorkerResult {
1297 value: json!({ "finding": "clean" }),
1298 ok: true,
1299 })
1300 });
1301 let svc = build_service(factory);
1302 let mut blueprint = bp(
1303 step("echo", path("$.input"), path("$.out")),
1304 vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
1305 );
1306 blueprint.audits = vec![AuditDef {
1307 agent: "auditor".to_string(),
1308 steps: None,
1309 mode: AuditMode::Sync,
1310 }];
1311 let out = svc
1312 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1313 .await
1314 .expect("launch ok — audits must never alter the audited step's outcome");
1315 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1316
1317 let audited_task_id = svc
1318 .engine()
1319 .with_state("test.find_audited_task", |s| {
1320 s.tasks
1321 .iter()
1322 .find(|(_, t)| t.spec.agent == "echo")
1323 .map(|(id, _)| id.clone())
1324 })
1325 .await
1326 .expect("with_state")
1327 .expect("the echo task must exist");
1328 let tail = svc.engine().output_tail(&audited_task_id, 1).await;
1329 let found = tail.iter().any(|ev| {
1330 matches!(
1331 ev,
1332 crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
1333 )
1334 });
1335 assert!(
1336 found,
1337 "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
1338 );
1339 }
1340
1341 #[tokio::test]
1342 async fn launch_single_step_writes_out_path() {
1343 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1344 Ok(WorkerResult {
1345 value: json!({ "echoed": inv.prompt }),
1346 ok: true,
1347 })
1348 });
1349 let svc = build_service(factory);
1350 let blueprint = bp(
1351 step("echo", path("$.input"), path("$.out")),
1352 vec![agent("echo", "echo")],
1353 );
1354 let out = svc
1355 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1356 .await
1357 .expect("launch ok");
1358 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1359 }
1360
1361 async fn dispatched_check_policy(
1382 launch_policy: Option<CheckPolicy>,
1383 bp_policy: Option<CheckPolicy>,
1384 ) -> Option<CheckPolicy> {
1385 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1386 Ok(WorkerResult {
1387 value: json!({ "echoed": inv.prompt }),
1388 ok: true,
1389 })
1390 });
1391 let svc = build_service(factory);
1392 let mut blueprint = bp(
1393 step("echo", path("$.input"), path("$.out")),
1394 vec![agent("echo", "echo")],
1395 );
1396 blueprint.check_policy = bp_policy;
1397 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1398 input.check_policy = launch_policy;
1399 input.task_input = Some(TaskInputSpec {
1400 project_root: None,
1401 work_dir: Some("/dispatched-check-policy-test-root".to_string()),
1402 task_metadata: None,
1403 });
1404 let _ = svc.launch(input).await;
1405 svc.engine()
1406 .with_state("test.read_dispatched_check_policy", |s| {
1407 s.tasks
1408 .values()
1409 .find(|t| t.spec.agent == "echo")
1410 .and_then(|t| t.spec.check_policy)
1411 })
1412 .await
1413 .expect("with_state")
1414 }
1415
1416 #[tokio::test]
1419 async fn cascade_launch_tier_wins_over_blueprint_tier() {
1420 assert_eq!(
1421 dispatched_check_policy(Some(CheckPolicy::Silent), Some(CheckPolicy::Strict)).await,
1422 Some(CheckPolicy::Silent),
1423 );
1424 }
1425
1426 #[tokio::test]
1429 async fn cascade_blueprint_tier_used_when_launch_absent() {
1430 assert_eq!(
1431 dispatched_check_policy(None, Some(CheckPolicy::Strict)).await,
1432 Some(CheckPolicy::Strict),
1433 );
1434 }
1435
1436 #[tokio::test]
1439 async fn cascade_launch_tier_alone_when_blueprint_absent() {
1440 assert_eq!(
1441 dispatched_check_policy(Some(CheckPolicy::Strict), None).await,
1442 Some(CheckPolicy::Strict),
1443 );
1444 }
1445
1446 #[tokio::test]
1451 async fn cascade_both_none_preserves_server_fallback() {
1452 assert_eq!(dispatched_check_policy(None, None).await, None);
1453 }
1454
1455 #[tokio::test]
1471 async fn strict_blueprint_without_roots_is_rejected_pre_dispatch() {
1472 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1473 Ok(WorkerResult {
1474 value: json!({ "echoed": inv.prompt }),
1475 ok: true,
1476 })
1477 });
1478 let svc = build_service(factory);
1479 let mut blueprint = bp(
1480 step("echo", path("$.input"), path("$.out")),
1481 vec![agent("echo", "echo")],
1482 );
1483 blueprint.check_policy = Some(CheckPolicy::Strict);
1484 let err = svc
1486 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1487 .await
1488 .expect_err("strict check_policy + no roots must be rejected before dispatch");
1489 match err {
1490 TaskLaunchError::PreDispatch(message) => {
1491 assert!(
1492 message.contains("strict"),
1493 "message must identify the strict-requires-roots condition: {message}"
1494 );
1495 }
1496 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1497 }
1498
1499 let dispatched = svc
1503 .engine()
1504 .with_state("test.no_echo_task_dispatched", |s| {
1505 s.tasks.values().any(|t| t.spec.agent == "echo")
1506 })
1507 .await
1508 .expect("with_state");
1509 assert!(
1510 !dispatched,
1511 "the pre-dispatch guard must reject before any step is dispatched"
1512 );
1513 }
1514
1515 #[tokio::test]
1527 async fn launch_without_any_check_policy_completes_fail_open() {
1528 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1529 Ok(WorkerResult {
1530 value: json!({ "echoed": inv.prompt }),
1531 ok: true,
1532 })
1533 });
1534 let svc = build_service(factory);
1535 let blueprint = bp(
1536 step("echo", path("$.input"), path("$.out")),
1537 vec![agent("echo", "echo")],
1538 );
1539 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1540 let out = svc
1541 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1542 .await
1543 .expect("warn-mode fail-open must let the launch complete");
1544 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1545 }
1546
1547 #[tokio::test]
1568 async fn strict_blueprint_with_launch_warn_override_bypasses_pre_dispatch_guard() {
1569 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1570 Ok(WorkerResult {
1571 value: json!({ "echoed": inv.prompt }),
1572 ok: true,
1573 })
1574 });
1575 let svc = build_service(factory);
1576 let mut blueprint = bp(
1577 step("echo", path("$.input"), path("$.out")),
1578 vec![agent("echo", "echo")],
1579 );
1580 blueprint.check_policy = Some(CheckPolicy::Strict);
1581 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1582 input.check_policy = Some(CheckPolicy::Warn);
1583 assert!(input.task_input.is_none(), "no roots supplied at all");
1584 let out = svc
1585 .launch(input)
1586 .await
1587 .expect("launch-tier warn override must bypass the pre-dispatch guard");
1588 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1589 }
1590
1591 #[tokio::test]
1597 async fn server_tier_strict_alone_triggers_pre_dispatch_guard() {
1598 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1599 Ok(WorkerResult {
1600 value: json!({ "echoed": inv.prompt }),
1601 ok: true,
1602 })
1603 });
1604 let svc = build_service_with_cfg(
1605 factory,
1606 EngineCfg {
1607 check_policy: CheckPolicy::Strict,
1608 ..EngineCfg::default()
1609 },
1610 );
1611 let blueprint = bp(
1612 step("echo", path("$.input"), path("$.out")),
1613 vec![agent("echo", "echo")],
1614 );
1615 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1616 let input = launch_input(blueprint, json!({ "input": "hi" }));
1617 assert!(input.check_policy.is_none(), "launch tier must be unset");
1618 assert!(input.task_input.is_none(), "no roots supplied");
1619 let err = svc.launch(input).await.expect_err(
1620 "server-tier Strict alone (BP/launch tiers both unset) must trigger the guard",
1621 );
1622 match err {
1623 TaskLaunchError::PreDispatch(message) => {
1624 assert!(
1625 message.contains("strict"),
1626 "expected the strict-requires-roots message, got: {message}"
1627 );
1628 }
1629 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1630 }
1631 }
1632
1633 #[tokio::test]
1639 async fn pre_dispatch_guard_rejects_when_task_input_present_but_roots_both_none() {
1640 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1641 Ok(WorkerResult {
1642 value: json!({ "echoed": inv.prompt }),
1643 ok: true,
1644 })
1645 });
1646 let svc = build_service(factory);
1647 let mut blueprint = bp(
1648 step("echo", path("$.input"), path("$.out")),
1649 vec![agent("echo", "echo")],
1650 );
1651 blueprint.check_policy = Some(CheckPolicy::Strict);
1652 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1653 input.task_input = Some(TaskInputSpec {
1654 project_root: None,
1655 work_dir: None,
1656 task_metadata: Some(json!({ "unrelated": true })),
1657 });
1658 let err = svc
1659 .launch(input)
1660 .await
1661 .expect_err("Some(TaskInputSpec) with both roots None must still be roots_missing");
1662 assert!(
1663 matches!(err, TaskLaunchError::PreDispatch(_)),
1664 "expected TaskLaunchError::PreDispatch, got {err:?}"
1665 );
1666 }
1667
1668 #[tokio::test]
1673 async fn pre_dispatch_guard_passes_when_work_dir_present_and_project_root_absent() {
1674 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1675 Ok(WorkerResult {
1676 value: json!({ "echoed": inv.prompt }),
1677 ok: true,
1678 })
1679 });
1680 let svc = build_service(factory);
1681 let mut blueprint = bp(
1682 step("echo", path("$.input"), path("$.out")),
1683 vec![agent("echo", "echo")],
1684 );
1685 blueprint.check_policy = Some(CheckPolicy::Strict);
1686 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1687 input.task_input = Some(TaskInputSpec {
1688 project_root: None,
1689 work_dir: Some("/repo/work".to_string()),
1690 task_metadata: None,
1691 });
1692 let out = svc
1693 .launch(input)
1694 .await
1695 .expect("work_dir alone must satisfy the guard's roots_missing check");
1696 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1697 }
1698
1699 #[tokio::test]
1700 async fn launch_three_step_seq_threads_ctx_forward() {
1701 let factory = RustFnInProcessSpawnerFactory::new()
1702 .register_fn("upper", |inv| async move {
1703 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1704 Ok(WorkerResult {
1705 value: json!(s.to_uppercase()),
1706 ok: true,
1707 })
1708 })
1709 .register_fn("suffix", |inv| async move {
1710 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1711 Ok(WorkerResult {
1712 value: json!(format!("{s}!")),
1713 ok: true,
1714 })
1715 })
1716 .register_fn("wrap", |inv| async move {
1717 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1718 Ok(WorkerResult {
1719 value: json!(format!("[{s}]")),
1720 ok: true,
1721 })
1722 });
1723 let svc = build_service(factory);
1724 let flow = FlowNode::Seq {
1725 children: vec![
1726 step("upper", path("$.in"), path("$.s1")),
1727 step("suffix", path("$.s1"), path("$.s2")),
1728 step("wrap", path("$.s2"), path("$.s3")),
1729 ],
1730 };
1731 let blueprint = bp(
1732 flow,
1733 vec![
1734 agent("upper", "upper"),
1735 agent("suffix", "suffix"),
1736 agent("wrap", "wrap"),
1737 ],
1738 );
1739 let out = svc
1740 .launch(launch_input(blueprint, json!({ "in": "hello" })))
1741 .await
1742 .expect("launch ok");
1743 assert_eq!(out.final_ctx["s1"], "HELLO");
1744 assert_eq!(out.final_ctx["s2"], "HELLO!");
1745 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
1746 }
1747
1748 #[tokio::test]
1749 async fn launch_fanout_join_all_parallel_completes() {
1750 use std::sync::atomic::{AtomicU32, Ordering};
1751 let counter = Arc::new(AtomicU32::new(0));
1752 let max_seen = Arc::new(AtomicU32::new(0));
1753 let counter_clone = counter.clone();
1754 let max_clone = max_seen.clone();
1755
1756 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
1759 let counter = counter_clone.clone();
1760 let max_seen = max_clone.clone();
1761 async move {
1762 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
1763 let mut prev = max_seen.load(Ordering::SeqCst);
1764 while now > prev {
1765 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
1766 Ok(_) => break,
1767 Err(p) => prev = p,
1768 }
1769 }
1770 tokio::time::sleep(Duration::from_millis(50)).await;
1771 counter.fetch_sub(1, Ordering::SeqCst);
1772 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1773 Ok(WorkerResult {
1774 value: json!(format!("did:{s}")),
1775 ok: true,
1776 })
1777 }
1778 });
1779 let svc = build_service(factory);
1780 let flow = FlowNode::Fanout {
1781 items: path("$.items"),
1782 bind: path("$.item"),
1783 body: Box::new(step("para", path("$.item"), path("$.r"))),
1784 join: JoinMode::All,
1785 out: path("$.results"),
1786 };
1787 let blueprint = bp(flow, vec![agent("para", "para")]);
1788 let out = svc
1789 .launch(launch_input(
1790 blueprint,
1791 json!({ "items": ["a", "b", "c", "d"] }),
1792 ))
1793 .await
1794 .expect("launch ok");
1795 let results = out.final_ctx["results"].as_array().expect("array");
1796 assert_eq!(results.len(), 4);
1797 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
1798 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
1799 }
1800 let max = max_seen.load(Ordering::SeqCst);
1801 assert!(
1802 max >= 2,
1803 "expected parallel execution (max inflight >= 2), got {max}"
1804 );
1805 }
1806
1807 #[tokio::test]
1808 async fn launch_propagates_worker_error_as_flow_eval_err() {
1809 let factory = RustFnInProcessSpawnerFactory::new()
1810 .register_fn("ok", |inv| async move {
1811 Ok(WorkerResult {
1812 value: json!(inv.prompt),
1813 ok: true,
1814 })
1815 })
1816 .register_fn("boom", |_inv| async move {
1817 Err(WorkerError::Failed("intentional boom".into()))
1818 });
1819 let svc = build_service(factory);
1820 let flow = FlowNode::Seq {
1821 children: vec![
1822 step("ok", path("$.input"), path("$.s1")),
1823 step("boom", path("$.s1"), path("$.s2")),
1824 step("ok", path("$.s2"), path("$.s3")),
1825 ],
1826 };
1827 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
1828 let err = svc
1829 .launch(launch_input(blueprint, json!({ "input": "x" })))
1830 .await
1831 .expect_err("expected fail");
1832 match err {
1833 TaskLaunchError::FlowEval { message: msg, .. } => {
1834 assert!(
1835 msg.contains("boom") || msg.contains("intentional"),
1836 "expected error to mention worker failure, got: {msg}"
1837 );
1838 }
1839 other => panic!("expected FlowEval error, got {other:?}"),
1840 }
1841 }
1842
1843 #[tokio::test]
1844 async fn launch_resolves_call_extern_via_registered_externs() {
1845 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1846 Ok(WorkerResult {
1847 value: json!({ "echoed": inv.prompt }),
1848 ok: true,
1849 })
1850 });
1851 let mut externs = mlua_flow_ir::ExternMap::new();
1852 externs.register("fmt.greet", |args: &[Value]| {
1853 let name = args[0].as_str().unwrap_or("?");
1854 Ok(json!(format!("hello, {name}")))
1855 });
1856 let svc = build_service(factory).with_externs(Arc::new(externs));
1857 let flow = step(
1858 "echo",
1859 Expr::CallExtern {
1860 ref_: "fmt.greet".into(),
1861 args: vec![path("$.who")],
1862 },
1863 path("$.out"),
1864 );
1865 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1866 let out = svc
1867 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1868 .await
1869 .expect("launch ok");
1870 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1871 }
1872
1873 #[tokio::test]
1874 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1875 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1876 Ok(WorkerResult {
1877 value: json!(inv.prompt),
1878 ok: true,
1879 })
1880 });
1881 let svc = build_service(factory); let flow = step(
1883 "echo",
1884 Expr::CallExtern {
1885 ref_: "fmt.greet".into(),
1886 args: vec![],
1887 },
1888 path("$.out"),
1889 );
1890 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1891 let err = svc
1892 .launch(launch_input(blueprint, json!({})))
1893 .await
1894 .expect_err("expected fail");
1895 match err {
1896 TaskLaunchError::FlowEval { message: msg, .. } => {
1897 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1898 }
1899 other => panic!("expected FlowEval error, got {other:?}"),
1900 }
1901 }
1902
1903 #[tokio::test]
1923 async fn launch_registers_the_blueprints_verdict_contracts_into_the_engine() {
1924 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |inv| async move {
1925 Ok(WorkerResult {
1926 value: json!(inv.prompt),
1927 ok: true,
1928 })
1929 });
1930 let svc = build_service(factory);
1931 let mut gate_agent = agent("gate", "gate");
1932 gate_agent.verdict = Some(mlua_swarm_schema::VerdictContract {
1933 channel: mlua_swarm_schema::VerdictChannel::Body,
1934 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
1935 });
1936 let flow = step("gate", path("$.input"), path("$.out"));
1937 let blueprint = bp(flow, vec![gate_agent]);
1938
1939 let out = svc
1940 .launch(launch_input(blueprint, json!({ "input": "PASS" })))
1941 .await
1942 .expect("launch ok");
1943 assert_eq!(out.final_ctx["out"], json!("PASS"));
1944
1945 let task_id = svc
1950 .engine()
1951 .with_state("test.find_dispatched_task_id", |s| {
1952 s.tasks.keys().next().cloned()
1953 })
1954 .await
1955 .expect("with_state")
1956 .expect("launch must have dispatched exactly one Step (one TaskState)");
1957
1958 let contract = svc
1959 .engine()
1960 .verdict_contract_for_task(&task_id)
1961 .await
1962 .expect(
1963 "TaskLaunchService::launch must have merged this Blueprint's compiled \
1964 verdict_contracts into the engine's runtime registry \
1965 (Engine::register_verdict_contracts, called right after \
1966 compiler.compile succeeds) — verdict_contract_for_task resolving None \
1967 here means that production wiring regressed",
1968 );
1969 assert_eq!(contract.channel, mlua_swarm_schema::VerdictChannel::Body);
1970 assert_eq!(
1971 contract.values,
1972 vec!["PASS".to_string(), "BLOCKED".to_string()]
1973 );
1974 }
1975
1976 #[tokio::test]
1981 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
1982 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
1983 use crate::types::{RunId, TaskId};
1984
1985 let factory = RustFnInProcessSpawnerFactory::new()
1986 .register_fn("upper", |inv| async move {
1987 Ok(WorkerResult {
1988 value: json!(inv.prompt.to_uppercase()),
1989 ok: true,
1990 })
1991 })
1992 .register_fn("suffix", |inv| async move {
1993 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1994 Ok(WorkerResult {
1995 value: json!(format!("{s}!")),
1996 ok: true,
1997 })
1998 });
1999 let svc = build_service(factory);
2000 let flow = FlowNode::Seq {
2001 children: vec![
2002 step("upper", path("$.in"), path("$.s1")),
2003 step("suffix", path("$.s1"), path("$.s2")),
2004 ],
2005 };
2006 let blueprint = bp(
2007 flow,
2008 vec![agent("upper", "upper"), agent("suffix", "suffix")],
2009 );
2010
2011 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2012 let run_id = RunId::new();
2013 run_store
2014 .create(RunRecord {
2015 id: run_id.clone(),
2016 task_id: TaskId::new(),
2017 status: RunStatus::Running,
2018 step_entries: Vec::new(),
2019 degradations: Vec::new(),
2020 operator_sid: None,
2021 result_ref: None,
2022 input_json: Some("{}".to_string()),
2023 created_at: 0,
2024 updated_at: 0,
2025 })
2026 .await
2027 .expect("seed RunRecord");
2028
2029 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
2030 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
2031
2032 let out = svc.launch(input).await.expect("launch ok");
2033 assert_eq!(out.final_ctx["s2"], "HI!");
2034
2035 let run = run_store.get(&run_id).await.expect("run present");
2036 assert_eq!(
2037 run.step_entries.len(),
2038 2,
2039 "expected one step_entry per dispatched step, got {:?}",
2040 run.step_entries
2041 );
2042 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
2043 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2044 assert!(run.step_entries[0].binding_digest.is_some());
2045 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
2046 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
2047 assert!(run.step_entries[1].binding_digest.is_some());
2048 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2049 assert_eq!(snapshot["bound_agents"].as_array().unwrap().len(), 2);
2050 }
2051
2052 #[tokio::test]
2053 async fn run_snapshot_reuses_bound_agent_after_blueprint_mutation() {
2054 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2055 use crate::types::{RunId, TaskId};
2056
2057 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2058 let run_id = RunId::new();
2059 run_store
2060 .create(RunRecord {
2061 id: run_id.clone(),
2062 task_id: TaskId::new(),
2063 status: RunStatus::Running,
2064 step_entries: Vec::new(),
2065 degradations: Vec::new(),
2066 operator_sid: None,
2067 result_ref: None,
2068 input_json: Some("{}".to_string()),
2069 created_at: 0,
2070 updated_at: 0,
2071 })
2072 .await
2073 .unwrap();
2074 let run_ctx = RunContext::new(run_id, run_store);
2075 let mut original_agent = agent("worker", "worker");
2076 original_agent.profile = Some(crate::blueprint::AgentProfile {
2077 system_prompt: "original role".to_string(),
2078 ..Default::default()
2079 });
2080 let mut blueprint = bp(
2081 step("worker", path("$.input"), path("$.out")),
2082 vec![original_agent],
2083 );
2084
2085 let (original, _) = load_or_resolve_bound_agents(
2086 &blueprint,
2087 Some(&run_ctx),
2088 None,
2089 LegacyWorkerBindingPolicy::Allow,
2090 )
2091 .await
2092 .unwrap();
2093 blueprint.agents[0].profile.as_mut().unwrap().system_prompt = "mutated role".to_string();
2094 let (restored, _) = load_or_resolve_bound_agents(
2095 &blueprint,
2096 Some(&run_ctx),
2097 None,
2098 LegacyWorkerBindingPolicy::Allow,
2099 )
2100 .await
2101 .unwrap();
2102
2103 assert_eq!(restored[0].binding_digest, original[0].binding_digest);
2104 assert_eq!(
2105 restored[0].agent.profile.as_ref().unwrap().system_prompt,
2106 "original role"
2107 );
2108 }
2109
2110 #[tokio::test]
2111 async fn strict_migration_policy_rejects_fresh_legacy_worker_binding() {
2112 let mut legacy_agent = agent("worker", "worker");
2113 legacy_agent.profile = Some(AgentProfile {
2114 worker_binding: Some("legacy-worker".to_string()),
2115 ..Default::default()
2116 });
2117 let blueprint = bp(
2118 step("worker", path("$.input"), path("$.out")),
2119 vec![legacy_agent],
2120 );
2121
2122 let error =
2123 load_or_resolve_bound_agents(&blueprint, None, None, LegacyWorkerBindingPolicy::Reject)
2124 .await
2125 .expect_err("strict migration policy must reject fallback");
2126 assert!(error
2127 .to_string()
2128 .contains("deprecated profile.worker_binding"));
2129 }
2130
2131 #[tokio::test]
2132 async fn run_snapshot_calls_binding_provider_only_on_first_resolution() {
2133 use crate::binding::{AgentBindingProvider, BindingProviderError};
2134 use crate::blueprint::{BindOutcome, BindReceipt, BindRequest};
2135 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2136 use crate::types::{RunId, TaskId};
2137 use std::sync::atomic::{AtomicUsize, Ordering};
2138
2139 struct CountingProvider(AtomicUsize);
2140
2141 #[async_trait::async_trait]
2142 impl AgentBindingProvider for CountingProvider {
2143 async fn bind(
2144 &self,
2145 requests: &[BindRequest],
2146 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
2147 self.0.fetch_add(1, Ordering::SeqCst);
2148 Ok(requests
2149 .iter()
2150 .map(|request| BindOutcome::Bound {
2151 receipt: BindReceipt {
2152 agent: request.agent.clone(),
2153 request_digest: request.request_digest.clone(),
2154 provider_id: "operator-main-ai".to_string(),
2155 provider_revision: Some("test".to_string()),
2156 resolved_model: request.requested_model.clone(),
2157 effective_tools: request.requested_tools.clone(),
2158 launch_variant: request.launch_variant.clone(),
2159 capability_snapshot_digest: None,
2160 },
2161 })
2162 .collect())
2163 }
2164 }
2165
2166 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2167 let run_id = RunId::new();
2168 run_store
2169 .create(RunRecord {
2170 id: run_id.clone(),
2171 task_id: TaskId::new(),
2172 status: RunStatus::Running,
2173 step_entries: Vec::new(),
2174 degradations: Vec::new(),
2175 operator_sid: None,
2176 result_ref: None,
2177 input_json: Some("{}".to_string()),
2178 created_at: 0,
2179 updated_at: 0,
2180 })
2181 .await
2182 .unwrap();
2183 let run_ctx = RunContext::new(run_id, run_store);
2184 let mut blueprint = bp(
2185 step("worker", path("$.input"), path("$.out")),
2186 vec![agent("worker", "worker")],
2187 );
2188 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2189 variant: "mse-worker".to_string(),
2190 tools: vec!["Read".to_string()],
2191 });
2192 let provider = CountingProvider(AtomicUsize::new(0));
2193
2194 let (first, _) = load_or_resolve_bound_agents(
2195 &blueprint,
2196 Some(&run_ctx),
2197 Some(&provider),
2198 LegacyWorkerBindingPolicy::Allow,
2199 )
2200 .await
2201 .unwrap();
2202 let (restored, _) = load_or_resolve_bound_agents(
2203 &blueprint,
2204 Some(&run_ctx),
2205 Some(&provider),
2206 LegacyWorkerBindingPolicy::Allow,
2207 )
2208 .await
2209 .unwrap();
2210
2211 assert_eq!(provider.0.load(Ordering::SeqCst), 1);
2212 assert!(first[0].attestation.is_some());
2213 assert_eq!(restored, first);
2214 }
2215
2216 #[tokio::test]
2225 async fn fresh_resolve_on_launch_persists_launch_origin_no_degradation() {
2226 use crate::store::run::{
2227 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2228 };
2229 use crate::types::{RunId, TaskId};
2230
2231 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2232 let run_id = RunId::new();
2233 run_store
2234 .create(RunRecord {
2235 id: run_id.clone(),
2236 task_id: TaskId::new(),
2237 status: RunStatus::Running,
2238 step_entries: Vec::new(),
2239 degradations: Vec::new(),
2240 operator_sid: None,
2241 result_ref: None,
2242 input_json: Some("{}".to_string()),
2243 created_at: 0,
2244 updated_at: 0,
2245 })
2246 .await
2247 .unwrap();
2248 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2250 let blueprint = bp(
2251 step("worker", path("$.input"), path("$.out")),
2252 vec![agent("worker", "worker")],
2253 );
2254
2255 load_or_resolve_bound_agents(
2256 &blueprint,
2257 Some(&run_ctx),
2258 None,
2259 LegacyWorkerBindingPolicy::Allow,
2260 )
2261 .await
2262 .expect("launch resolve ok");
2263
2264 let run = run_store.get(&run_id).await.expect("run present");
2265 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2266 assert!(
2267 snapshot["bound_agents"].is_array(),
2268 "bound_agents must be persisted"
2269 );
2270 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("launch"));
2271 assert_eq!(
2272 SnapshotOrigin::from_snapshot(&snapshot),
2273 SnapshotOrigin::Launch
2274 );
2275 assert!(
2276 run.degradations.is_empty(),
2277 "an initial-launch resolve is not a degradation"
2278 );
2279 }
2280
2281 #[tokio::test]
2286 async fn backfill_on_resume_persists_resume_origin_and_records_degradation() {
2287 use crate::store::run::{
2288 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2289 };
2290 use crate::types::{RunId, TaskId};
2291
2292 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2293 let run_id = RunId::new();
2294 run_store
2295 .create(RunRecord {
2296 id: run_id.clone(),
2297 task_id: TaskId::new(),
2298 status: RunStatus::Running,
2299 step_entries: Vec::new(),
2300 degradations: Vec::new(),
2301 operator_sid: None,
2302 result_ref: None,
2303 input_json: Some("{}".to_string()),
2304 created_at: 0,
2305 updated_at: 0,
2306 })
2307 .await
2308 .unwrap();
2309 let run_ctx = RunContext::new(run_id.clone(), run_store.clone()).with_resume();
2310 let blueprint = bp(
2311 step("worker", path("$.input"), path("$.out")),
2312 vec![agent("worker", "worker")],
2313 );
2314
2315 load_or_resolve_bound_agents(
2316 &blueprint,
2317 Some(&run_ctx),
2318 None,
2319 LegacyWorkerBindingPolicy::Allow,
2320 )
2321 .await
2322 .expect("resume backfill ok");
2323
2324 let run = run_store.get(&run_id).await.expect("run present");
2325 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2326 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("resume_backfill"));
2327 assert_eq!(
2328 SnapshotOrigin::from_snapshot(&snapshot),
2329 SnapshotOrigin::ResumeBackfill
2330 );
2331 assert_eq!(
2332 run.degradations.len(),
2333 1,
2334 "a resume backfill must record exactly one degradation"
2335 );
2336 assert_eq!(run.degradations[0].tool, "binding");
2337 assert_eq!(run.degradations[0].fallback, "resume_backfill");
2338 }
2339
2340 fn runner_blueprint(strict_binding: bool) -> Blueprint {
2347 let mut blueprint = bp(
2348 step("worker", path("$.input"), path("$.out")),
2349 vec![agent("worker", "worker")],
2350 );
2351 blueprint.strategy.strict_binding = strict_binding;
2352 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2353 variant: "mse-worker".to_string(),
2354 tools: vec!["Read".to_string()],
2355 });
2356 blueprint
2357 }
2358
2359 struct AlwaysUnboundProvider;
2362
2363 #[async_trait::async_trait]
2364 impl AgentBindingProvider for AlwaysUnboundProvider {
2365 async fn bind(
2366 &self,
2367 requests: &[crate::blueprint::BindRequest],
2368 ) -> Result<Vec<crate::blueprint::BindOutcome>, crate::binding::BindingProviderError>
2369 {
2370 Ok(requests
2371 .iter()
2372 .map(|request| crate::blueprint::BindOutcome::Unbound {
2373 agent: request.agent.clone(),
2374 reason: "no capability manifest submitted".to_string(),
2375 })
2376 .collect())
2377 }
2378 }
2379
2380 #[tokio::test]
2384 async fn non_strict_unbound_agent_runs_declaration_only_with_degradation() {
2385 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2386 use crate::types::{RunId, TaskId};
2387
2388 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2389 let run_id = RunId::new();
2390 run_store
2391 .create(RunRecord {
2392 id: run_id.clone(),
2393 task_id: TaskId::new(),
2394 status: RunStatus::Running,
2395 step_entries: Vec::new(),
2396 degradations: Vec::new(),
2397 operator_sid: None,
2398 result_ref: None,
2399 input_json: Some("{}".to_string()),
2400 created_at: 0,
2401 updated_at: 0,
2402 })
2403 .await
2404 .unwrap();
2405 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2406
2407 let (bound, _) = load_or_resolve_bound_agents(
2408 &runner_blueprint(false),
2409 Some(&run_ctx),
2410 Some(&AlwaysUnboundProvider),
2411 LegacyWorkerBindingPolicy::Allow,
2412 )
2413 .await
2414 .expect("non-strict launch must succeed even without an attestation");
2415 assert!(
2416 bound[0].attestation.is_none(),
2417 "an unattested agent must stay DeclarationOnly"
2418 );
2419
2420 let run = run_store.get(&run_id).await.expect("run present");
2421 assert_eq!(run.degradations.len(), 1, "expected one degradation entry");
2422 assert_eq!(run.degradations[0].tool, "binding");
2423 assert_eq!(run.degradations[0].fallback, "DeclarationOnly");
2424 assert!(run.degradations[0].error.contains("no capability manifest"));
2425 }
2426
2427 #[tokio::test]
2431 async fn strict_unbound_agent_fails_with_requirements_in_message() {
2432 let error = load_or_resolve_bound_agents(
2433 &runner_blueprint(true),
2434 None,
2435 Some(&AlwaysUnboundProvider),
2436 LegacyWorkerBindingPolicy::Allow,
2437 )
2438 .await
2439 .expect_err("strict + Unbound must reject the launch");
2440 match error {
2441 TaskLaunchError::PreDispatch(message) => {
2442 assert!(message.contains("worker"), "message: {message}");
2443 assert!(message.contains("mse-worker"), "message: {message}");
2444 assert!(message.contains("Read"), "message: {message}");
2445 }
2446 other => panic!("expected PreDispatch, got {other:?}"),
2447 }
2448 }
2449
2450 #[tokio::test]
2453 async fn strict_without_provider_rejects_runner_backed_launch() {
2454 let error = load_or_resolve_bound_agents(
2455 &runner_blueprint(true),
2456 None,
2457 None,
2458 LegacyWorkerBindingPolicy::Allow,
2459 )
2460 .await
2461 .expect_err("strict + no provider must reject a Runner-backed launch");
2462 match error {
2463 TaskLaunchError::PreDispatch(message) => {
2464 assert!(
2465 message.contains("strict_binding requires a binding provider"),
2466 "message: {message}"
2467 );
2468 }
2469 other => panic!("expected PreDispatch, got {other:?}"),
2470 }
2471 }
2472
2473 #[tokio::test]
2476 async fn strict_with_correct_manifest_attests_the_agent() {
2477 use crate::binding::ManifestBindingProvider;
2478 use crate::blueprint::{AgentProviderCapability, AgentProviderManifest};
2479
2480 let provider = ManifestBindingProvider::new(AgentProviderManifest {
2481 provider_id: "operator-main-ai".to_string(),
2482 provider_revision: Some("1".to_string()),
2483 capabilities: vec![AgentProviderCapability {
2484 launch_variant: Some("mse-worker".to_string()),
2485 resolved_model: None,
2486 effective_tools: vec!["Read".to_string()],
2487 capability_snapshot_digest: None,
2488 }],
2489 });
2490 let (bound, _) = load_or_resolve_bound_agents(
2491 &runner_blueprint(true),
2492 None,
2493 Some(&provider),
2494 LegacyWorkerBindingPolicy::Allow,
2495 )
2496 .await
2497 .expect("strict launch with a correct manifest must attest");
2498 assert!(
2499 bound[0].attestation.is_some(),
2500 "a correctly attested agent must carry its attestation"
2501 );
2502 }
2503
2504 #[test]
2513 fn worker_bindings_carry_request_digest_and_model() {
2514 let mut blueprint = runner_blueprint(false);
2515 blueprint.agents[0].profile = Some(AgentProfile {
2516 model: Some("claude-sonnet".to_string()),
2517 ..Default::default()
2518 });
2519 let bound = resolve_bound_agents(&blueprint).expect("resolvable Runner refs");
2520 let bindings = worker_bindings_from_bound_agents(&bound);
2521
2522 let wb = bindings.get("worker").expect("worker binding present");
2523 assert_eq!(
2524 wb.request_digest.as_ref(),
2525 Some(&bound[0].binding_digest),
2526 "the spawn frame must carry the immutable snapshot digest"
2527 );
2528 assert!(wb
2529 .request_digest
2530 .as_ref()
2531 .unwrap()
2532 .as_str()
2533 .starts_with("sha256:"));
2534 assert_eq!(wb.requested_model.as_deref(), Some("claude-sonnet"));
2535 }
2536
2537 #[test]
2540 fn worker_bindings_omit_model_when_profile_has_none() {
2541 let bound = resolve_bound_agents(&runner_blueprint(false)).expect("resolvable Runner refs");
2542 let bindings = worker_bindings_from_bound_agents(&bound);
2543 let wb = bindings.get("worker").expect("worker binding present");
2544 assert!(wb.request_digest.is_some());
2545 assert!(wb.requested_model.is_none());
2546 }
2547
2548 #[tokio::test]
2549 async fn launch_without_run_ctx_appends_no_step_entries() {
2550 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2554 Ok(WorkerResult {
2555 value: json!(inv.prompt),
2556 ok: true,
2557 })
2558 });
2559 let svc = build_service(factory);
2560 let blueprint = bp(
2561 step("echo", path("$.input"), path("$.out")),
2562 vec![agent("echo", "echo")],
2563 );
2564 let input = launch_input(blueprint, json!({ "input": "hi" }));
2565 assert!(
2566 input.run_ctx.is_none(),
2567 "automate() defaults run_ctx to None"
2568 );
2569 let out = svc.launch(input).await.expect("launch ok");
2570 assert_eq!(out.final_ctx["out"], "hi");
2571 }
2572
2573 #[tokio::test]
2579 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
2580 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2587 Ok(WorkerResult {
2588 value: json!({ "echoed": inv.prompt }),
2589 ok: true,
2590 })
2591 });
2592 let svc = build_service(factory);
2593 let blueprint = bp(
2594 step("echo", path("$.input"), path("$.out")),
2595 vec![agent("echo", "echo")],
2596 );
2597 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2598 input.task_input = Some(TaskInputSpec {
2599 project_root: Some("/repo".to_string()),
2600 work_dir: Some("/repo/work".to_string()),
2601 task_metadata: Some(json!({ "issue": 19 })),
2602 });
2603 let out = svc.launch(input).await.expect("launch ok");
2604 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
2605 assert!(
2606 out.final_ctx.get("project_root").is_none(),
2607 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
2608 out.final_ctx
2609 );
2610 assert!(out.final_ctx.get("work_dir").is_none());
2611 assert!(out.final_ctx.get("task_metadata").is_none());
2612 }
2613
2614 #[tokio::test]
2615 async fn launch_with_task_input_none_is_a_no_op() {
2616 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2617 Ok(WorkerResult {
2618 value: json!(inv.prompt),
2619 ok: true,
2620 })
2621 });
2622 let svc = build_service(factory);
2623 let blueprint = bp(
2624 step("echo", path("$.input"), path("$.out")),
2625 vec![agent("echo", "echo")],
2626 );
2627 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2628 assert!(input.task_input.is_none(), "automate() defaults to None");
2629 input.task_input = None;
2630 let out = svc.launch(input).await.expect("launch ok");
2631 assert_eq!(out.final_ctx["out"], "hi");
2632 }
2633
2634 #[tokio::test]
2635 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
2636 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2640 Ok(WorkerResult {
2641 value: json!(inv.prompt),
2642 ok: true,
2643 })
2644 });
2645 let svc = build_service(factory);
2646 let blueprint = bp(
2647 step("echo", path("$.input"), path("$.out")),
2648 vec![agent("echo", "echo")],
2649 );
2650 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2651 input.task_input = Some(TaskInputSpec::default());
2652 let out = svc.launch(input).await.expect("launch ok");
2653 assert_eq!(out.final_ctx["out"], "hi");
2654 }
2655
2656 #[test]
2661 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
2662 let bp_default = json!({ "seeded": "from-bp" });
2663 let task = json!({});
2664 let merged = merge_init_ctx(Some(&bp_default), &task);
2665 assert_eq!(merged, json!({ "seeded": "from-bp" }));
2666 }
2667
2668 #[test]
2669 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
2670 let bp_default = json!({});
2671 let task = json!({ "seeded": "from-task" });
2672 let merged = merge_init_ctx(Some(&bp_default), &task);
2673 assert_eq!(merged, json!({ "seeded": "from-task" }));
2674 }
2675
2676 #[test]
2677 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
2678 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2679 let task = json!({ "a": "task", "c": "task-only" });
2680 let merged = merge_init_ctx(Some(&bp_default), &task);
2681 assert_eq!(
2682 merged,
2683 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2684 );
2685 }
2686
2687 #[test]
2688 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
2689 let bp_default = json!({ "seeded": "from-bp" });
2690 let task = json!("plain-string-seed");
2691 let merged = merge_init_ctx(Some(&bp_default), &task);
2692 assert_eq!(merged, json!("plain-string-seed"));
2693 }
2694
2695 #[test]
2696 fn merge_init_ctx_no_bp_default_is_a_no_op() {
2697 let task = json!({ "input": "hi" });
2698 let merged = merge_init_ctx(None, &task);
2699 assert_eq!(merged, task);
2700 }
2701
2702 #[test]
2707 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
2708 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2712 let task = json!({ "a": "task", "c": "task-only" });
2713 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
2714 let two_layer = merge_init_ctx(Some(&bp_default), &task);
2715 assert_eq!(three_layer, two_layer);
2716 assert_eq!(
2717 three_layer,
2718 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2719 );
2720 }
2721
2722 #[test]
2723 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
2724 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2725 let task = json!({ "a": "task", "c": "task-only" });
2726 let run_override = json!({ "a": "run", "d": "run-only" });
2727 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2728 assert_eq!(
2729 merged,
2730 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
2731 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
2732 );
2733 }
2734
2735 #[test]
2736 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
2737 let bp_default = json!({ "seeded": "from-bp" });
2738 let task = json!({ "seeded": "from-task" });
2739 let run_override = json!("plain-string-run-seed");
2740 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2741 assert_eq!(merged, json!("plain-string-run-seed"));
2742 }
2743
2744 #[test]
2745 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
2746 let task = json!({ "input": "hi" });
2747 let merged = merge_init_ctx_3layer(None, &task, None);
2748 assert_eq!(merged, task);
2749 }
2750
2751 #[tokio::test]
2752 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
2753 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2756 Ok(WorkerResult {
2757 value: json!(inv.prompt),
2758 ok: true,
2759 })
2760 });
2761 let svc = build_service(factory);
2762 let mut blueprint = bp(
2763 step("echo", path("$.greeting"), path("$.out")),
2764 vec![agent("echo", "echo")],
2765 );
2766 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
2767 let out = svc
2769 .launch(launch_input(blueprint, json!({})))
2770 .await
2771 .expect("launch ok");
2772 assert_eq!(out.final_ctx["out"], "hello from bp");
2773 }
2774
2775 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
2780 AgentDef {
2781 name: name.to_string(),
2782 kind: AgentKind::RustFn,
2783 spec: json!({ "fn_id": fn_id }),
2784 profile: None,
2785 meta: Some(meta),
2786 runner: None,
2787 runner_ref: None,
2788 verdict: None,
2789 }
2790 }
2791
2792 #[test]
2793 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
2794 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2795 let (global, per_agent) = derive_agent_ctx(&blueprint);
2796 assert_eq!(global, None);
2797 assert!(per_agent.is_empty());
2798 }
2799
2800 #[test]
2801 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
2802 let mut blueprint = bp(
2803 step("echo", path("$.in"), path("$.out")),
2804 vec![
2805 agent_with_meta(
2806 "with-ctx",
2807 "echo",
2808 AgentMeta {
2809 ctx: Some(json!({ "org_conventions": "x" })),
2810 ..Default::default()
2811 },
2812 ),
2813 agent("no-ctx", "echo"),
2814 ],
2815 );
2816 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
2817 let (global, per_agent) = derive_agent_ctx(&blueprint);
2818 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
2819 assert_eq!(
2820 per_agent.len(),
2821 1,
2822 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
2823 );
2824 assert_eq!(
2825 per_agent.get("with-ctx"),
2826 Some(&json!({ "org_conventions": "x" }))
2827 );
2828 assert!(!per_agent.contains_key("no-ctx"));
2829 }
2830
2831 #[test]
2832 fn derive_context_policies_empty_blueprint_yields_empty_state() {
2833 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2834 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2835 assert_eq!(default_policy, None);
2836 assert!(per_agent.is_empty());
2837 }
2838
2839 #[test]
2840 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
2841 let mut blueprint = bp(
2842 step("echo", path("$.in"), path("$.out")),
2843 vec![
2844 agent_with_meta(
2845 "with-policy",
2846 "echo",
2847 AgentMeta {
2848 context_policy: Some(ContextPolicy {
2849 include: None,
2850 exclude: vec!["work_dir".to_string()],
2851 ..Default::default()
2852 }),
2853 ..Default::default()
2854 },
2855 ),
2856 agent("no-policy", "echo"),
2857 ],
2858 );
2859 blueprint.default_context_policy = Some(ContextPolicy {
2860 include: Some(vec!["project_root".to_string()]),
2861 exclude: vec![],
2862 ..Default::default()
2863 });
2864 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2865 assert_eq!(
2866 default_policy,
2867 Some(ContextPolicy {
2868 include: Some(vec!["project_root".to_string()]),
2869 exclude: vec![],
2870 ..Default::default()
2871 })
2872 );
2873 assert_eq!(per_agent.len(), 1);
2874 assert_eq!(
2875 per_agent.get("with-policy"),
2876 Some(&ContextPolicy {
2877 include: None,
2878 exclude: vec!["work_dir".to_string()],
2879 ..Default::default()
2880 })
2881 );
2882 assert!(!per_agent.contains_key("no-policy"));
2883 }
2884
2885 #[test]
2891 fn derive_step_metas_empty_blueprint_yields_empty_map() {
2892 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2893 assert!(derive_step_metas(&blueprint).is_empty());
2894 }
2895
2896 #[test]
2897 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
2898 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2899 blueprint.metas = vec![
2900 MetaDef {
2901 name: "heavy-scan".to_string(),
2902 ctx: json!({ "work_dir": "/x" }),
2903 },
2904 MetaDef {
2905 name: "light-scan".to_string(),
2906 ctx: json!({ "work_dir": "/y" }),
2907 },
2908 ];
2909 let metas = derive_step_metas(&blueprint);
2910 assert_eq!(metas.len(), 2);
2911 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
2912 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
2913 }
2914
2915 #[test]
2916 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
2917 let mut blueprint = bp(
2918 step("echo", path("$.in"), path("$.out")),
2919 vec![agent_with_meta(
2920 "with-meta-ref",
2921 "echo",
2922 AgentMeta {
2923 ctx: Some(json!({ "work_dir": "/inline-wins" })),
2924 meta_ref: Some("shared".to_string()),
2925 ..Default::default()
2926 },
2927 )],
2928 );
2929 blueprint.metas = vec![MetaDef {
2930 name: "shared".to_string(),
2931 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
2932 }];
2933 let (_, per_agent) = derive_agent_ctx(&blueprint);
2934 assert_eq!(
2935 per_agent.get("with-meta-ref"),
2936 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
2937 "inline ctx must win the collided key while pool-only keys survive the merge"
2938 );
2939 }
2940
2941 #[test]
2942 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
2943 let mut blueprint = bp(
2944 step("echo", path("$.in"), path("$.out")),
2945 vec![agent_with_meta(
2946 "with-meta-ref-only",
2947 "echo",
2948 AgentMeta {
2949 meta_ref: Some("shared".to_string()),
2950 ..Default::default()
2951 },
2952 )],
2953 );
2954 blueprint.metas = vec![MetaDef {
2955 name: "shared".to_string(),
2956 ctx: json!({ "work_dir": "/base" }),
2957 }];
2958 let (_, per_agent) = derive_agent_ctx(&blueprint);
2959 assert_eq!(
2960 per_agent.get("with-meta-ref-only"),
2961 Some(&json!({ "work_dir": "/base" }))
2962 );
2963 }
2964
2965 #[test]
2966 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
2967 let blueprint = bp(
2968 step("echo", path("$.in"), path("$.out")),
2969 vec![agent_with_meta(
2970 "with-unresolved-meta-ref",
2971 "echo",
2972 AgentMeta {
2973 ctx: Some(json!({ "work_dir": "/inline-only" })),
2974 meta_ref: Some("missing".to_string()),
2975 ..Default::default()
2976 },
2977 )],
2978 );
2979 let (_, per_agent) = derive_agent_ctx(&blueprint);
2981 assert_eq!(
2982 per_agent.get("with-unresolved-meta-ref"),
2983 Some(&json!({ "work_dir": "/inline-only" })),
2984 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
2985 );
2986 }
2987
2988 #[test]
3004 fn resolve_runner_legacy_fallback_matches_derive_worker_bindings_semantics() {
3005 fn legacy_agent(name: &str, variant: &str, tools: Vec<&str>) -> AgentDef {
3006 AgentDef {
3007 name: name.to_string(),
3008 kind: AgentKind::Operator,
3009 spec: json!({}),
3010 profile: Some(AgentProfile {
3011 worker_binding: Some(variant.to_string()),
3012 tools: tools.into_iter().map(str::to_string).collect(),
3013 ..Default::default()
3014 }),
3015 meta: None,
3016 runner: None,
3017 runner_ref: None,
3018 verdict: None,
3019 }
3020 }
3021
3022 let blueprint = bp(
3023 step("planner", path("$.in"), path("$.out")),
3024 vec![
3025 legacy_agent("planner", "mse-worker-planner", vec!["Read", "Grep"]),
3026 legacy_agent("coder", "mse-worker-coder", vec![]),
3027 agent("no-binding", "echo"),
3028 ],
3029 );
3030
3031 let derived = derive_worker_bindings(&blueprint);
3032
3033 for agent_def in &blueprint.agents {
3034 let resolved = resolve_runner(&blueprint, agent_def).expect("no unresolved refs");
3035 match derived.get(&agent_def.name) {
3036 Some(binding) => {
3037 assert_eq!(
3038 resolved,
3039 Some(Runner::WsClaudeCode {
3040 variant: binding.variant.clone(),
3041 tools: binding.tools.clone(),
3042 }),
3043 "resolve_runner must synthesize the same WsClaudeCode Runner \
3044 derive_worker_bindings produces for agent '{}'",
3045 agent_def.name
3046 );
3047 }
3048 None => {
3049 assert_eq!(
3050 resolved, None,
3051 "agent '{}' has no derive_worker_bindings entry, so resolve_runner \
3052 must resolve to None too (no other tier declared)",
3053 agent_def.name
3054 );
3055 }
3056 }
3057 }
3058 }
3059
3060 #[test]
3061 fn ws_operator_runner_projects_into_the_existing_spawn_binding() {
3062 let mut blueprint = bp(
3063 step("reviewer", path("$.in"), path("$.out")),
3064 vec![agent("reviewer", "echo")],
3065 );
3066 blueprint.agents[0].runner = Some(Runner::WsOperator {
3067 variant: "mse-reviewer".to_string(),
3068 tools: vec!["Read".to_string(), "Grep".to_string()],
3069 });
3070
3071 let derived = derive_worker_bindings(&blueprint);
3072 let binding = derived
3073 .get("reviewer")
3074 .expect("ws_operator must feed the canonical spawn binding path");
3075 assert_eq!(binding.variant, "mse-reviewer");
3076 assert_eq!(binding.tools, ["Read", "Grep"]);
3077 }
3078
3079 fn counting_echo_service() -> (TaskLaunchService, Arc<std::sync::atomic::AtomicUsize>) {
3088 use std::sync::atomic::{AtomicUsize, Ordering};
3089 let calls = Arc::new(AtomicUsize::new(0));
3090 let counter = calls.clone();
3091 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
3092 let counter = counter.clone();
3093 async move {
3094 counter.fetch_add(1, Ordering::SeqCst);
3095 Ok(WorkerResult {
3096 value: json!({ "echoed": inv.prompt }),
3097 ok: true,
3098 })
3099 }
3100 });
3101 (build_service(factory), calls)
3102 }
3103
3104 async fn seed_legacy_run(run_store: &Arc<dyn crate::store::run::RunStore>) -> crate::RunId {
3105 use crate::store::run::{RunRecord, RunStatus};
3106 use crate::types::TaskId;
3107 let run_id = crate::RunId::new();
3108 run_store
3109 .create(RunRecord {
3110 id: run_id.clone(),
3111 task_id: TaskId::new(),
3112 status: RunStatus::Running,
3113 step_entries: Vec::new(),
3114 degradations: Vec::new(),
3115 operator_sid: None,
3116 result_ref: None,
3117 input_json: Some("{}".to_string()),
3120 created_at: 0,
3121 updated_at: 0,
3122 })
3123 .await
3124 .expect("seed legacy RunRecord");
3125 run_id
3126 }
3127
3128 #[tokio::test]
3134 async fn backfilled_run_replays_legacy_keys_stably_across_two_resumes() {
3135 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3136 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3137 use std::sync::atomic::Ordering;
3138 use std::sync::Mutex;
3139
3140 let (svc, echo_calls) = counting_echo_service();
3141 let blueprint = bp(
3142 step("echo", path("$.input"), path("$.out")),
3143 vec![agent("echo", "echo")],
3144 );
3145 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3146 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3147 let run_id = seed_legacy_run(&run_store).await;
3148
3149 let rc1 = RunContext::new(run_id.clone(), run_store.clone())
3153 .with_replay_store(replay_store.clone())
3154 .with_resume();
3155 let mut input1 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3156 input1.run_ctx = Some(rc1);
3157 let out1 = svc.launch(input1).await.expect("phase-1 resume launch ok");
3158 assert_eq!(out1.final_ctx["out"]["echoed"], "hi");
3159 assert_eq!(
3160 echo_calls.load(Ordering::SeqCst),
3161 1,
3162 "phase 1 dispatches the worker once (nothing to replay yet)"
3163 );
3164 let entries = replay_store
3165 .list_by_run(&run_id)
3166 .await
3167 .expect("list replay rows");
3168 assert_eq!(
3169 entries.len(),
3170 1,
3171 "phase 1 must log exactly one legacy-hashed replay row"
3172 );
3173 let run = run_store.get(&run_id).await.expect("run present");
3174 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3175 assert_eq!(
3176 SnapshotOrigin::from_snapshot(&snap),
3177 SnapshotOrigin::ResumeBackfill,
3178 "phase 1 must pin the snapshot as resume_backfill"
3179 );
3180
3181 let cursor = ReplayCursor::from_entries(entries);
3186 let rc2 = RunContext::new(run_id.clone(), run_store.clone())
3187 .with_replay_store(replay_store.clone())
3188 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
3189 .with_resume();
3190 let mut input2 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3191 input2.run_ctx = Some(rc2);
3192 let out2 = svc.launch(input2).await.expect("phase-2 resume launch ok");
3193 assert_eq!(out2.final_ctx["out"]["echoed"], "hi");
3194 assert_eq!(
3195 echo_calls.load(Ordering::SeqCst),
3196 1,
3197 "phase 2 must REPLAY the legacy-hashed row — the worker must not run again"
3198 );
3199 let run2 = run_store.get(&run_id).await.expect("run present");
3200 let snap2: Value = serde_json::from_str(run2.input_json.as_deref().unwrap()).unwrap();
3201 assert_eq!(
3202 SnapshotOrigin::from_snapshot(&snap2),
3203 SnapshotOrigin::ResumeBackfill,
3204 "origin must stay resume_backfill across resumes (replay key stability)"
3205 );
3206 }
3207
3208 #[tokio::test]
3213 async fn launch_origin_run_uses_digest_keys_and_misses_legacy_replay_row() {
3214 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3215 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3216 use std::sync::atomic::Ordering;
3217 use std::sync::Mutex;
3218
3219 let (svc, echo_calls) = counting_echo_service();
3220 let blueprint = bp(
3221 step("echo", path("$.input"), path("$.out")),
3222 vec![agent("echo", "echo")],
3223 );
3224 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3225 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3226
3227 let backfill_run = seed_legacy_run(&run_store).await;
3229 let rc_bf = RunContext::new(backfill_run.clone(), run_store.clone())
3230 .with_replay_store(replay_store.clone())
3231 .with_resume();
3232 let mut input_bf = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3233 input_bf.run_ctx = Some(rc_bf);
3234 svc.launch(input_bf).await.expect("backfill launch ok");
3235 assert_eq!(echo_calls.load(Ordering::SeqCst), 1);
3236 let legacy_entries = replay_store
3237 .list_by_run(&backfill_run)
3238 .await
3239 .expect("list legacy rows");
3240 assert_eq!(legacy_entries.len(), 1);
3241
3242 let launch_run = seed_legacy_run(&run_store).await;
3246 let cursor = ReplayCursor::from_entries(legacy_entries);
3247 let rc_launch = RunContext::new(launch_run.clone(), run_store.clone())
3248 .with_replay_store(replay_store.clone())
3249 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
3250 let mut input_launch = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3251 input_launch.run_ctx = Some(rc_launch);
3252 svc.launch(input_launch)
3253 .await
3254 .expect("launch-origin launch ok");
3255 assert_eq!(
3256 echo_calls.load(Ordering::SeqCst),
3257 2,
3258 "a launch-origin Run keys replay by binding digest, so the \
3259 legacy-hashed row must MISS and the worker must run"
3260 );
3261 let run = run_store.get(&launch_run).await.expect("run present");
3262 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3263 assert_eq!(SnapshotOrigin::from_snapshot(&snap), SnapshotOrigin::Launch);
3264 }
3265
3266 #[test]
3274 fn task_launch_error_flow_eval_struct_variant_display_preserves_prefix() {
3275 let err = TaskLaunchError::FlowEval {
3276 message: "dispatcher error at ref foo".to_string(),
3277 failed_step: Some("foo".to_string()),
3278 verdict_value: Some(json!({"verdict": "BLOCKED"})),
3279 partial_ctx: Some(json!({"steps": {}})),
3280 };
3281 assert_eq!(err.to_string(), "flow eval: dispatcher error at ref foo");
3282
3283 let err_bare = TaskLaunchError::FlowEval {
3287 message: "unresolved extern".to_string(),
3288 failed_step: None,
3289 verdict_value: None,
3290 partial_ctx: None,
3291 };
3292 assert_eq!(err_bare.to_string(), "flow eval: unresolved extern");
3293 }
3294
3295 #[tokio::test]
3300 async fn task_launch_flow_eval_error_carries_failed_step_and_verdict_value() {
3301 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3302 use crate::types::{RunId, TaskId};
3303
3304 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3305 Ok(WorkerResult {
3306 value: json!({ "verdict": "BLOCKED", "reason": "not applicable" }),
3307 ok: false,
3308 })
3309 });
3310 let svc = build_service(factory);
3311 let blueprint = bp(
3312 step("gate", path("$.input"), path("$.out")),
3313 vec![agent("gate", "gate")],
3314 );
3315
3316 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3317 let run_id = RunId::new();
3318 run_store
3319 .create(RunRecord {
3320 id: run_id.clone(),
3321 task_id: TaskId::new(),
3322 status: RunStatus::Running,
3323 step_entries: Vec::new(),
3324 degradations: Vec::new(),
3325 operator_sid: None,
3326 result_ref: None,
3327 input_json: Some("{}".to_string()),
3328 created_at: 0,
3329 updated_at: 0,
3330 })
3331 .await
3332 .expect("seed RunRecord");
3333
3334 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
3335 input.run_ctx = Some(RunContext::new(run_id, run_store));
3336
3337 let err = svc.launch(input).await.expect_err("expected FlowEval");
3338 match err {
3339 TaskLaunchError::FlowEval {
3340 message,
3341 failed_step,
3342 verdict_value,
3343 partial_ctx,
3344 } => {
3345 assert!(
3346 message.contains("blocked"),
3347 "expected message to mention blocked, got: {message}"
3348 );
3349 assert_eq!(
3350 failed_step,
3351 Some("gate".to_string()),
3352 "failed_step should be the Blueprint step ref, not the opaque StepId"
3353 );
3354 let vv = verdict_value.expect("verdict_value must be Some for Blocked");
3355 assert_eq!(vv["verdict"], "BLOCKED");
3356 assert_eq!(vv["reason"], "not applicable");
3357 assert!(
3361 partial_ctx.is_some(),
3362 "partial_ctx must be Some when a RunContext was supplied"
3363 );
3364 }
3365 other => panic!("expected FlowEval, got {other:?}"),
3366 }
3367 }
3368
3369 #[tokio::test]
3375 async fn task_launch_flow_eval_error_partial_ctx_reconstructs_from_run_store() {
3376 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3377 use crate::types::{RunId, TaskId};
3378
3379 let factory = RustFnInProcessSpawnerFactory::new()
3380 .register_fn("upper", |inv| async move {
3381 Ok(WorkerResult {
3382 value: json!(inv.prompt.to_uppercase()),
3383 ok: true,
3384 })
3385 })
3386 .register_fn("gate", |_inv| async move {
3387 Ok(WorkerResult {
3388 value: json!({ "verdict": "BLOCKED" }),
3389 ok: false,
3390 })
3391 })
3392 .register_fn("never", |inv| async move {
3393 Ok(WorkerResult {
3394 value: json!(inv.prompt),
3395 ok: true,
3396 })
3397 });
3398 let svc = build_service(factory);
3399 let flow = FlowNode::Seq {
3400 children: vec![
3401 step("upper", path("$.in"), path("$.s1")),
3402 step("gate", path("$.s1"), path("$.s2")),
3403 step("never", path("$.s2"), path("$.s3")),
3404 ],
3405 };
3406 let blueprint = bp(
3407 flow,
3408 vec![
3409 agent("upper", "upper"),
3410 agent("gate", "gate"),
3411 agent("never", "never"),
3412 ],
3413 );
3414
3415 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3416 let run_id = RunId::new();
3417 run_store
3418 .create(RunRecord {
3419 id: run_id.clone(),
3420 task_id: TaskId::new(),
3421 status: RunStatus::Running,
3422 step_entries: Vec::new(),
3423 degradations: Vec::new(),
3424 operator_sid: None,
3425 result_ref: None,
3426 input_json: Some("{}".to_string()),
3427 created_at: 0,
3428 updated_at: 0,
3429 })
3430 .await
3431 .expect("seed RunRecord");
3432
3433 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
3434 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
3435
3436 let err = svc.launch(input).await.expect_err("expected FlowEval");
3437 let partial_ctx = match err {
3438 TaskLaunchError::FlowEval { partial_ctx, .. } => {
3439 partial_ctx.expect("partial_ctx must be Some")
3440 }
3441 other => panic!("expected FlowEval, got {other:?}"),
3442 };
3443 let steps = partial_ctx
3444 .get("steps")
3445 .and_then(|v| v.as_object())
3446 .expect("partial_ctx.steps object");
3447 assert_eq!(
3451 steps.len(),
3452 2,
3453 "expected 2 step_entries (upper passed + gate blocked), got: {steps:?}"
3454 );
3455 let mut status_by_ref: HashMap<String, String> = HashMap::new();
3456 for (_step_id, entry) in steps {
3457 let step_ref = entry
3458 .get("step_ref")
3459 .and_then(|v| v.as_str())
3460 .expect("step_ref present")
3461 .to_string();
3462 let status = entry
3463 .get("status")
3464 .and_then(|v| v.as_str())
3465 .expect("status present")
3466 .to_string();
3467 status_by_ref.insert(step_ref, status);
3468 }
3469 assert_eq!(
3470 status_by_ref.get("upper").map(String::as_str),
3471 Some("passed")
3472 );
3473 assert_eq!(
3474 status_by_ref.get("gate").map(String::as_str),
3475 Some("blocked")
3476 );
3477 assert!(
3478 !status_by_ref.contains_key("never"),
3479 "step 'never' must not appear — flow-ir stops dispatching after Blocked abort"
3480 );
3481 }
3482
3483 #[tokio::test]
3489 async fn task_launch_flow_eval_error_without_run_ctx_has_none_fields() {
3490 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3491 Ok(WorkerResult {
3492 value: json!({ "verdict": "BLOCKED" }),
3493 ok: false,
3494 })
3495 });
3496 let svc = build_service(factory);
3497 let blueprint = bp(
3498 step("gate", path("$.input"), path("$.out")),
3499 vec![agent("gate", "gate")],
3500 );
3501 let err = svc
3502 .launch(launch_input(blueprint, json!({ "input": "hi" })))
3503 .await
3504 .expect_err("expected FlowEval");
3505 match err {
3506 TaskLaunchError::FlowEval {
3507 failed_step,
3508 verdict_value,
3509 partial_ctx,
3510 ..
3511 } => {
3512 assert_eq!(
3513 failed_step, None,
3514 "failed_step must be None without run_ctx"
3515 );
3516 assert_eq!(
3517 verdict_value, None,
3518 "verdict_value must be None without run_ctx"
3519 );
3520 assert_eq!(
3521 partial_ctx, None,
3522 "partial_ctx must be None without run_ctx"
3523 );
3524 }
3525 other => panic!("expected FlowEval, got {other:?}"),
3526 }
3527 }
3528}