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