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 check_policy: None,
1236 blueprint_ref_includes: Vec::new(),
1237 }
1238 }
1239
1240 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
1241 TaskLaunchInput::automate(
1242 blueprint,
1243 "ut-op",
1244 Role::Operator,
1245 Duration::from_secs(30),
1246 init_ctx,
1247 )
1248 }
1249
1250 #[test]
1256 fn derive_audits_empty_by_default() {
1257 let blueprint = bp(
1258 step("echo", path("$.input"), path("$.out")),
1259 vec![agent("echo", "echo")],
1260 );
1261 assert!(
1262 derive_audits(&blueprint).is_empty(),
1263 "audits_absent_no_layer: an undeclared audits Vec must stay empty"
1264 );
1265 }
1266
1267 #[test]
1268 fn derive_audits_returns_blueprint_audits_verbatim() {
1269 let mut blueprint = bp(
1270 step("echo", path("$.input"), path("$.out")),
1271 vec![agent("echo", "echo")],
1272 );
1273 blueprint.audits = vec![crate::blueprint::AuditDef {
1274 agent: "auditor".to_string(),
1275 steps: None,
1276 mode: crate::blueprint::AuditMode::Async,
1277 }];
1278 let got = derive_audits(&blueprint);
1279 assert_eq!(got.len(), 1);
1280 assert_eq!(got[0].agent, "auditor");
1281 }
1282
1283 #[tokio::test]
1284 async fn launch_appends_audit_artifact_when_audits_declared() {
1285 use crate::blueprint::{AuditDef, AuditMode};
1286
1287 let factory = RustFnInProcessSpawnerFactory::new()
1288 .register_fn("echo", |inv| async move {
1289 Ok(WorkerResult {
1290 value: json!({ "echoed": inv.prompt }),
1291 ok: true,
1292 })
1293 })
1294 .register_fn("audit-fn", |_inv| async move {
1295 Ok(WorkerResult {
1296 value: json!({ "finding": "clean" }),
1297 ok: true,
1298 })
1299 });
1300 let svc = build_service(factory);
1301 let mut blueprint = bp(
1302 step("echo", path("$.input"), path("$.out")),
1303 vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
1304 );
1305 blueprint.audits = vec![AuditDef {
1306 agent: "auditor".to_string(),
1307 steps: None,
1308 mode: AuditMode::Sync,
1309 }];
1310 let out = svc
1311 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1312 .await
1313 .expect("launch ok — audits must never alter the audited step's outcome");
1314 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1315
1316 let audited_task_id = svc
1317 .engine()
1318 .with_state("test.find_audited_task", |s| {
1319 s.tasks
1320 .iter()
1321 .find(|(_, t)| t.spec.agent == "echo")
1322 .map(|(id, _)| id.clone())
1323 })
1324 .await
1325 .expect("with_state")
1326 .expect("the echo task must exist");
1327 let tail = svc.engine().output_tail(&audited_task_id, 1).await;
1328 let found = tail.iter().any(|ev| {
1329 matches!(
1330 ev,
1331 crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
1332 )
1333 });
1334 assert!(
1335 found,
1336 "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
1337 );
1338 }
1339
1340 #[tokio::test]
1341 async fn launch_single_step_writes_out_path() {
1342 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1343 Ok(WorkerResult {
1344 value: json!({ "echoed": inv.prompt }),
1345 ok: true,
1346 })
1347 });
1348 let svc = build_service(factory);
1349 let blueprint = bp(
1350 step("echo", path("$.input"), path("$.out")),
1351 vec![agent("echo", "echo")],
1352 );
1353 let out = svc
1354 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1355 .await
1356 .expect("launch ok");
1357 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1358 }
1359
1360 async fn dispatched_check_policy(
1381 launch_policy: Option<CheckPolicy>,
1382 bp_policy: Option<CheckPolicy>,
1383 ) -> Option<CheckPolicy> {
1384 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1385 Ok(WorkerResult {
1386 value: json!({ "echoed": inv.prompt }),
1387 ok: true,
1388 })
1389 });
1390 let svc = build_service(factory);
1391 let mut blueprint = bp(
1392 step("echo", path("$.input"), path("$.out")),
1393 vec![agent("echo", "echo")],
1394 );
1395 blueprint.check_policy = bp_policy;
1396 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1397 input.check_policy = launch_policy;
1398 input.task_input = Some(TaskInputSpec {
1399 project_root: None,
1400 work_dir: Some("/dispatched-check-policy-test-root".to_string()),
1401 task_metadata: None,
1402 });
1403 let _ = svc.launch(input).await;
1404 svc.engine()
1405 .with_state("test.read_dispatched_check_policy", |s| {
1406 s.tasks
1407 .values()
1408 .find(|t| t.spec.agent == "echo")
1409 .and_then(|t| t.spec.check_policy)
1410 })
1411 .await
1412 .expect("with_state")
1413 }
1414
1415 #[tokio::test]
1418 async fn cascade_launch_tier_wins_over_blueprint_tier() {
1419 assert_eq!(
1420 dispatched_check_policy(Some(CheckPolicy::Silent), Some(CheckPolicy::Strict)).await,
1421 Some(CheckPolicy::Silent),
1422 );
1423 }
1424
1425 #[tokio::test]
1428 async fn cascade_blueprint_tier_used_when_launch_absent() {
1429 assert_eq!(
1430 dispatched_check_policy(None, Some(CheckPolicy::Strict)).await,
1431 Some(CheckPolicy::Strict),
1432 );
1433 }
1434
1435 #[tokio::test]
1438 async fn cascade_launch_tier_alone_when_blueprint_absent() {
1439 assert_eq!(
1440 dispatched_check_policy(Some(CheckPolicy::Strict), None).await,
1441 Some(CheckPolicy::Strict),
1442 );
1443 }
1444
1445 #[tokio::test]
1450 async fn cascade_both_none_preserves_server_fallback() {
1451 assert_eq!(dispatched_check_policy(None, None).await, None);
1452 }
1453
1454 #[tokio::test]
1470 async fn strict_blueprint_without_roots_is_rejected_pre_dispatch() {
1471 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1472 Ok(WorkerResult {
1473 value: json!({ "echoed": inv.prompt }),
1474 ok: true,
1475 })
1476 });
1477 let svc = build_service(factory);
1478 let mut blueprint = bp(
1479 step("echo", path("$.input"), path("$.out")),
1480 vec![agent("echo", "echo")],
1481 );
1482 blueprint.check_policy = Some(CheckPolicy::Strict);
1483 let err = svc
1485 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1486 .await
1487 .expect_err("strict check_policy + no roots must be rejected before dispatch");
1488 match err {
1489 TaskLaunchError::PreDispatch(message) => {
1490 assert!(
1491 message.contains("strict"),
1492 "message must identify the strict-requires-roots condition: {message}"
1493 );
1494 }
1495 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1496 }
1497
1498 let dispatched = svc
1502 .engine()
1503 .with_state("test.no_echo_task_dispatched", |s| {
1504 s.tasks.values().any(|t| t.spec.agent == "echo")
1505 })
1506 .await
1507 .expect("with_state");
1508 assert!(
1509 !dispatched,
1510 "the pre-dispatch guard must reject before any step is dispatched"
1511 );
1512 }
1513
1514 #[tokio::test]
1526 async fn launch_without_any_check_policy_completes_fail_open() {
1527 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1528 Ok(WorkerResult {
1529 value: json!({ "echoed": inv.prompt }),
1530 ok: true,
1531 })
1532 });
1533 let svc = build_service(factory);
1534 let blueprint = bp(
1535 step("echo", path("$.input"), path("$.out")),
1536 vec![agent("echo", "echo")],
1537 );
1538 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1539 let out = svc
1540 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1541 .await
1542 .expect("warn-mode fail-open must let the launch complete");
1543 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1544 }
1545
1546 #[tokio::test]
1567 async fn strict_blueprint_with_launch_warn_override_bypasses_pre_dispatch_guard() {
1568 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1569 Ok(WorkerResult {
1570 value: json!({ "echoed": inv.prompt }),
1571 ok: true,
1572 })
1573 });
1574 let svc = build_service(factory);
1575 let mut blueprint = bp(
1576 step("echo", path("$.input"), path("$.out")),
1577 vec![agent("echo", "echo")],
1578 );
1579 blueprint.check_policy = Some(CheckPolicy::Strict);
1580 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1581 input.check_policy = Some(CheckPolicy::Warn);
1582 assert!(input.task_input.is_none(), "no roots supplied at all");
1583 let out = svc
1584 .launch(input)
1585 .await
1586 .expect("launch-tier warn override must bypass the pre-dispatch guard");
1587 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1588 }
1589
1590 #[tokio::test]
1596 async fn server_tier_strict_alone_triggers_pre_dispatch_guard() {
1597 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1598 Ok(WorkerResult {
1599 value: json!({ "echoed": inv.prompt }),
1600 ok: true,
1601 })
1602 });
1603 let svc = build_service_with_cfg(
1604 factory,
1605 EngineCfg {
1606 check_policy: CheckPolicy::Strict,
1607 ..EngineCfg::default()
1608 },
1609 );
1610 let blueprint = bp(
1611 step("echo", path("$.input"), path("$.out")),
1612 vec![agent("echo", "echo")],
1613 );
1614 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1615 let input = launch_input(blueprint, json!({ "input": "hi" }));
1616 assert!(input.check_policy.is_none(), "launch tier must be unset");
1617 assert!(input.task_input.is_none(), "no roots supplied");
1618 let err = svc.launch(input).await.expect_err(
1619 "server-tier Strict alone (BP/launch tiers both unset) must trigger the guard",
1620 );
1621 match err {
1622 TaskLaunchError::PreDispatch(message) => {
1623 assert!(
1624 message.contains("strict"),
1625 "expected the strict-requires-roots message, got: {message}"
1626 );
1627 }
1628 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1629 }
1630 }
1631
1632 #[tokio::test]
1638 async fn pre_dispatch_guard_rejects_when_task_input_present_but_roots_both_none() {
1639 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1640 Ok(WorkerResult {
1641 value: json!({ "echoed": inv.prompt }),
1642 ok: true,
1643 })
1644 });
1645 let svc = build_service(factory);
1646 let mut blueprint = bp(
1647 step("echo", path("$.input"), path("$.out")),
1648 vec![agent("echo", "echo")],
1649 );
1650 blueprint.check_policy = Some(CheckPolicy::Strict);
1651 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1652 input.task_input = Some(TaskInputSpec {
1653 project_root: None,
1654 work_dir: None,
1655 task_metadata: Some(json!({ "unrelated": true })),
1656 });
1657 let err = svc
1658 .launch(input)
1659 .await
1660 .expect_err("Some(TaskInputSpec) with both roots None must still be roots_missing");
1661 assert!(
1662 matches!(err, TaskLaunchError::PreDispatch(_)),
1663 "expected TaskLaunchError::PreDispatch, got {err:?}"
1664 );
1665 }
1666
1667 #[tokio::test]
1672 async fn pre_dispatch_guard_passes_when_work_dir_present_and_project_root_absent() {
1673 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1674 Ok(WorkerResult {
1675 value: json!({ "echoed": inv.prompt }),
1676 ok: true,
1677 })
1678 });
1679 let svc = build_service(factory);
1680 let mut blueprint = bp(
1681 step("echo", path("$.input"), path("$.out")),
1682 vec![agent("echo", "echo")],
1683 );
1684 blueprint.check_policy = Some(CheckPolicy::Strict);
1685 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1686 input.task_input = Some(TaskInputSpec {
1687 project_root: None,
1688 work_dir: Some("/repo/work".to_string()),
1689 task_metadata: None,
1690 });
1691 let out = svc
1692 .launch(input)
1693 .await
1694 .expect("work_dir alone must satisfy the guard's roots_missing check");
1695 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1696 }
1697
1698 #[tokio::test]
1699 async fn launch_three_step_seq_threads_ctx_forward() {
1700 let factory = RustFnInProcessSpawnerFactory::new()
1701 .register_fn("upper", |inv| async move {
1702 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1703 Ok(WorkerResult {
1704 value: json!(s.to_uppercase()),
1705 ok: true,
1706 })
1707 })
1708 .register_fn("suffix", |inv| async move {
1709 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1710 Ok(WorkerResult {
1711 value: json!(format!("{s}!")),
1712 ok: true,
1713 })
1714 })
1715 .register_fn("wrap", |inv| async move {
1716 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1717 Ok(WorkerResult {
1718 value: json!(format!("[{s}]")),
1719 ok: true,
1720 })
1721 });
1722 let svc = build_service(factory);
1723 let flow = FlowNode::Seq {
1724 children: vec![
1725 step("upper", path("$.in"), path("$.s1")),
1726 step("suffix", path("$.s1"), path("$.s2")),
1727 step("wrap", path("$.s2"), path("$.s3")),
1728 ],
1729 };
1730 let blueprint = bp(
1731 flow,
1732 vec![
1733 agent("upper", "upper"),
1734 agent("suffix", "suffix"),
1735 agent("wrap", "wrap"),
1736 ],
1737 );
1738 let out = svc
1739 .launch(launch_input(blueprint, json!({ "in": "hello" })))
1740 .await
1741 .expect("launch ok");
1742 assert_eq!(out.final_ctx["s1"], "HELLO");
1743 assert_eq!(out.final_ctx["s2"], "HELLO!");
1744 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
1745 }
1746
1747 #[tokio::test]
1748 async fn launch_fanout_join_all_parallel_completes() {
1749 use std::sync::atomic::{AtomicU32, Ordering};
1750 let counter = Arc::new(AtomicU32::new(0));
1751 let max_seen = Arc::new(AtomicU32::new(0));
1752 let counter_clone = counter.clone();
1753 let max_clone = max_seen.clone();
1754
1755 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
1758 let counter = counter_clone.clone();
1759 let max_seen = max_clone.clone();
1760 async move {
1761 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
1762 let mut prev = max_seen.load(Ordering::SeqCst);
1763 while now > prev {
1764 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
1765 Ok(_) => break,
1766 Err(p) => prev = p,
1767 }
1768 }
1769 tokio::time::sleep(Duration::from_millis(50)).await;
1770 counter.fetch_sub(1, Ordering::SeqCst);
1771 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1772 Ok(WorkerResult {
1773 value: json!(format!("did:{s}")),
1774 ok: true,
1775 })
1776 }
1777 });
1778 let svc = build_service(factory);
1779 let flow = FlowNode::Fanout {
1780 items: path("$.items"),
1781 bind: path("$.item"),
1782 body: Box::new(step("para", path("$.item"), path("$.r"))),
1783 join: JoinMode::All,
1784 out: path("$.results"),
1785 };
1786 let blueprint = bp(flow, vec![agent("para", "para")]);
1787 let out = svc
1788 .launch(launch_input(
1789 blueprint,
1790 json!({ "items": ["a", "b", "c", "d"] }),
1791 ))
1792 .await
1793 .expect("launch ok");
1794 let results = out.final_ctx["results"].as_array().expect("array");
1795 assert_eq!(results.len(), 4);
1796 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
1797 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
1798 }
1799 let max = max_seen.load(Ordering::SeqCst);
1800 assert!(
1801 max >= 2,
1802 "expected parallel execution (max inflight >= 2), got {max}"
1803 );
1804 }
1805
1806 #[tokio::test]
1807 async fn launch_propagates_worker_error_as_flow_eval_err() {
1808 let factory = RustFnInProcessSpawnerFactory::new()
1809 .register_fn("ok", |inv| async move {
1810 Ok(WorkerResult {
1811 value: json!(inv.prompt),
1812 ok: true,
1813 })
1814 })
1815 .register_fn("boom", |_inv| async move {
1816 Err(WorkerError::Failed("intentional boom".into()))
1817 });
1818 let svc = build_service(factory);
1819 let flow = FlowNode::Seq {
1820 children: vec![
1821 step("ok", path("$.input"), path("$.s1")),
1822 step("boom", path("$.s1"), path("$.s2")),
1823 step("ok", path("$.s2"), path("$.s3")),
1824 ],
1825 };
1826 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
1827 let err = svc
1828 .launch(launch_input(blueprint, json!({ "input": "x" })))
1829 .await
1830 .expect_err("expected fail");
1831 match err {
1832 TaskLaunchError::FlowEval { message: msg, .. } => {
1833 assert!(
1834 msg.contains("boom") || msg.contains("intentional"),
1835 "expected error to mention worker failure, got: {msg}"
1836 );
1837 }
1838 other => panic!("expected FlowEval error, got {other:?}"),
1839 }
1840 }
1841
1842 #[tokio::test]
1843 async fn launch_resolves_call_extern_via_registered_externs() {
1844 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1845 Ok(WorkerResult {
1846 value: json!({ "echoed": inv.prompt }),
1847 ok: true,
1848 })
1849 });
1850 let mut externs = mlua_flow_ir::ExternMap::new();
1851 externs.register("fmt.greet", |args: &[Value]| {
1852 let name = args[0].as_str().unwrap_or("?");
1853 Ok(json!(format!("hello, {name}")))
1854 });
1855 let svc = build_service(factory).with_externs(Arc::new(externs));
1856 let flow = step(
1857 "echo",
1858 Expr::CallExtern {
1859 ref_: "fmt.greet".into(),
1860 args: vec![path("$.who")],
1861 },
1862 path("$.out"),
1863 );
1864 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1865 let out = svc
1866 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1867 .await
1868 .expect("launch ok");
1869 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1870 }
1871
1872 #[tokio::test]
1873 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1874 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1875 Ok(WorkerResult {
1876 value: json!(inv.prompt),
1877 ok: true,
1878 })
1879 });
1880 let svc = build_service(factory); let flow = step(
1882 "echo",
1883 Expr::CallExtern {
1884 ref_: "fmt.greet".into(),
1885 args: vec![],
1886 },
1887 path("$.out"),
1888 );
1889 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1890 let err = svc
1891 .launch(launch_input(blueprint, json!({})))
1892 .await
1893 .expect_err("expected fail");
1894 match err {
1895 TaskLaunchError::FlowEval { message: msg, .. } => {
1896 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1897 }
1898 other => panic!("expected FlowEval error, got {other:?}"),
1899 }
1900 }
1901
1902 #[tokio::test]
1922 async fn launch_registers_the_blueprints_verdict_contracts_into_the_engine() {
1923 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |inv| async move {
1924 Ok(WorkerResult {
1925 value: json!(inv.prompt),
1926 ok: true,
1927 })
1928 });
1929 let svc = build_service(factory);
1930 let mut gate_agent = agent("gate", "gate");
1931 gate_agent.verdict = Some(mlua_swarm_schema::VerdictContract {
1932 channel: mlua_swarm_schema::VerdictChannel::Body,
1933 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
1934 });
1935 let flow = step("gate", path("$.input"), path("$.out"));
1936 let blueprint = bp(flow, vec![gate_agent]);
1937
1938 let out = svc
1939 .launch(launch_input(blueprint, json!({ "input": "PASS" })))
1940 .await
1941 .expect("launch ok");
1942 assert_eq!(out.final_ctx["out"], json!("PASS"));
1943
1944 let task_id = svc
1949 .engine()
1950 .with_state("test.find_dispatched_task_id", |s| {
1951 s.tasks.keys().next().cloned()
1952 })
1953 .await
1954 .expect("with_state")
1955 .expect("launch must have dispatched exactly one Step (one TaskState)");
1956
1957 let contract = svc
1958 .engine()
1959 .verdict_contract_for_task(&task_id)
1960 .await
1961 .expect(
1962 "TaskLaunchService::launch must have merged this Blueprint's compiled \
1963 verdict_contracts into the engine's runtime registry \
1964 (Engine::register_verdict_contracts, called right after \
1965 compiler.compile succeeds) — verdict_contract_for_task resolving None \
1966 here means that production wiring regressed",
1967 );
1968 assert_eq!(contract.channel, mlua_swarm_schema::VerdictChannel::Body);
1969 assert_eq!(
1970 contract.values,
1971 vec!["PASS".to_string(), "BLOCKED".to_string()]
1972 );
1973 }
1974
1975 #[tokio::test]
1980 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
1981 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
1982 use crate::types::{RunId, TaskId};
1983
1984 let factory = RustFnInProcessSpawnerFactory::new()
1985 .register_fn("upper", |inv| async move {
1986 Ok(WorkerResult {
1987 value: json!(inv.prompt.to_uppercase()),
1988 ok: true,
1989 })
1990 })
1991 .register_fn("suffix", |inv| async move {
1992 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1993 Ok(WorkerResult {
1994 value: json!(format!("{s}!")),
1995 ok: true,
1996 })
1997 });
1998 let svc = build_service(factory);
1999 let flow = FlowNode::Seq {
2000 children: vec![
2001 step("upper", path("$.in"), path("$.s1")),
2002 step("suffix", path("$.s1"), path("$.s2")),
2003 ],
2004 };
2005 let blueprint = bp(
2006 flow,
2007 vec![agent("upper", "upper"), agent("suffix", "suffix")],
2008 );
2009
2010 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2011 let run_id = RunId::new();
2012 run_store
2013 .create(RunRecord {
2014 id: run_id.clone(),
2015 task_id: TaskId::new(),
2016 status: RunStatus::Running,
2017 step_entries: Vec::new(),
2018 degradations: Vec::new(),
2019 operator_sid: None,
2020 result_ref: None,
2021 input_json: Some("{}".to_string()),
2022 created_at: 0,
2023 updated_at: 0,
2024 })
2025 .await
2026 .expect("seed RunRecord");
2027
2028 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
2029 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
2030
2031 let out = svc.launch(input).await.expect("launch ok");
2032 assert_eq!(out.final_ctx["s2"], "HI!");
2033
2034 let run = run_store.get(&run_id).await.expect("run present");
2035 assert_eq!(
2036 run.step_entries.len(),
2037 2,
2038 "expected one step_entry per dispatched step, got {:?}",
2039 run.step_entries
2040 );
2041 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
2042 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2043 assert!(run.step_entries[0].binding_digest.is_some());
2044 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
2045 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
2046 assert!(run.step_entries[1].binding_digest.is_some());
2047 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2048 assert_eq!(snapshot["bound_agents"].as_array().unwrap().len(), 2);
2049 }
2050
2051 #[tokio::test]
2052 async fn run_snapshot_reuses_bound_agent_after_blueprint_mutation() {
2053 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2054 use crate::types::{RunId, TaskId};
2055
2056 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2057 let run_id = RunId::new();
2058 run_store
2059 .create(RunRecord {
2060 id: run_id.clone(),
2061 task_id: TaskId::new(),
2062 status: RunStatus::Running,
2063 step_entries: Vec::new(),
2064 degradations: Vec::new(),
2065 operator_sid: None,
2066 result_ref: None,
2067 input_json: Some("{}".to_string()),
2068 created_at: 0,
2069 updated_at: 0,
2070 })
2071 .await
2072 .unwrap();
2073 let run_ctx = RunContext::new(run_id, run_store);
2074 let mut original_agent = agent("worker", "worker");
2075 original_agent.profile = Some(crate::blueprint::AgentProfile {
2076 system_prompt: "original role".to_string(),
2077 ..Default::default()
2078 });
2079 let mut blueprint = bp(
2080 step("worker", path("$.input"), path("$.out")),
2081 vec![original_agent],
2082 );
2083
2084 let (original, _) = load_or_resolve_bound_agents(
2085 &blueprint,
2086 Some(&run_ctx),
2087 None,
2088 LegacyWorkerBindingPolicy::Allow,
2089 )
2090 .await
2091 .unwrap();
2092 blueprint.agents[0].profile.as_mut().unwrap().system_prompt = "mutated role".to_string();
2093 let (restored, _) = load_or_resolve_bound_agents(
2094 &blueprint,
2095 Some(&run_ctx),
2096 None,
2097 LegacyWorkerBindingPolicy::Allow,
2098 )
2099 .await
2100 .unwrap();
2101
2102 assert_eq!(restored[0].binding_digest, original[0].binding_digest);
2103 assert_eq!(
2104 restored[0].agent.profile.as_ref().unwrap().system_prompt,
2105 "original role"
2106 );
2107 }
2108
2109 #[tokio::test]
2110 async fn strict_migration_policy_rejects_fresh_legacy_worker_binding() {
2111 let mut legacy_agent = agent("worker", "worker");
2112 legacy_agent.profile = Some(AgentProfile {
2113 worker_binding: Some("legacy-worker".to_string()),
2114 ..Default::default()
2115 });
2116 let blueprint = bp(
2117 step("worker", path("$.input"), path("$.out")),
2118 vec![legacy_agent],
2119 );
2120
2121 let error =
2122 load_or_resolve_bound_agents(&blueprint, None, None, LegacyWorkerBindingPolicy::Reject)
2123 .await
2124 .expect_err("strict migration policy must reject fallback");
2125 assert!(error
2126 .to_string()
2127 .contains("deprecated profile.worker_binding"));
2128 }
2129
2130 #[tokio::test]
2131 async fn run_snapshot_calls_binding_provider_only_on_first_resolution() {
2132 use crate::binding::{AgentBindingProvider, BindingProviderError};
2133 use crate::blueprint::{BindOutcome, BindReceipt, BindRequest};
2134 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2135 use crate::types::{RunId, TaskId};
2136 use std::sync::atomic::{AtomicUsize, Ordering};
2137
2138 struct CountingProvider(AtomicUsize);
2139
2140 #[async_trait::async_trait]
2141 impl AgentBindingProvider for CountingProvider {
2142 async fn bind(
2143 &self,
2144 requests: &[BindRequest],
2145 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
2146 self.0.fetch_add(1, Ordering::SeqCst);
2147 Ok(requests
2148 .iter()
2149 .map(|request| BindOutcome::Bound {
2150 receipt: BindReceipt {
2151 agent: request.agent.clone(),
2152 request_digest: request.request_digest.clone(),
2153 provider_id: "operator-main-ai".to_string(),
2154 provider_revision: Some("test".to_string()),
2155 resolved_model: request.requested_model.clone(),
2156 effective_tools: request.requested_tools.clone(),
2157 launch_variant: request.launch_variant.clone(),
2158 capability_snapshot_digest: None,
2159 },
2160 })
2161 .collect())
2162 }
2163 }
2164
2165 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2166 let run_id = RunId::new();
2167 run_store
2168 .create(RunRecord {
2169 id: run_id.clone(),
2170 task_id: TaskId::new(),
2171 status: RunStatus::Running,
2172 step_entries: Vec::new(),
2173 degradations: Vec::new(),
2174 operator_sid: None,
2175 result_ref: None,
2176 input_json: Some("{}".to_string()),
2177 created_at: 0,
2178 updated_at: 0,
2179 })
2180 .await
2181 .unwrap();
2182 let run_ctx = RunContext::new(run_id, run_store);
2183 let mut blueprint = bp(
2184 step("worker", path("$.input"), path("$.out")),
2185 vec![agent("worker", "worker")],
2186 );
2187 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2188 variant: "mse-worker".to_string(),
2189 tools: vec!["Read".to_string()],
2190 });
2191 let provider = CountingProvider(AtomicUsize::new(0));
2192
2193 let (first, _) = load_or_resolve_bound_agents(
2194 &blueprint,
2195 Some(&run_ctx),
2196 Some(&provider),
2197 LegacyWorkerBindingPolicy::Allow,
2198 )
2199 .await
2200 .unwrap();
2201 let (restored, _) = load_or_resolve_bound_agents(
2202 &blueprint,
2203 Some(&run_ctx),
2204 Some(&provider),
2205 LegacyWorkerBindingPolicy::Allow,
2206 )
2207 .await
2208 .unwrap();
2209
2210 assert_eq!(provider.0.load(Ordering::SeqCst), 1);
2211 assert!(first[0].attestation.is_some());
2212 assert_eq!(restored, first);
2213 }
2214
2215 #[tokio::test]
2224 async fn fresh_resolve_on_launch_persists_launch_origin_no_degradation() {
2225 use crate::store::run::{
2226 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2227 };
2228 use crate::types::{RunId, TaskId};
2229
2230 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2231 let run_id = RunId::new();
2232 run_store
2233 .create(RunRecord {
2234 id: run_id.clone(),
2235 task_id: TaskId::new(),
2236 status: RunStatus::Running,
2237 step_entries: Vec::new(),
2238 degradations: Vec::new(),
2239 operator_sid: None,
2240 result_ref: None,
2241 input_json: Some("{}".to_string()),
2242 created_at: 0,
2243 updated_at: 0,
2244 })
2245 .await
2246 .unwrap();
2247 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2249 let blueprint = bp(
2250 step("worker", path("$.input"), path("$.out")),
2251 vec![agent("worker", "worker")],
2252 );
2253
2254 load_or_resolve_bound_agents(
2255 &blueprint,
2256 Some(&run_ctx),
2257 None,
2258 LegacyWorkerBindingPolicy::Allow,
2259 )
2260 .await
2261 .expect("launch resolve ok");
2262
2263 let run = run_store.get(&run_id).await.expect("run present");
2264 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2265 assert!(
2266 snapshot["bound_agents"].is_array(),
2267 "bound_agents must be persisted"
2268 );
2269 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("launch"));
2270 assert_eq!(
2271 SnapshotOrigin::from_snapshot(&snapshot),
2272 SnapshotOrigin::Launch
2273 );
2274 assert!(
2275 run.degradations.is_empty(),
2276 "an initial-launch resolve is not a degradation"
2277 );
2278 }
2279
2280 #[tokio::test]
2285 async fn backfill_on_resume_persists_resume_origin_and_records_degradation() {
2286 use crate::store::run::{
2287 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2288 };
2289 use crate::types::{RunId, TaskId};
2290
2291 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2292 let run_id = RunId::new();
2293 run_store
2294 .create(RunRecord {
2295 id: run_id.clone(),
2296 task_id: TaskId::new(),
2297 status: RunStatus::Running,
2298 step_entries: Vec::new(),
2299 degradations: Vec::new(),
2300 operator_sid: None,
2301 result_ref: None,
2302 input_json: Some("{}".to_string()),
2303 created_at: 0,
2304 updated_at: 0,
2305 })
2306 .await
2307 .unwrap();
2308 let run_ctx = RunContext::new(run_id.clone(), run_store.clone()).with_resume();
2309 let blueprint = bp(
2310 step("worker", path("$.input"), path("$.out")),
2311 vec![agent("worker", "worker")],
2312 );
2313
2314 load_or_resolve_bound_agents(
2315 &blueprint,
2316 Some(&run_ctx),
2317 None,
2318 LegacyWorkerBindingPolicy::Allow,
2319 )
2320 .await
2321 .expect("resume backfill ok");
2322
2323 let run = run_store.get(&run_id).await.expect("run present");
2324 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2325 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("resume_backfill"));
2326 assert_eq!(
2327 SnapshotOrigin::from_snapshot(&snapshot),
2328 SnapshotOrigin::ResumeBackfill
2329 );
2330 assert_eq!(
2331 run.degradations.len(),
2332 1,
2333 "a resume backfill must record exactly one degradation"
2334 );
2335 assert_eq!(run.degradations[0].tool, "binding");
2336 assert_eq!(run.degradations[0].fallback, "resume_backfill");
2337 }
2338
2339 fn runner_blueprint(strict_binding: bool) -> Blueprint {
2346 let mut blueprint = bp(
2347 step("worker", path("$.input"), path("$.out")),
2348 vec![agent("worker", "worker")],
2349 );
2350 blueprint.strategy.strict_binding = strict_binding;
2351 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2352 variant: "mse-worker".to_string(),
2353 tools: vec!["Read".to_string()],
2354 });
2355 blueprint
2356 }
2357
2358 struct AlwaysUnboundProvider;
2361
2362 #[async_trait::async_trait]
2363 impl AgentBindingProvider for AlwaysUnboundProvider {
2364 async fn bind(
2365 &self,
2366 requests: &[crate::blueprint::BindRequest],
2367 ) -> Result<Vec<crate::blueprint::BindOutcome>, crate::binding::BindingProviderError>
2368 {
2369 Ok(requests
2370 .iter()
2371 .map(|request| crate::blueprint::BindOutcome::Unbound {
2372 agent: request.agent.clone(),
2373 reason: "no capability manifest submitted".to_string(),
2374 })
2375 .collect())
2376 }
2377 }
2378
2379 #[tokio::test]
2383 async fn non_strict_unbound_agent_runs_declaration_only_with_degradation() {
2384 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2385 use crate::types::{RunId, TaskId};
2386
2387 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2388 let run_id = RunId::new();
2389 run_store
2390 .create(RunRecord {
2391 id: run_id.clone(),
2392 task_id: TaskId::new(),
2393 status: RunStatus::Running,
2394 step_entries: Vec::new(),
2395 degradations: Vec::new(),
2396 operator_sid: None,
2397 result_ref: None,
2398 input_json: Some("{}".to_string()),
2399 created_at: 0,
2400 updated_at: 0,
2401 })
2402 .await
2403 .unwrap();
2404 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2405
2406 let (bound, _) = load_or_resolve_bound_agents(
2407 &runner_blueprint(false),
2408 Some(&run_ctx),
2409 Some(&AlwaysUnboundProvider),
2410 LegacyWorkerBindingPolicy::Allow,
2411 )
2412 .await
2413 .expect("non-strict launch must succeed even without an attestation");
2414 assert!(
2415 bound[0].attestation.is_none(),
2416 "an unattested agent must stay DeclarationOnly"
2417 );
2418
2419 let run = run_store.get(&run_id).await.expect("run present");
2420 assert_eq!(run.degradations.len(), 1, "expected one degradation entry");
2421 assert_eq!(run.degradations[0].tool, "binding");
2422 assert_eq!(run.degradations[0].fallback, "DeclarationOnly");
2423 assert!(run.degradations[0].error.contains("no capability manifest"));
2424 }
2425
2426 #[tokio::test]
2430 async fn strict_unbound_agent_fails_with_requirements_in_message() {
2431 let error = load_or_resolve_bound_agents(
2432 &runner_blueprint(true),
2433 None,
2434 Some(&AlwaysUnboundProvider),
2435 LegacyWorkerBindingPolicy::Allow,
2436 )
2437 .await
2438 .expect_err("strict + Unbound must reject the launch");
2439 match error {
2440 TaskLaunchError::PreDispatch(message) => {
2441 assert!(message.contains("worker"), "message: {message}");
2442 assert!(message.contains("mse-worker"), "message: {message}");
2443 assert!(message.contains("Read"), "message: {message}");
2444 }
2445 other => panic!("expected PreDispatch, got {other:?}"),
2446 }
2447 }
2448
2449 #[tokio::test]
2452 async fn strict_without_provider_rejects_runner_backed_launch() {
2453 let error = load_or_resolve_bound_agents(
2454 &runner_blueprint(true),
2455 None,
2456 None,
2457 LegacyWorkerBindingPolicy::Allow,
2458 )
2459 .await
2460 .expect_err("strict + no provider must reject a Runner-backed launch");
2461 match error {
2462 TaskLaunchError::PreDispatch(message) => {
2463 assert!(
2464 message.contains("strict_binding requires a binding provider"),
2465 "message: {message}"
2466 );
2467 }
2468 other => panic!("expected PreDispatch, got {other:?}"),
2469 }
2470 }
2471
2472 #[tokio::test]
2475 async fn strict_with_correct_manifest_attests_the_agent() {
2476 use crate::binding::ManifestBindingProvider;
2477 use crate::blueprint::{AgentProviderCapability, AgentProviderManifest};
2478
2479 let provider = ManifestBindingProvider::new(AgentProviderManifest {
2480 provider_id: "operator-main-ai".to_string(),
2481 provider_revision: Some("1".to_string()),
2482 capabilities: vec![AgentProviderCapability {
2483 launch_variant: Some("mse-worker".to_string()),
2484 resolved_model: None,
2485 effective_tools: vec!["Read".to_string()],
2486 capability_snapshot_digest: None,
2487 }],
2488 });
2489 let (bound, _) = load_or_resolve_bound_agents(
2490 &runner_blueprint(true),
2491 None,
2492 Some(&provider),
2493 LegacyWorkerBindingPolicy::Allow,
2494 )
2495 .await
2496 .expect("strict launch with a correct manifest must attest");
2497 assert!(
2498 bound[0].attestation.is_some(),
2499 "a correctly attested agent must carry its attestation"
2500 );
2501 }
2502
2503 #[test]
2512 fn worker_bindings_carry_request_digest_and_model() {
2513 let mut blueprint = runner_blueprint(false);
2514 blueprint.agents[0].profile = Some(AgentProfile {
2515 model: Some("claude-sonnet".to_string()),
2516 ..Default::default()
2517 });
2518 let bound = resolve_bound_agents(&blueprint).expect("resolvable Runner refs");
2519 let bindings = worker_bindings_from_bound_agents(&bound);
2520
2521 let wb = bindings.get("worker").expect("worker binding present");
2522 assert_eq!(
2523 wb.request_digest.as_ref(),
2524 Some(&bound[0].binding_digest),
2525 "the spawn frame must carry the immutable snapshot digest"
2526 );
2527 assert!(wb
2528 .request_digest
2529 .as_ref()
2530 .unwrap()
2531 .as_str()
2532 .starts_with("sha256:"));
2533 assert_eq!(wb.requested_model.as_deref(), Some("claude-sonnet"));
2534 }
2535
2536 #[test]
2539 fn worker_bindings_omit_model_when_profile_has_none() {
2540 let bound = resolve_bound_agents(&runner_blueprint(false)).expect("resolvable Runner refs");
2541 let bindings = worker_bindings_from_bound_agents(&bound);
2542 let wb = bindings.get("worker").expect("worker binding present");
2543 assert!(wb.request_digest.is_some());
2544 assert!(wb.requested_model.is_none());
2545 }
2546
2547 #[tokio::test]
2548 async fn launch_without_run_ctx_appends_no_step_entries() {
2549 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2553 Ok(WorkerResult {
2554 value: json!(inv.prompt),
2555 ok: true,
2556 })
2557 });
2558 let svc = build_service(factory);
2559 let blueprint = bp(
2560 step("echo", path("$.input"), path("$.out")),
2561 vec![agent("echo", "echo")],
2562 );
2563 let input = launch_input(blueprint, json!({ "input": "hi" }));
2564 assert!(
2565 input.run_ctx.is_none(),
2566 "automate() defaults run_ctx to None"
2567 );
2568 let out = svc.launch(input).await.expect("launch ok");
2569 assert_eq!(out.final_ctx["out"], "hi");
2570 }
2571
2572 #[tokio::test]
2578 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
2579 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2586 Ok(WorkerResult {
2587 value: json!({ "echoed": inv.prompt }),
2588 ok: true,
2589 })
2590 });
2591 let svc = build_service(factory);
2592 let blueprint = bp(
2593 step("echo", path("$.input"), path("$.out")),
2594 vec![agent("echo", "echo")],
2595 );
2596 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2597 input.task_input = Some(TaskInputSpec {
2598 project_root: Some("/repo".to_string()),
2599 work_dir: Some("/repo/work".to_string()),
2600 task_metadata: Some(json!({ "issue": 19 })),
2601 });
2602 let out = svc.launch(input).await.expect("launch ok");
2603 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
2604 assert!(
2605 out.final_ctx.get("project_root").is_none(),
2606 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
2607 out.final_ctx
2608 );
2609 assert!(out.final_ctx.get("work_dir").is_none());
2610 assert!(out.final_ctx.get("task_metadata").is_none());
2611 }
2612
2613 #[tokio::test]
2614 async fn launch_with_task_input_none_is_a_no_op() {
2615 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2616 Ok(WorkerResult {
2617 value: json!(inv.prompt),
2618 ok: true,
2619 })
2620 });
2621 let svc = build_service(factory);
2622 let blueprint = bp(
2623 step("echo", path("$.input"), path("$.out")),
2624 vec![agent("echo", "echo")],
2625 );
2626 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2627 assert!(input.task_input.is_none(), "automate() defaults to None");
2628 input.task_input = None;
2629 let out = svc.launch(input).await.expect("launch ok");
2630 assert_eq!(out.final_ctx["out"], "hi");
2631 }
2632
2633 #[tokio::test]
2634 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
2635 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2639 Ok(WorkerResult {
2640 value: json!(inv.prompt),
2641 ok: true,
2642 })
2643 });
2644 let svc = build_service(factory);
2645 let blueprint = bp(
2646 step("echo", path("$.input"), path("$.out")),
2647 vec![agent("echo", "echo")],
2648 );
2649 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2650 input.task_input = Some(TaskInputSpec::default());
2651 let out = svc.launch(input).await.expect("launch ok");
2652 assert_eq!(out.final_ctx["out"], "hi");
2653 }
2654
2655 #[test]
2660 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
2661 let bp_default = json!({ "seeded": "from-bp" });
2662 let task = json!({});
2663 let merged = merge_init_ctx(Some(&bp_default), &task);
2664 assert_eq!(merged, json!({ "seeded": "from-bp" }));
2665 }
2666
2667 #[test]
2668 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
2669 let bp_default = json!({});
2670 let task = json!({ "seeded": "from-task" });
2671 let merged = merge_init_ctx(Some(&bp_default), &task);
2672 assert_eq!(merged, json!({ "seeded": "from-task" }));
2673 }
2674
2675 #[test]
2676 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
2677 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2678 let task = json!({ "a": "task", "c": "task-only" });
2679 let merged = merge_init_ctx(Some(&bp_default), &task);
2680 assert_eq!(
2681 merged,
2682 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2683 );
2684 }
2685
2686 #[test]
2687 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
2688 let bp_default = json!({ "seeded": "from-bp" });
2689 let task = json!("plain-string-seed");
2690 let merged = merge_init_ctx(Some(&bp_default), &task);
2691 assert_eq!(merged, json!("plain-string-seed"));
2692 }
2693
2694 #[test]
2695 fn merge_init_ctx_no_bp_default_is_a_no_op() {
2696 let task = json!({ "input": "hi" });
2697 let merged = merge_init_ctx(None, &task);
2698 assert_eq!(merged, task);
2699 }
2700
2701 #[test]
2706 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
2707 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2711 let task = json!({ "a": "task", "c": "task-only" });
2712 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
2713 let two_layer = merge_init_ctx(Some(&bp_default), &task);
2714 assert_eq!(three_layer, two_layer);
2715 assert_eq!(
2716 three_layer,
2717 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2718 );
2719 }
2720
2721 #[test]
2722 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
2723 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2724 let task = json!({ "a": "task", "c": "task-only" });
2725 let run_override = json!({ "a": "run", "d": "run-only" });
2726 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2727 assert_eq!(
2728 merged,
2729 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
2730 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
2731 );
2732 }
2733
2734 #[test]
2735 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
2736 let bp_default = json!({ "seeded": "from-bp" });
2737 let task = json!({ "seeded": "from-task" });
2738 let run_override = json!("plain-string-run-seed");
2739 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2740 assert_eq!(merged, json!("plain-string-run-seed"));
2741 }
2742
2743 #[test]
2744 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
2745 let task = json!({ "input": "hi" });
2746 let merged = merge_init_ctx_3layer(None, &task, None);
2747 assert_eq!(merged, task);
2748 }
2749
2750 #[tokio::test]
2751 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
2752 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2755 Ok(WorkerResult {
2756 value: json!(inv.prompt),
2757 ok: true,
2758 })
2759 });
2760 let svc = build_service(factory);
2761 let mut blueprint = bp(
2762 step("echo", path("$.greeting"), path("$.out")),
2763 vec![agent("echo", "echo")],
2764 );
2765 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
2766 let out = svc
2768 .launch(launch_input(blueprint, json!({})))
2769 .await
2770 .expect("launch ok");
2771 assert_eq!(out.final_ctx["out"], "hello from bp");
2772 }
2773
2774 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
2779 AgentDef {
2780 name: name.to_string(),
2781 kind: AgentKind::RustFn,
2782 spec: json!({ "fn_id": fn_id }),
2783 profile: None,
2784 meta: Some(meta),
2785 runner: None,
2786 runner_ref: None,
2787 verdict: None,
2788 }
2789 }
2790
2791 #[test]
2792 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
2793 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2794 let (global, per_agent) = derive_agent_ctx(&blueprint);
2795 assert_eq!(global, None);
2796 assert!(per_agent.is_empty());
2797 }
2798
2799 #[test]
2800 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
2801 let mut blueprint = bp(
2802 step("echo", path("$.in"), path("$.out")),
2803 vec![
2804 agent_with_meta(
2805 "with-ctx",
2806 "echo",
2807 AgentMeta {
2808 ctx: Some(json!({ "org_conventions": "x" })),
2809 ..Default::default()
2810 },
2811 ),
2812 agent("no-ctx", "echo"),
2813 ],
2814 );
2815 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
2816 let (global, per_agent) = derive_agent_ctx(&blueprint);
2817 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
2818 assert_eq!(
2819 per_agent.len(),
2820 1,
2821 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
2822 );
2823 assert_eq!(
2824 per_agent.get("with-ctx"),
2825 Some(&json!({ "org_conventions": "x" }))
2826 );
2827 assert!(!per_agent.contains_key("no-ctx"));
2828 }
2829
2830 #[test]
2831 fn derive_context_policies_empty_blueprint_yields_empty_state() {
2832 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2833 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2834 assert_eq!(default_policy, None);
2835 assert!(per_agent.is_empty());
2836 }
2837
2838 #[test]
2839 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
2840 let mut blueprint = bp(
2841 step("echo", path("$.in"), path("$.out")),
2842 vec![
2843 agent_with_meta(
2844 "with-policy",
2845 "echo",
2846 AgentMeta {
2847 context_policy: Some(ContextPolicy {
2848 include: None,
2849 exclude: vec!["work_dir".to_string()],
2850 ..Default::default()
2851 }),
2852 ..Default::default()
2853 },
2854 ),
2855 agent("no-policy", "echo"),
2856 ],
2857 );
2858 blueprint.default_context_policy = Some(ContextPolicy {
2859 include: Some(vec!["project_root".to_string()]),
2860 exclude: vec![],
2861 ..Default::default()
2862 });
2863 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2864 assert_eq!(
2865 default_policy,
2866 Some(ContextPolicy {
2867 include: Some(vec!["project_root".to_string()]),
2868 exclude: vec![],
2869 ..Default::default()
2870 })
2871 );
2872 assert_eq!(per_agent.len(), 1);
2873 assert_eq!(
2874 per_agent.get("with-policy"),
2875 Some(&ContextPolicy {
2876 include: None,
2877 exclude: vec!["work_dir".to_string()],
2878 ..Default::default()
2879 })
2880 );
2881 assert!(!per_agent.contains_key("no-policy"));
2882 }
2883
2884 #[test]
2890 fn derive_step_metas_empty_blueprint_yields_empty_map() {
2891 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2892 assert!(derive_step_metas(&blueprint).is_empty());
2893 }
2894
2895 #[test]
2896 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
2897 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2898 blueprint.metas = vec![
2899 MetaDef {
2900 name: "heavy-scan".to_string(),
2901 ctx: json!({ "work_dir": "/x" }),
2902 },
2903 MetaDef {
2904 name: "light-scan".to_string(),
2905 ctx: json!({ "work_dir": "/y" }),
2906 },
2907 ];
2908 let metas = derive_step_metas(&blueprint);
2909 assert_eq!(metas.len(), 2);
2910 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
2911 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
2912 }
2913
2914 #[test]
2915 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
2916 let mut blueprint = bp(
2917 step("echo", path("$.in"), path("$.out")),
2918 vec![agent_with_meta(
2919 "with-meta-ref",
2920 "echo",
2921 AgentMeta {
2922 ctx: Some(json!({ "work_dir": "/inline-wins" })),
2923 meta_ref: Some("shared".to_string()),
2924 ..Default::default()
2925 },
2926 )],
2927 );
2928 blueprint.metas = vec![MetaDef {
2929 name: "shared".to_string(),
2930 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
2931 }];
2932 let (_, per_agent) = derive_agent_ctx(&blueprint);
2933 assert_eq!(
2934 per_agent.get("with-meta-ref"),
2935 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
2936 "inline ctx must win the collided key while pool-only keys survive the merge"
2937 );
2938 }
2939
2940 #[test]
2941 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
2942 let mut blueprint = bp(
2943 step("echo", path("$.in"), path("$.out")),
2944 vec![agent_with_meta(
2945 "with-meta-ref-only",
2946 "echo",
2947 AgentMeta {
2948 meta_ref: Some("shared".to_string()),
2949 ..Default::default()
2950 },
2951 )],
2952 );
2953 blueprint.metas = vec![MetaDef {
2954 name: "shared".to_string(),
2955 ctx: json!({ "work_dir": "/base" }),
2956 }];
2957 let (_, per_agent) = derive_agent_ctx(&blueprint);
2958 assert_eq!(
2959 per_agent.get("with-meta-ref-only"),
2960 Some(&json!({ "work_dir": "/base" }))
2961 );
2962 }
2963
2964 #[test]
2965 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
2966 let blueprint = bp(
2967 step("echo", path("$.in"), path("$.out")),
2968 vec![agent_with_meta(
2969 "with-unresolved-meta-ref",
2970 "echo",
2971 AgentMeta {
2972 ctx: Some(json!({ "work_dir": "/inline-only" })),
2973 meta_ref: Some("missing".to_string()),
2974 ..Default::default()
2975 },
2976 )],
2977 );
2978 let (_, per_agent) = derive_agent_ctx(&blueprint);
2980 assert_eq!(
2981 per_agent.get("with-unresolved-meta-ref"),
2982 Some(&json!({ "work_dir": "/inline-only" })),
2983 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
2984 );
2985 }
2986
2987 #[test]
3003 fn resolve_runner_legacy_fallback_matches_derive_worker_bindings_semantics() {
3004 fn legacy_agent(name: &str, variant: &str, tools: Vec<&str>) -> AgentDef {
3005 AgentDef {
3006 name: name.to_string(),
3007 kind: AgentKind::Operator,
3008 spec: json!({}),
3009 profile: Some(AgentProfile {
3010 worker_binding: Some(variant.to_string()),
3011 tools: tools.into_iter().map(str::to_string).collect(),
3012 ..Default::default()
3013 }),
3014 meta: None,
3015 runner: None,
3016 runner_ref: None,
3017 verdict: None,
3018 }
3019 }
3020
3021 let blueprint = bp(
3022 step("planner", path("$.in"), path("$.out")),
3023 vec![
3024 legacy_agent("planner", "mse-worker-planner", vec!["Read", "Grep"]),
3025 legacy_agent("coder", "mse-worker-coder", vec![]),
3026 agent("no-binding", "echo"),
3027 ],
3028 );
3029
3030 let derived = derive_worker_bindings(&blueprint);
3031
3032 for agent_def in &blueprint.agents {
3033 let resolved = resolve_runner(&blueprint, agent_def).expect("no unresolved refs");
3034 match derived.get(&agent_def.name) {
3035 Some(binding) => {
3036 assert_eq!(
3037 resolved,
3038 Some(Runner::WsClaudeCode {
3039 variant: binding.variant.clone(),
3040 tools: binding.tools.clone(),
3041 }),
3042 "resolve_runner must synthesize the same WsClaudeCode Runner \
3043 derive_worker_bindings produces for agent '{}'",
3044 agent_def.name
3045 );
3046 }
3047 None => {
3048 assert_eq!(
3049 resolved, None,
3050 "agent '{}' has no derive_worker_bindings entry, so resolve_runner \
3051 must resolve to None too (no other tier declared)",
3052 agent_def.name
3053 );
3054 }
3055 }
3056 }
3057 }
3058
3059 #[test]
3060 fn ws_operator_runner_projects_into_the_existing_spawn_binding() {
3061 let mut blueprint = bp(
3062 step("reviewer", path("$.in"), path("$.out")),
3063 vec![agent("reviewer", "echo")],
3064 );
3065 blueprint.agents[0].runner = Some(Runner::WsOperator {
3066 variant: "mse-reviewer".to_string(),
3067 tools: vec!["Read".to_string(), "Grep".to_string()],
3068 });
3069
3070 let derived = derive_worker_bindings(&blueprint);
3071 let binding = derived
3072 .get("reviewer")
3073 .expect("ws_operator must feed the canonical spawn binding path");
3074 assert_eq!(binding.variant, "mse-reviewer");
3075 assert_eq!(binding.tools, ["Read", "Grep"]);
3076 }
3077
3078 fn counting_echo_service() -> (TaskLaunchService, Arc<std::sync::atomic::AtomicUsize>) {
3087 use std::sync::atomic::{AtomicUsize, Ordering};
3088 let calls = Arc::new(AtomicUsize::new(0));
3089 let counter = calls.clone();
3090 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
3091 let counter = counter.clone();
3092 async move {
3093 counter.fetch_add(1, Ordering::SeqCst);
3094 Ok(WorkerResult {
3095 value: json!({ "echoed": inv.prompt }),
3096 ok: true,
3097 })
3098 }
3099 });
3100 (build_service(factory), calls)
3101 }
3102
3103 async fn seed_legacy_run(run_store: &Arc<dyn crate::store::run::RunStore>) -> crate::RunId {
3104 use crate::store::run::{RunRecord, RunStatus};
3105 use crate::types::TaskId;
3106 let run_id = crate::RunId::new();
3107 run_store
3108 .create(RunRecord {
3109 id: run_id.clone(),
3110 task_id: TaskId::new(),
3111 status: RunStatus::Running,
3112 step_entries: Vec::new(),
3113 degradations: Vec::new(),
3114 operator_sid: None,
3115 result_ref: None,
3116 input_json: Some("{}".to_string()),
3119 created_at: 0,
3120 updated_at: 0,
3121 })
3122 .await
3123 .expect("seed legacy RunRecord");
3124 run_id
3125 }
3126
3127 #[tokio::test]
3133 async fn backfilled_run_replays_legacy_keys_stably_across_two_resumes() {
3134 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3135 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3136 use std::sync::atomic::Ordering;
3137 use std::sync::Mutex;
3138
3139 let (svc, echo_calls) = counting_echo_service();
3140 let blueprint = bp(
3141 step("echo", path("$.input"), path("$.out")),
3142 vec![agent("echo", "echo")],
3143 );
3144 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3145 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3146 let run_id = seed_legacy_run(&run_store).await;
3147
3148 let rc1 = RunContext::new(run_id.clone(), run_store.clone())
3152 .with_replay_store(replay_store.clone())
3153 .with_resume();
3154 let mut input1 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3155 input1.run_ctx = Some(rc1);
3156 let out1 = svc.launch(input1).await.expect("phase-1 resume launch ok");
3157 assert_eq!(out1.final_ctx["out"]["echoed"], "hi");
3158 assert_eq!(
3159 echo_calls.load(Ordering::SeqCst),
3160 1,
3161 "phase 1 dispatches the worker once (nothing to replay yet)"
3162 );
3163 let entries = replay_store
3164 .list_by_run(&run_id)
3165 .await
3166 .expect("list replay rows");
3167 assert_eq!(
3168 entries.len(),
3169 1,
3170 "phase 1 must log exactly one legacy-hashed replay row"
3171 );
3172 let run = run_store.get(&run_id).await.expect("run present");
3173 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3174 assert_eq!(
3175 SnapshotOrigin::from_snapshot(&snap),
3176 SnapshotOrigin::ResumeBackfill,
3177 "phase 1 must pin the snapshot as resume_backfill"
3178 );
3179
3180 let cursor = ReplayCursor::from_entries(entries);
3185 let rc2 = RunContext::new(run_id.clone(), run_store.clone())
3186 .with_replay_store(replay_store.clone())
3187 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
3188 .with_resume();
3189 let mut input2 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3190 input2.run_ctx = Some(rc2);
3191 let out2 = svc.launch(input2).await.expect("phase-2 resume launch ok");
3192 assert_eq!(out2.final_ctx["out"]["echoed"], "hi");
3193 assert_eq!(
3194 echo_calls.load(Ordering::SeqCst),
3195 1,
3196 "phase 2 must REPLAY the legacy-hashed row — the worker must not run again"
3197 );
3198 let run2 = run_store.get(&run_id).await.expect("run present");
3199 let snap2: Value = serde_json::from_str(run2.input_json.as_deref().unwrap()).unwrap();
3200 assert_eq!(
3201 SnapshotOrigin::from_snapshot(&snap2),
3202 SnapshotOrigin::ResumeBackfill,
3203 "origin must stay resume_backfill across resumes (replay key stability)"
3204 );
3205 }
3206
3207 #[tokio::test]
3212 async fn launch_origin_run_uses_digest_keys_and_misses_legacy_replay_row() {
3213 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3214 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3215 use std::sync::atomic::Ordering;
3216 use std::sync::Mutex;
3217
3218 let (svc, echo_calls) = counting_echo_service();
3219 let blueprint = bp(
3220 step("echo", path("$.input"), path("$.out")),
3221 vec![agent("echo", "echo")],
3222 );
3223 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3224 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3225
3226 let backfill_run = seed_legacy_run(&run_store).await;
3228 let rc_bf = RunContext::new(backfill_run.clone(), run_store.clone())
3229 .with_replay_store(replay_store.clone())
3230 .with_resume();
3231 let mut input_bf = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3232 input_bf.run_ctx = Some(rc_bf);
3233 svc.launch(input_bf).await.expect("backfill launch ok");
3234 assert_eq!(echo_calls.load(Ordering::SeqCst), 1);
3235 let legacy_entries = replay_store
3236 .list_by_run(&backfill_run)
3237 .await
3238 .expect("list legacy rows");
3239 assert_eq!(legacy_entries.len(), 1);
3240
3241 let launch_run = seed_legacy_run(&run_store).await;
3245 let cursor = ReplayCursor::from_entries(legacy_entries);
3246 let rc_launch = RunContext::new(launch_run.clone(), run_store.clone())
3247 .with_replay_store(replay_store.clone())
3248 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
3249 let mut input_launch = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3250 input_launch.run_ctx = Some(rc_launch);
3251 svc.launch(input_launch)
3252 .await
3253 .expect("launch-origin launch ok");
3254 assert_eq!(
3255 echo_calls.load(Ordering::SeqCst),
3256 2,
3257 "a launch-origin Run keys replay by binding digest, so the \
3258 legacy-hashed row must MISS and the worker must run"
3259 );
3260 let run = run_store.get(&launch_run).await.expect("run present");
3261 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3262 assert_eq!(SnapshotOrigin::from_snapshot(&snap), SnapshotOrigin::Launch);
3263 }
3264
3265 #[test]
3273 fn task_launch_error_flow_eval_struct_variant_display_preserves_prefix() {
3274 let err = TaskLaunchError::FlowEval {
3275 message: "dispatcher error at ref foo".to_string(),
3276 failed_step: Some("foo".to_string()),
3277 verdict_value: Some(json!({"verdict": "BLOCKED"})),
3278 partial_ctx: Some(json!({"steps": {}})),
3279 };
3280 assert_eq!(err.to_string(), "flow eval: dispatcher error at ref foo");
3281
3282 let err_bare = TaskLaunchError::FlowEval {
3286 message: "unresolved extern".to_string(),
3287 failed_step: None,
3288 verdict_value: None,
3289 partial_ctx: None,
3290 };
3291 assert_eq!(err_bare.to_string(), "flow eval: unresolved extern");
3292 }
3293
3294 #[tokio::test]
3299 async fn task_launch_flow_eval_error_carries_failed_step_and_verdict_value() {
3300 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3301 use crate::types::{RunId, TaskId};
3302
3303 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3304 Ok(WorkerResult {
3305 value: json!({ "verdict": "BLOCKED", "reason": "not applicable" }),
3306 ok: false,
3307 })
3308 });
3309 let svc = build_service(factory);
3310 let blueprint = bp(
3311 step("gate", path("$.input"), path("$.out")),
3312 vec![agent("gate", "gate")],
3313 );
3314
3315 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3316 let run_id = RunId::new();
3317 run_store
3318 .create(RunRecord {
3319 id: run_id.clone(),
3320 task_id: TaskId::new(),
3321 status: RunStatus::Running,
3322 step_entries: Vec::new(),
3323 degradations: Vec::new(),
3324 operator_sid: None,
3325 result_ref: None,
3326 input_json: Some("{}".to_string()),
3327 created_at: 0,
3328 updated_at: 0,
3329 })
3330 .await
3331 .expect("seed RunRecord");
3332
3333 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
3334 input.run_ctx = Some(RunContext::new(run_id, run_store));
3335
3336 let err = svc.launch(input).await.expect_err("expected FlowEval");
3337 match err {
3338 TaskLaunchError::FlowEval {
3339 message,
3340 failed_step,
3341 verdict_value,
3342 partial_ctx,
3343 } => {
3344 assert!(
3345 message.contains("blocked"),
3346 "expected message to mention blocked, got: {message}"
3347 );
3348 assert_eq!(
3349 failed_step,
3350 Some("gate".to_string()),
3351 "failed_step should be the Blueprint step ref, not the opaque StepId"
3352 );
3353 let vv = verdict_value.expect("verdict_value must be Some for Blocked");
3354 assert_eq!(vv["verdict"], "BLOCKED");
3355 assert_eq!(vv["reason"], "not applicable");
3356 assert!(
3360 partial_ctx.is_some(),
3361 "partial_ctx must be Some when a RunContext was supplied"
3362 );
3363 }
3364 other => panic!("expected FlowEval, got {other:?}"),
3365 }
3366 }
3367
3368 #[tokio::test]
3374 async fn task_launch_flow_eval_error_partial_ctx_reconstructs_from_run_store() {
3375 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3376 use crate::types::{RunId, TaskId};
3377
3378 let factory = RustFnInProcessSpawnerFactory::new()
3379 .register_fn("upper", |inv| async move {
3380 Ok(WorkerResult {
3381 value: json!(inv.prompt.to_uppercase()),
3382 ok: true,
3383 })
3384 })
3385 .register_fn("gate", |_inv| async move {
3386 Ok(WorkerResult {
3387 value: json!({ "verdict": "BLOCKED" }),
3388 ok: false,
3389 })
3390 })
3391 .register_fn("never", |inv| async move {
3392 Ok(WorkerResult {
3393 value: json!(inv.prompt),
3394 ok: true,
3395 })
3396 });
3397 let svc = build_service(factory);
3398 let flow = FlowNode::Seq {
3399 children: vec![
3400 step("upper", path("$.in"), path("$.s1")),
3401 step("gate", path("$.s1"), path("$.s2")),
3402 step("never", path("$.s2"), path("$.s3")),
3403 ],
3404 };
3405 let blueprint = bp(
3406 flow,
3407 vec![
3408 agent("upper", "upper"),
3409 agent("gate", "gate"),
3410 agent("never", "never"),
3411 ],
3412 );
3413
3414 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3415 let run_id = RunId::new();
3416 run_store
3417 .create(RunRecord {
3418 id: run_id.clone(),
3419 task_id: TaskId::new(),
3420 status: RunStatus::Running,
3421 step_entries: Vec::new(),
3422 degradations: Vec::new(),
3423 operator_sid: None,
3424 result_ref: None,
3425 input_json: Some("{}".to_string()),
3426 created_at: 0,
3427 updated_at: 0,
3428 })
3429 .await
3430 .expect("seed RunRecord");
3431
3432 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
3433 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
3434
3435 let err = svc.launch(input).await.expect_err("expected FlowEval");
3436 let partial_ctx = match err {
3437 TaskLaunchError::FlowEval { partial_ctx, .. } => {
3438 partial_ctx.expect("partial_ctx must be Some")
3439 }
3440 other => panic!("expected FlowEval, got {other:?}"),
3441 };
3442 let steps = partial_ctx
3443 .get("steps")
3444 .and_then(|v| v.as_object())
3445 .expect("partial_ctx.steps object");
3446 assert_eq!(
3450 steps.len(),
3451 2,
3452 "expected 2 step_entries (upper passed + gate blocked), got: {steps:?}"
3453 );
3454 let mut status_by_ref: HashMap<String, String> = HashMap::new();
3455 for (_step_id, entry) in steps {
3456 let step_ref = entry
3457 .get("step_ref")
3458 .and_then(|v| v.as_str())
3459 .expect("step_ref present")
3460 .to_string();
3461 let status = entry
3462 .get("status")
3463 .and_then(|v| v.as_str())
3464 .expect("status present")
3465 .to_string();
3466 status_by_ref.insert(step_ref, status);
3467 }
3468 assert_eq!(
3469 status_by_ref.get("upper").map(String::as_str),
3470 Some("passed")
3471 );
3472 assert_eq!(
3473 status_by_ref.get("gate").map(String::as_str),
3474 Some("blocked")
3475 );
3476 assert!(
3477 !status_by_ref.contains_key("never"),
3478 "step 'never' must not appear — flow-ir stops dispatching after Blocked abort"
3479 );
3480 }
3481
3482 #[tokio::test]
3488 async fn task_launch_flow_eval_error_without_run_ctx_has_none_fields() {
3489 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3490 Ok(WorkerResult {
3491 value: json!({ "verdict": "BLOCKED" }),
3492 ok: false,
3493 })
3494 });
3495 let svc = build_service(factory);
3496 let blueprint = bp(
3497 step("gate", path("$.input"), path("$.out")),
3498 vec![agent("gate", "gate")],
3499 );
3500 let err = svc
3501 .launch(launch_input(blueprint, json!({ "input": "hi" })))
3502 .await
3503 .expect_err("expected FlowEval");
3504 match err {
3505 TaskLaunchError::FlowEval {
3506 failed_step,
3507 verdict_value,
3508 partial_ctx,
3509 ..
3510 } => {
3511 assert_eq!(
3512 failed_step, None,
3513 "failed_step must be None without run_ctx"
3514 );
3515 assert_eq!(
3516 verdict_value, None,
3517 "verdict_value must be None without run_ctx"
3518 );
3519 assert_eq!(
3520 partial_ctx, None,
3521 "partial_ctx must be None without run_ctx"
3522 );
3523 }
3524 other => panic!("expected FlowEval, got {other:?}"),
3525 }
3526 }
3527}