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_pin: Option<String>,
690 pub operator_kind_overrides: HashMap<String, OperatorKind>,
695 pub init_ctx: Value,
700 pub task_input: Option<TaskInputSpec>,
707 pub run_ctx: Option<RunContext>,
714 pub check_policy: Option<CheckPolicy>,
737}
738
739impl TaskLaunchInput {
740 pub fn automate(
750 blueprint: Blueprint,
751 operator_id: impl Into<String>,
752 role: Role,
753 ttl: Duration,
754 init_ctx: Value,
755 ) -> Self {
756 Self {
757 blueprint,
758 operator_id: operator_id.into(),
759 role,
760 ttl,
761 operator_kind: None,
762 bridge_id: None,
763 hook_id: None,
764 operator_backend_id: None,
765 operator_pin: None,
766 operator_kind_overrides: HashMap::new(),
767 init_ctx,
768 task_input: None,
769 run_ctx: None,
770 check_policy: None,
771 }
772 }
773}
774
775#[derive(Debug, Clone)]
777pub struct TaskLaunchOutput {
778 pub token: CapToken,
780 pub final_ctx: Value,
784}
785
786pub struct TaskLaunchService {
790 engine: Engine,
791 compiler: Compiler,
792 externs: Arc<dyn Externs + Send + Sync>,
797 binding_provider: Option<Arc<dyn AgentBindingProvider>>,
800 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
803}
804
805impl TaskLaunchService {
806 pub fn new(engine: Engine, compiler: Compiler) -> Self {
808 Self {
809 engine,
810 compiler,
811 externs: Arc::new(NoExterns),
812 binding_provider: None,
813 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::default(),
814 }
815 }
816
817 pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
821 self.externs = externs;
822 self
823 }
824
825 pub fn with_binding_provider(mut self, provider: Arc<dyn AgentBindingProvider>) -> Self {
829 self.binding_provider = Some(provider);
830 self
831 }
832
833 pub fn with_legacy_worker_binding_policy(mut self, policy: LegacyWorkerBindingPolicy) -> Self {
835 self.legacy_worker_binding_policy = policy;
836 self
837 }
838
839 pub fn engine(&self) -> &Engine {
841 &self.engine
842 }
843
844 pub fn compiler(&self) -> &Compiler {
846 &self.compiler
847 }
848
849 pub async fn launch(
861 &self,
862 mut input: TaskLaunchInput,
863 ) -> Result<TaskLaunchOutput, TaskLaunchError> {
864 let pinned_binding_provider: Option<Arc<dyn AgentBindingProvider>> = input
878 .operator_pin
879 .as_deref()
880 .and_then(|pin| self.binding_provider.as_ref()?.pinned_to_session(pin));
881 let binding_provider = pinned_binding_provider
882 .as_deref()
883 .or(self.binding_provider.as_deref());
884 let (bound_agents, snapshot_origin) = load_or_resolve_bound_agents(
885 &input.blueprint,
886 input.run_ctx.as_ref(),
887 binding_provider,
888 self.legacy_worker_binding_policy,
889 )
890 .await?;
891 let binding_digests: HashMap<String, crate::blueprint::BindingDigest> = bound_agents
892 .iter()
893 .map(|bound| (bound.agent.name.clone(), bound.binding_digest.clone()))
894 .collect();
895 if let Some(run_ctx) = input.run_ctx.take() {
896 input.run_ctx = Some(match snapshot_origin {
907 SnapshotOrigin::Launch => run_ctx.with_binding_digests(binding_digests.clone()),
908 SnapshotOrigin::ResumeBackfill => run_ctx,
909 });
910 }
911 input.blueprint = materialize_bound_blueprint(&input.blueprint, &bound_agents);
912 let compiled = self.compiler.compile_bound_pinned(
913 &input.blueprint,
914 &bound_agents,
915 input.operator_pin.as_deref(),
916 )?;
917 self.engine
926 .register_verdict_contracts(compiled.router.verdict_contracts.clone());
927 let spawner = linker::link(
928 compiled.router.clone(),
929 &input.blueprint.spawner_hints.layers,
930 &self.engine,
931 );
932 let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
948 let (context_policy_default, context_policy_per_agent) =
949 derive_context_policies(&input.blueprint);
950 let spawner = SpawnerStack::new(spawner)
951 .layer(AgentContextMiddleware::new(
952 agent_ctx_global,
953 agent_ctx_per_agent,
954 context_policy_default,
955 context_policy_per_agent,
956 ))
957 .build();
958 let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
965 SpawnerStack::new(spawner)
966 .layer(ProjectNameAliasMiddleware::new(alias))
967 .build()
968 } else {
969 spawner
970 };
971 let worker_bindings = worker_bindings_from_bound_agents(&bound_agents);
975 let spawner = if worker_bindings.is_empty() {
976 spawner
977 } else {
978 SpawnerStack::new(spawner)
979 .layer(WorkerBindingMiddleware::new(worker_bindings))
980 .build()
981 };
982 let audit_defs = derive_audits(&input.blueprint);
992 let spawner = if audit_defs.is_empty() {
993 spawner
994 } else {
995 SpawnerStack::new(spawner)
996 .layer(AfterRunAuditMiddleware::new(
997 audit_defs,
998 compiled.router.clone(),
999 ))
1000 .build()
1001 };
1002
1003 let spawner = match input.task_input.as_ref().and_then(|spec| {
1010 TaskInputMiddleware::new_from_fields(
1011 spec.project_root.clone(),
1012 spec.work_dir.clone(),
1013 spec.task_metadata.clone(),
1014 )
1015 }) {
1016 Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
1017 None => spawner,
1018 };
1019
1020 let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
1025 let bp_global_kind = input
1026 .blueprint
1027 .default_operator_kind
1028 .map(OperatorKind::from);
1029
1030 let token = self
1031 .engine
1032 .attach_with_ids(
1033 input.operator_id,
1034 input.role,
1035 input.ttl,
1036 input.operator_kind,
1037 input.bridge_id,
1038 input.hook_id,
1039 input.operator_backend_id,
1040 input.operator_kind_overrides,
1041 bp_agent_kinds,
1042 bp_global_kind,
1043 )
1044 .await?;
1045 let resolved_check_policy = input.check_policy.or(input.blueprint.check_policy);
1056 let effective_check_policy =
1071 resolved_check_policy.unwrap_or(self.engine.cfg().check_policy);
1072 if effective_check_policy == CheckPolicy::Strict {
1073 let roots_missing = input
1074 .task_input
1075 .as_ref()
1076 .map(|t| t.project_root.is_none() && t.work_dir.is_none())
1077 .unwrap_or(true);
1078 if roots_missing {
1079 return Err(TaskLaunchError::PreDispatch(
1080 "check_policy=strict requires project_root or work_dir, but the launch \
1081 supplied neither"
1082 .to_string(),
1083 ));
1084 }
1085 }
1086 let dispatcher =
1087 EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
1088 let dispatcher = dispatcher.with_check_policy(resolved_check_policy);
1089 let map_err_run_ctx = input.run_ctx.clone();
1097 let dispatcher = match input.run_ctx {
1098 Some(run_ctx) => dispatcher.with_run(run_ctx),
1099 None => dispatcher,
1100 };
1101 let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
1105 let dispatcher = dispatcher.with_binding_digests(binding_digests);
1106 let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
1112 let dispatcher =
1119 dispatcher.with_projection_placement(compiled.projection_placement.clone());
1120 let merged_init_ctx =
1126 merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
1127 let eval_result = mlua_flow_ir::eval_async_externs(
1128 &input.blueprint.flow,
1129 merged_init_ctx,
1130 &dispatcher,
1131 &*self.externs,
1132 )
1133 .await;
1134 let final_ctx = match eval_result {
1135 Ok(v) => v,
1136 Err(e) => {
1137 let (failed_step, verdict_value) = match &map_err_run_ctx {
1157 Some(rc) => {
1158 let slot = rc.last_failure.lock().ok().and_then(|g| g.clone());
1159 match slot {
1160 Some(lf) => (
1161 lf.step_ref.clone().or_else(|| Some(lf.step_id.to_string())),
1162 Some(lf.verdict_value.clone()),
1163 ),
1164 None => (None, None),
1165 }
1166 }
1167 None => (None, None),
1168 };
1169 let partial_ctx = match &map_err_run_ctx {
1170 Some(rc) => Some(rc.snapshot_partial_ctx().await),
1171 None => None,
1172 };
1173 return Err(TaskLaunchError::FlowEval {
1174 message: e.to_string(),
1175 failed_step,
1176 verdict_value,
1177 partial_ctx,
1178 });
1179 }
1180 };
1181 Ok(TaskLaunchOutput { token, final_ctx })
1182 }
1183}
1184
1185#[cfg(test)]
1190mod tests {
1191 use super::*;
1192 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1193 use crate::blueprint::{
1194 current_schema_version, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
1195 BlueprintMetadata, CompilerHints, CompilerStrategy, MetaDef, Runner,
1196 };
1197 use crate::core::config::EngineCfg;
1198 use crate::worker::adapter::{WorkerError, WorkerResult};
1199 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
1200 use serde_json::json;
1201 use std::sync::Arc;
1202
1203 fn path(s: &str) -> Expr {
1204 Expr::Path {
1205 at: s.parse().expect("literal test path"),
1206 }
1207 }
1208 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
1209 FlowNode::Step {
1210 ref_: ref_.to_string(),
1211 in_,
1212 out,
1213 }
1214 }
1215
1216 fn agent(name: &str, fn_id: &str) -> AgentDef {
1217 AgentDef {
1218 name: name.to_string(),
1219 kind: AgentKind::RustFn,
1220 spec: json!({ "fn_id": fn_id }),
1221 profile: None,
1222 meta: Some(AgentMeta::default()),
1223 runner: None,
1224 runner_ref: None,
1225 verdict: None,
1226 lints: None,
1227 }
1228 }
1229
1230 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
1231 let engine = Engine::new(EngineCfg::default());
1232 let mut reg = SpawnerRegistry::new();
1233 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1234 let compiler = Compiler::new(reg);
1235 TaskLaunchService::new(engine, compiler)
1236 }
1237
1238 fn build_service_with_cfg(
1242 factory: RustFnInProcessSpawnerFactory,
1243 cfg: EngineCfg,
1244 ) -> TaskLaunchService {
1245 let engine = Engine::new(cfg);
1246 let mut reg = SpawnerRegistry::new();
1247 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1248 let compiler = Compiler::new(reg);
1249 TaskLaunchService::new(engine, compiler)
1250 }
1251
1252 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
1253 Blueprint {
1254 schema_version: current_schema_version(),
1255 id: "ut".into(),
1256 flow,
1257 agents,
1258 operators: vec![],
1259 metas: vec![],
1260 hints: CompilerHints::default(),
1261 strategy: CompilerStrategy::default(),
1262 metadata: BlueprintMetadata::default(),
1263 spawner_hints: Default::default(),
1264 default_agent_kind: AgentKind::Operator,
1265 default_operator_kind: None,
1266 default_init_ctx: None,
1267 default_agent_ctx: None,
1268 default_context_policy: None,
1269 projection_placement: None,
1270 audits: vec![],
1271 degradation_policy: None,
1272 runners: vec![],
1273 default_runner: None,
1274 subprocesses: vec![],
1275 check_policy: None,
1276 blueprint_ref_includes: Vec::new(),
1277 }
1278 }
1279
1280 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
1281 TaskLaunchInput::automate(
1282 blueprint,
1283 "ut-op",
1284 Role::Operator,
1285 Duration::from_secs(30),
1286 init_ctx,
1287 )
1288 }
1289
1290 #[test]
1296 fn derive_audits_empty_by_default() {
1297 let blueprint = bp(
1298 step("echo", path("$.input"), path("$.out")),
1299 vec![agent("echo", "echo")],
1300 );
1301 assert!(
1302 derive_audits(&blueprint).is_empty(),
1303 "audits_absent_no_layer: an undeclared audits Vec must stay empty"
1304 );
1305 }
1306
1307 #[test]
1308 fn derive_audits_returns_blueprint_audits_verbatim() {
1309 let mut blueprint = bp(
1310 step("echo", path("$.input"), path("$.out")),
1311 vec![agent("echo", "echo")],
1312 );
1313 blueprint.audits = vec![crate::blueprint::AuditDef {
1314 agent: "auditor".to_string(),
1315 steps: None,
1316 mode: crate::blueprint::AuditMode::Async,
1317 }];
1318 let got = derive_audits(&blueprint);
1319 assert_eq!(got.len(), 1);
1320 assert_eq!(got[0].agent, "auditor");
1321 }
1322
1323 #[tokio::test]
1324 async fn launch_appends_audit_artifact_when_audits_declared() {
1325 use crate::blueprint::{AuditDef, AuditMode};
1326
1327 let factory = RustFnInProcessSpawnerFactory::new()
1328 .register_fn("echo", |inv| async move {
1329 Ok(WorkerResult {
1330 value: json!({ "echoed": inv.prompt }),
1331 ok: true,
1332 stats: None,
1333 })
1334 })
1335 .register_fn("audit-fn", |_inv| async move {
1336 Ok(WorkerResult {
1337 value: json!({ "finding": "clean" }),
1338 ok: true,
1339 stats: None,
1340 })
1341 });
1342 let svc = build_service(factory);
1343 let mut blueprint = bp(
1344 step("echo", path("$.input"), path("$.out")),
1345 vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
1346 );
1347 blueprint.audits = vec![AuditDef {
1348 agent: "auditor".to_string(),
1349 steps: None,
1350 mode: AuditMode::Sync,
1351 }];
1352 let out = svc
1353 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1354 .await
1355 .expect("launch ok — audits must never alter the audited step's outcome");
1356 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1357
1358 let audited_task_id = svc
1359 .engine()
1360 .with_state("test.find_audited_task", |s| {
1361 s.tasks
1362 .iter()
1363 .find(|(_, t)| t.spec.agent == "echo")
1364 .map(|(id, _)| id.clone())
1365 })
1366 .await
1367 .expect("with_state")
1368 .expect("the echo task must exist");
1369 let tail = svc.engine().output_tail(&audited_task_id, 1).await;
1370 let found = tail.iter().any(|ev| {
1371 matches!(
1372 ev,
1373 crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
1374 )
1375 });
1376 assert!(
1377 found,
1378 "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
1379 );
1380 }
1381
1382 #[tokio::test]
1383 async fn launch_single_step_writes_out_path() {
1384 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1385 Ok(WorkerResult {
1386 value: json!({ "echoed": inv.prompt }),
1387 ok: true,
1388 stats: None,
1389 })
1390 });
1391 let svc = build_service(factory);
1392 let blueprint = bp(
1393 step("echo", path("$.input"), path("$.out")),
1394 vec![agent("echo", "echo")],
1395 );
1396 let out = svc
1397 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1398 .await
1399 .expect("launch ok");
1400 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1401 }
1402
1403 async fn dispatched_check_policy(
1424 launch_policy: Option<CheckPolicy>,
1425 bp_policy: Option<CheckPolicy>,
1426 ) -> Option<CheckPolicy> {
1427 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1428 Ok(WorkerResult {
1429 value: json!({ "echoed": inv.prompt }),
1430 ok: true,
1431 stats: None,
1432 })
1433 });
1434 let svc = build_service(factory);
1435 let mut blueprint = bp(
1436 step("echo", path("$.input"), path("$.out")),
1437 vec![agent("echo", "echo")],
1438 );
1439 blueprint.check_policy = bp_policy;
1440 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1441 input.check_policy = launch_policy;
1442 input.task_input = Some(TaskInputSpec {
1443 project_root: None,
1444 work_dir: Some("/dispatched-check-policy-test-root".to_string()),
1445 task_metadata: None,
1446 });
1447 let _ = svc.launch(input).await;
1448 svc.engine()
1449 .with_state("test.read_dispatched_check_policy", |s| {
1450 s.tasks
1451 .values()
1452 .find(|t| t.spec.agent == "echo")
1453 .and_then(|t| t.spec.check_policy)
1454 })
1455 .await
1456 .expect("with_state")
1457 }
1458
1459 #[tokio::test]
1462 async fn cascade_launch_tier_wins_over_blueprint_tier() {
1463 assert_eq!(
1464 dispatched_check_policy(Some(CheckPolicy::Silent), Some(CheckPolicy::Strict)).await,
1465 Some(CheckPolicy::Silent),
1466 );
1467 }
1468
1469 #[tokio::test]
1472 async fn cascade_blueprint_tier_used_when_launch_absent() {
1473 assert_eq!(
1474 dispatched_check_policy(None, Some(CheckPolicy::Strict)).await,
1475 Some(CheckPolicy::Strict),
1476 );
1477 }
1478
1479 #[tokio::test]
1482 async fn cascade_launch_tier_alone_when_blueprint_absent() {
1483 assert_eq!(
1484 dispatched_check_policy(Some(CheckPolicy::Strict), None).await,
1485 Some(CheckPolicy::Strict),
1486 );
1487 }
1488
1489 #[tokio::test]
1494 async fn cascade_both_none_preserves_server_fallback() {
1495 assert_eq!(dispatched_check_policy(None, None).await, None);
1496 }
1497
1498 #[tokio::test]
1514 async fn strict_blueprint_without_roots_is_rejected_pre_dispatch() {
1515 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1516 Ok(WorkerResult {
1517 value: json!({ "echoed": inv.prompt }),
1518 ok: true,
1519 stats: None,
1520 })
1521 });
1522 let svc = build_service(factory);
1523 let mut blueprint = bp(
1524 step("echo", path("$.input"), path("$.out")),
1525 vec![agent("echo", "echo")],
1526 );
1527 blueprint.check_policy = Some(CheckPolicy::Strict);
1528 let err = svc
1530 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1531 .await
1532 .expect_err("strict check_policy + no roots must be rejected before dispatch");
1533 match err {
1534 TaskLaunchError::PreDispatch(message) => {
1535 assert!(
1536 message.contains("strict"),
1537 "message must identify the strict-requires-roots condition: {message}"
1538 );
1539 }
1540 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1541 }
1542
1543 let dispatched = svc
1547 .engine()
1548 .with_state("test.no_echo_task_dispatched", |s| {
1549 s.tasks.values().any(|t| t.spec.agent == "echo")
1550 })
1551 .await
1552 .expect("with_state");
1553 assert!(
1554 !dispatched,
1555 "the pre-dispatch guard must reject before any step is dispatched"
1556 );
1557 }
1558
1559 #[tokio::test]
1571 async fn launch_without_any_check_policy_completes_fail_open() {
1572 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1573 Ok(WorkerResult {
1574 value: json!({ "echoed": inv.prompt }),
1575 ok: true,
1576 stats: None,
1577 })
1578 });
1579 let svc = build_service(factory);
1580 let blueprint = bp(
1581 step("echo", path("$.input"), path("$.out")),
1582 vec![agent("echo", "echo")],
1583 );
1584 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1585 let out = svc
1586 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1587 .await
1588 .expect("warn-mode fail-open must let the launch complete");
1589 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1590 }
1591
1592 #[tokio::test]
1613 async fn strict_blueprint_with_launch_warn_override_bypasses_pre_dispatch_guard() {
1614 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1615 Ok(WorkerResult {
1616 value: json!({ "echoed": inv.prompt }),
1617 ok: true,
1618 stats: None,
1619 })
1620 });
1621 let svc = build_service(factory);
1622 let mut blueprint = bp(
1623 step("echo", path("$.input"), path("$.out")),
1624 vec![agent("echo", "echo")],
1625 );
1626 blueprint.check_policy = Some(CheckPolicy::Strict);
1627 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1628 input.check_policy = Some(CheckPolicy::Warn);
1629 assert!(input.task_input.is_none(), "no roots supplied at all");
1630 let out = svc
1631 .launch(input)
1632 .await
1633 .expect("launch-tier warn override must bypass the pre-dispatch guard");
1634 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1635 }
1636
1637 #[tokio::test]
1643 async fn server_tier_strict_alone_triggers_pre_dispatch_guard() {
1644 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1645 Ok(WorkerResult {
1646 value: json!({ "echoed": inv.prompt }),
1647 ok: true,
1648 stats: None,
1649 })
1650 });
1651 let svc = build_service_with_cfg(
1652 factory,
1653 EngineCfg {
1654 check_policy: CheckPolicy::Strict,
1655 ..EngineCfg::default()
1656 },
1657 );
1658 let blueprint = bp(
1659 step("echo", path("$.input"), path("$.out")),
1660 vec![agent("echo", "echo")],
1661 );
1662 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1663 let input = launch_input(blueprint, json!({ "input": "hi" }));
1664 assert!(input.check_policy.is_none(), "launch tier must be unset");
1665 assert!(input.task_input.is_none(), "no roots supplied");
1666 let err = svc.launch(input).await.expect_err(
1667 "server-tier Strict alone (BP/launch tiers both unset) must trigger the guard",
1668 );
1669 match err {
1670 TaskLaunchError::PreDispatch(message) => {
1671 assert!(
1672 message.contains("strict"),
1673 "expected the strict-requires-roots message, got: {message}"
1674 );
1675 }
1676 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1677 }
1678 }
1679
1680 #[tokio::test]
1686 async fn pre_dispatch_guard_rejects_when_task_input_present_but_roots_both_none() {
1687 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1688 Ok(WorkerResult {
1689 value: json!({ "echoed": inv.prompt }),
1690 ok: true,
1691 stats: None,
1692 })
1693 });
1694 let svc = build_service(factory);
1695 let mut blueprint = bp(
1696 step("echo", path("$.input"), path("$.out")),
1697 vec![agent("echo", "echo")],
1698 );
1699 blueprint.check_policy = Some(CheckPolicy::Strict);
1700 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1701 input.task_input = Some(TaskInputSpec {
1702 project_root: None,
1703 work_dir: None,
1704 task_metadata: Some(json!({ "unrelated": true })),
1705 });
1706 let err = svc
1707 .launch(input)
1708 .await
1709 .expect_err("Some(TaskInputSpec) with both roots None must still be roots_missing");
1710 assert!(
1711 matches!(err, TaskLaunchError::PreDispatch(_)),
1712 "expected TaskLaunchError::PreDispatch, got {err:?}"
1713 );
1714 }
1715
1716 #[tokio::test]
1721 async fn pre_dispatch_guard_passes_when_work_dir_present_and_project_root_absent() {
1722 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1723 Ok(WorkerResult {
1724 value: json!({ "echoed": inv.prompt }),
1725 ok: true,
1726 stats: None,
1727 })
1728 });
1729 let svc = build_service(factory);
1730 let mut blueprint = bp(
1731 step("echo", path("$.input"), path("$.out")),
1732 vec![agent("echo", "echo")],
1733 );
1734 blueprint.check_policy = Some(CheckPolicy::Strict);
1735 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1736 input.task_input = Some(TaskInputSpec {
1737 project_root: None,
1738 work_dir: Some("/repo/work".to_string()),
1739 task_metadata: None,
1740 });
1741 let out = svc
1742 .launch(input)
1743 .await
1744 .expect("work_dir alone must satisfy the guard's roots_missing check");
1745 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1746 }
1747
1748 #[tokio::test]
1749 async fn launch_three_step_seq_threads_ctx_forward() {
1750 let factory = RustFnInProcessSpawnerFactory::new()
1751 .register_fn("upper", |inv| async move {
1752 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1753 Ok(WorkerResult {
1754 value: json!(s.to_uppercase()),
1755 ok: true,
1756 stats: None,
1757 })
1758 })
1759 .register_fn("suffix", |inv| async move {
1760 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1761 Ok(WorkerResult {
1762 value: json!(format!("{s}!")),
1763 ok: true,
1764 stats: None,
1765 })
1766 })
1767 .register_fn("wrap", |inv| async move {
1768 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1769 Ok(WorkerResult {
1770 value: json!(format!("[{s}]")),
1771 ok: true,
1772 stats: None,
1773 })
1774 });
1775 let svc = build_service(factory);
1776 let flow = FlowNode::Seq {
1777 children: vec![
1778 step("upper", path("$.in"), path("$.s1")),
1779 step("suffix", path("$.s1"), path("$.s2")),
1780 step("wrap", path("$.s2"), path("$.s3")),
1781 ],
1782 };
1783 let blueprint = bp(
1784 flow,
1785 vec![
1786 agent("upper", "upper"),
1787 agent("suffix", "suffix"),
1788 agent("wrap", "wrap"),
1789 ],
1790 );
1791 let out = svc
1792 .launch(launch_input(blueprint, json!({ "in": "hello" })))
1793 .await
1794 .expect("launch ok");
1795 assert_eq!(out.final_ctx["s1"], "HELLO");
1796 assert_eq!(out.final_ctx["s2"], "HELLO!");
1797 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
1798 }
1799
1800 #[tokio::test]
1801 async fn launch_fanout_join_all_parallel_completes() {
1802 use std::sync::atomic::{AtomicU32, Ordering};
1803 let counter = Arc::new(AtomicU32::new(0));
1804 let max_seen = Arc::new(AtomicU32::new(0));
1805 let counter_clone = counter.clone();
1806 let max_clone = max_seen.clone();
1807
1808 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
1811 let counter = counter_clone.clone();
1812 let max_seen = max_clone.clone();
1813 async move {
1814 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
1815 let mut prev = max_seen.load(Ordering::SeqCst);
1816 while now > prev {
1817 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
1818 Ok(_) => break,
1819 Err(p) => prev = p,
1820 }
1821 }
1822 tokio::time::sleep(Duration::from_millis(50)).await;
1823 counter.fetch_sub(1, Ordering::SeqCst);
1824 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1825 Ok(WorkerResult {
1826 value: json!(format!("did:{s}")),
1827 ok: true,
1828 stats: None,
1829 })
1830 }
1831 });
1832 let svc = build_service(factory);
1833 let flow = FlowNode::Fanout {
1834 items: path("$.items"),
1835 bind: path("$.item"),
1836 body: Box::new(step("para", path("$.item"), path("$.r"))),
1837 join: JoinMode::All,
1838 out: path("$.results"),
1839 };
1840 let blueprint = bp(flow, vec![agent("para", "para")]);
1841 let out = svc
1842 .launch(launch_input(
1843 blueprint,
1844 json!({ "items": ["a", "b", "c", "d"] }),
1845 ))
1846 .await
1847 .expect("launch ok");
1848 let results = out.final_ctx["results"].as_array().expect("array");
1849 assert_eq!(results.len(), 4);
1850 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
1851 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
1852 }
1853 let max = max_seen.load(Ordering::SeqCst);
1854 assert!(
1855 max >= 2,
1856 "expected parallel execution (max inflight >= 2), got {max}"
1857 );
1858 }
1859
1860 #[tokio::test]
1861 async fn launch_propagates_worker_error_as_flow_eval_err() {
1862 let factory = RustFnInProcessSpawnerFactory::new()
1863 .register_fn("ok", |inv| async move {
1864 Ok(WorkerResult {
1865 value: json!(inv.prompt),
1866 ok: true,
1867 stats: None,
1868 })
1869 })
1870 .register_fn("boom", |_inv| async move {
1871 Err(WorkerError::Failed("intentional boom".into()))
1872 });
1873 let svc = build_service(factory);
1874 let flow = FlowNode::Seq {
1875 children: vec![
1876 step("ok", path("$.input"), path("$.s1")),
1877 step("boom", path("$.s1"), path("$.s2")),
1878 step("ok", path("$.s2"), path("$.s3")),
1879 ],
1880 };
1881 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
1882 let err = svc
1883 .launch(launch_input(blueprint, json!({ "input": "x" })))
1884 .await
1885 .expect_err("expected fail");
1886 match err {
1887 TaskLaunchError::FlowEval { message: msg, .. } => {
1888 assert!(
1889 msg.contains("boom") || msg.contains("intentional"),
1890 "expected error to mention worker failure, got: {msg}"
1891 );
1892 }
1893 other => panic!("expected FlowEval error, got {other:?}"),
1894 }
1895 }
1896
1897 #[tokio::test]
1898 async fn launch_resolves_call_extern_via_registered_externs() {
1899 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1900 Ok(WorkerResult {
1901 value: json!({ "echoed": inv.prompt }),
1902 ok: true,
1903 stats: None,
1904 })
1905 });
1906 let mut externs = mlua_flow_ir::ExternMap::new();
1907 externs.register("fmt.greet", |args: &[Value]| {
1908 let name = args[0].as_str().unwrap_or("?");
1909 Ok(json!(format!("hello, {name}")))
1910 });
1911 let svc = build_service(factory).with_externs(Arc::new(externs));
1912 let flow = step(
1913 "echo",
1914 Expr::CallExtern {
1915 ref_: "fmt.greet".into(),
1916 args: vec![path("$.who")],
1917 },
1918 path("$.out"),
1919 );
1920 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1921 let out = svc
1922 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1923 .await
1924 .expect("launch ok");
1925 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1926 }
1927
1928 #[tokio::test]
1929 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1930 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1931 Ok(WorkerResult {
1932 value: json!(inv.prompt),
1933 ok: true,
1934 stats: None,
1935 })
1936 });
1937 let svc = build_service(factory); let flow = step(
1939 "echo",
1940 Expr::CallExtern {
1941 ref_: "fmt.greet".into(),
1942 args: vec![],
1943 },
1944 path("$.out"),
1945 );
1946 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1947 let err = svc
1948 .launch(launch_input(blueprint, json!({})))
1949 .await
1950 .expect_err("expected fail");
1951 match err {
1952 TaskLaunchError::FlowEval { message: msg, .. } => {
1953 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1954 }
1955 other => panic!("expected FlowEval error, got {other:?}"),
1956 }
1957 }
1958
1959 #[tokio::test]
1979 async fn launch_registers_the_blueprints_verdict_contracts_into_the_engine() {
1980 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |inv| async move {
1981 Ok(WorkerResult {
1982 value: json!(inv.prompt),
1983 ok: true,
1984 stats: None,
1985 })
1986 });
1987 let svc = build_service(factory);
1988 let mut gate_agent = agent("gate", "gate");
1989 gate_agent.verdict = Some(mlua_swarm_schema::VerdictContract {
1990 channel: mlua_swarm_schema::VerdictChannel::Body,
1991 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
1992 });
1993 let flow = step("gate", path("$.input"), path("$.out"));
1994 let blueprint = bp(flow, vec![gate_agent]);
1995
1996 let out = svc
1997 .launch(launch_input(blueprint, json!({ "input": "PASS" })))
1998 .await
1999 .expect("launch ok");
2000 assert_eq!(out.final_ctx["out"], json!("PASS"));
2001
2002 let task_id = svc
2007 .engine()
2008 .with_state("test.find_dispatched_task_id", |s| {
2009 s.tasks.keys().next().cloned()
2010 })
2011 .await
2012 .expect("with_state")
2013 .expect("launch must have dispatched exactly one Step (one TaskState)");
2014
2015 let contract = svc
2016 .engine()
2017 .verdict_contract_for_task(&task_id)
2018 .await
2019 .expect(
2020 "TaskLaunchService::launch must have merged this Blueprint's compiled \
2021 verdict_contracts into the engine's runtime registry \
2022 (Engine::register_verdict_contracts, called right after \
2023 compiler.compile succeeds) — verdict_contract_for_task resolving None \
2024 here means that production wiring regressed",
2025 );
2026 assert_eq!(contract.channel, mlua_swarm_schema::VerdictChannel::Body);
2027 assert_eq!(
2028 contract.values,
2029 vec!["PASS".to_string(), "BLOCKED".to_string()]
2030 );
2031 }
2032
2033 #[tokio::test]
2038 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
2039 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2040 use crate::types::{RunId, TaskId};
2041
2042 let factory = RustFnInProcessSpawnerFactory::new()
2043 .register_fn("upper", |inv| async move {
2044 Ok(WorkerResult {
2045 value: json!(inv.prompt.to_uppercase()),
2046 ok: true,
2047 stats: None,
2048 })
2049 })
2050 .register_fn("suffix", |inv| async move {
2051 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
2052 Ok(WorkerResult {
2053 value: json!(format!("{s}!")),
2054 ok: true,
2055 stats: None,
2056 })
2057 });
2058 let svc = build_service(factory);
2059 let flow = FlowNode::Seq {
2060 children: vec![
2061 step("upper", path("$.in"), path("$.s1")),
2062 step("suffix", path("$.s1"), path("$.s2")),
2063 ],
2064 };
2065 let blueprint = bp(
2066 flow,
2067 vec![agent("upper", "upper"), agent("suffix", "suffix")],
2068 );
2069
2070 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2071 let run_id = RunId::new();
2072 run_store
2073 .create(RunRecord {
2074 id: run_id.clone(),
2075 task_id: TaskId::new(),
2076 status: RunStatus::Running,
2077 step_entries: Vec::new(),
2078 degradations: Vec::new(),
2079 operator_sid: None,
2080 result_ref: None,
2081 input_json: Some("{}".to_string()),
2082 created_at: 0,
2083 updated_at: 0,
2084 })
2085 .await
2086 .expect("seed RunRecord");
2087
2088 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
2089 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
2090
2091 let out = svc.launch(input).await.expect("launch ok");
2092 assert_eq!(out.final_ctx["s2"], "HI!");
2093
2094 let run = run_store.get(&run_id).await.expect("run present");
2095 assert_eq!(
2096 run.step_entries.len(),
2097 2,
2098 "expected one step_entry per dispatched step, got {:?}",
2099 run.step_entries
2100 );
2101 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
2102 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2103 assert!(run.step_entries[0].binding_digest.is_some());
2104 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
2105 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
2106 assert!(run.step_entries[1].binding_digest.is_some());
2107 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2108 assert_eq!(snapshot["bound_agents"].as_array().unwrap().len(), 2);
2109 }
2110
2111 #[tokio::test]
2112 async fn run_snapshot_reuses_bound_agent_after_blueprint_mutation() {
2113 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2114 use crate::types::{RunId, TaskId};
2115
2116 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2117 let run_id = RunId::new();
2118 run_store
2119 .create(RunRecord {
2120 id: run_id.clone(),
2121 task_id: TaskId::new(),
2122 status: RunStatus::Running,
2123 step_entries: Vec::new(),
2124 degradations: Vec::new(),
2125 operator_sid: None,
2126 result_ref: None,
2127 input_json: Some("{}".to_string()),
2128 created_at: 0,
2129 updated_at: 0,
2130 })
2131 .await
2132 .unwrap();
2133 let run_ctx = RunContext::new(run_id, run_store);
2134 let mut original_agent = agent("worker", "worker");
2135 original_agent.profile = Some(crate::blueprint::AgentProfile {
2136 system_prompt: "original role".to_string(),
2137 ..Default::default()
2138 });
2139 let mut blueprint = bp(
2140 step("worker", path("$.input"), path("$.out")),
2141 vec![original_agent],
2142 );
2143
2144 let (original, _) = load_or_resolve_bound_agents(
2145 &blueprint,
2146 Some(&run_ctx),
2147 None,
2148 LegacyWorkerBindingPolicy::Allow,
2149 )
2150 .await
2151 .unwrap();
2152 blueprint.agents[0].profile.as_mut().unwrap().system_prompt = "mutated role".to_string();
2153 let (restored, _) = load_or_resolve_bound_agents(
2154 &blueprint,
2155 Some(&run_ctx),
2156 None,
2157 LegacyWorkerBindingPolicy::Allow,
2158 )
2159 .await
2160 .unwrap();
2161
2162 assert_eq!(restored[0].binding_digest, original[0].binding_digest);
2163 assert_eq!(
2164 restored[0].agent.profile.as_ref().unwrap().system_prompt,
2165 "original role"
2166 );
2167 }
2168
2169 #[tokio::test]
2170 async fn strict_migration_policy_rejects_fresh_legacy_worker_binding() {
2171 let mut legacy_agent = agent("worker", "worker");
2172 legacy_agent.profile = Some(AgentProfile {
2173 worker_binding: Some("legacy-worker".to_string()),
2174 ..Default::default()
2175 });
2176 let blueprint = bp(
2177 step("worker", path("$.input"), path("$.out")),
2178 vec![legacy_agent],
2179 );
2180
2181 let error =
2182 load_or_resolve_bound_agents(&blueprint, None, None, LegacyWorkerBindingPolicy::Reject)
2183 .await
2184 .expect_err("strict migration policy must reject fallback");
2185 assert!(error
2186 .to_string()
2187 .contains("deprecated profile.worker_binding"));
2188 }
2189
2190 #[tokio::test]
2191 async fn run_snapshot_calls_binding_provider_only_on_first_resolution() {
2192 use crate::binding::{AgentBindingProvider, BindingProviderError};
2193 use crate::blueprint::{BindOutcome, BindReceipt, BindRequest};
2194 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2195 use crate::types::{RunId, TaskId};
2196 use std::sync::atomic::{AtomicUsize, Ordering};
2197
2198 struct CountingProvider(AtomicUsize);
2199
2200 #[async_trait::async_trait]
2201 impl AgentBindingProvider for CountingProvider {
2202 async fn bind(
2203 &self,
2204 requests: &[BindRequest],
2205 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
2206 self.0.fetch_add(1, Ordering::SeqCst);
2207 Ok(requests
2208 .iter()
2209 .map(|request| BindOutcome::Bound {
2210 receipt: BindReceipt {
2211 agent: request.agent.clone(),
2212 request_digest: request.request_digest.clone(),
2213 provider_id: "operator-main-ai".to_string(),
2214 provider_revision: Some("test".to_string()),
2215 resolved_model: request.requested_model.clone(),
2216 effective_tools: request.requested_tools.clone(),
2217 launch_variant: request.launch_variant.clone(),
2218 capability_snapshot_digest: None,
2219 },
2220 })
2221 .collect())
2222 }
2223 }
2224
2225 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2226 let run_id = RunId::new();
2227 run_store
2228 .create(RunRecord {
2229 id: run_id.clone(),
2230 task_id: TaskId::new(),
2231 status: RunStatus::Running,
2232 step_entries: Vec::new(),
2233 degradations: Vec::new(),
2234 operator_sid: None,
2235 result_ref: None,
2236 input_json: Some("{}".to_string()),
2237 created_at: 0,
2238 updated_at: 0,
2239 })
2240 .await
2241 .unwrap();
2242 let run_ctx = RunContext::new(run_id, run_store);
2243 let mut blueprint = bp(
2244 step("worker", path("$.input"), path("$.out")),
2245 vec![agent("worker", "worker")],
2246 );
2247 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2248 variant: "mse-worker".to_string(),
2249 tools: vec!["Read".to_string()],
2250 });
2251 let provider = CountingProvider(AtomicUsize::new(0));
2252
2253 let (first, _) = load_or_resolve_bound_agents(
2254 &blueprint,
2255 Some(&run_ctx),
2256 Some(&provider),
2257 LegacyWorkerBindingPolicy::Allow,
2258 )
2259 .await
2260 .unwrap();
2261 let (restored, _) = load_or_resolve_bound_agents(
2262 &blueprint,
2263 Some(&run_ctx),
2264 Some(&provider),
2265 LegacyWorkerBindingPolicy::Allow,
2266 )
2267 .await
2268 .unwrap();
2269
2270 assert_eq!(provider.0.load(Ordering::SeqCst), 1);
2271 assert!(first[0].attestation.is_some());
2272 assert_eq!(restored, first);
2273 }
2274
2275 #[tokio::test]
2284 async fn fresh_resolve_on_launch_persists_launch_origin_no_degradation() {
2285 use crate::store::run::{
2286 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2287 };
2288 use crate::types::{RunId, TaskId};
2289
2290 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2291 let run_id = RunId::new();
2292 run_store
2293 .create(RunRecord {
2294 id: run_id.clone(),
2295 task_id: TaskId::new(),
2296 status: RunStatus::Running,
2297 step_entries: Vec::new(),
2298 degradations: Vec::new(),
2299 operator_sid: None,
2300 result_ref: None,
2301 input_json: Some("{}".to_string()),
2302 created_at: 0,
2303 updated_at: 0,
2304 })
2305 .await
2306 .unwrap();
2307 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
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("launch resolve 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!(
2326 snapshot["bound_agents"].is_array(),
2327 "bound_agents must be persisted"
2328 );
2329 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("launch"));
2330 assert_eq!(
2331 SnapshotOrigin::from_snapshot(&snapshot),
2332 SnapshotOrigin::Launch
2333 );
2334 assert!(
2335 run.degradations.is_empty(),
2336 "an initial-launch resolve is not a degradation"
2337 );
2338 }
2339
2340 #[tokio::test]
2345 async fn backfill_on_resume_persists_resume_origin_and_records_degradation() {
2346 use crate::store::run::{
2347 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2348 };
2349 use crate::types::{RunId, TaskId};
2350
2351 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2352 let run_id = RunId::new();
2353 run_store
2354 .create(RunRecord {
2355 id: run_id.clone(),
2356 task_id: TaskId::new(),
2357 status: RunStatus::Running,
2358 step_entries: Vec::new(),
2359 degradations: Vec::new(),
2360 operator_sid: None,
2361 result_ref: None,
2362 input_json: Some("{}".to_string()),
2363 created_at: 0,
2364 updated_at: 0,
2365 })
2366 .await
2367 .unwrap();
2368 let run_ctx = RunContext::new(run_id.clone(), run_store.clone()).with_resume();
2369 let blueprint = bp(
2370 step("worker", path("$.input"), path("$.out")),
2371 vec![agent("worker", "worker")],
2372 );
2373
2374 load_or_resolve_bound_agents(
2375 &blueprint,
2376 Some(&run_ctx),
2377 None,
2378 LegacyWorkerBindingPolicy::Allow,
2379 )
2380 .await
2381 .expect("resume backfill ok");
2382
2383 let run = run_store.get(&run_id).await.expect("run present");
2384 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2385 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("resume_backfill"));
2386 assert_eq!(
2387 SnapshotOrigin::from_snapshot(&snapshot),
2388 SnapshotOrigin::ResumeBackfill
2389 );
2390 assert_eq!(
2391 run.degradations.len(),
2392 1,
2393 "a resume backfill must record exactly one degradation"
2394 );
2395 assert_eq!(run.degradations[0].tool, "binding");
2396 assert_eq!(run.degradations[0].fallback, "resume_backfill");
2397 }
2398
2399 fn runner_blueprint(strict_binding: bool) -> Blueprint {
2406 let mut blueprint = bp(
2407 step("worker", path("$.input"), path("$.out")),
2408 vec![agent("worker", "worker")],
2409 );
2410 blueprint.strategy.strict_binding = strict_binding;
2411 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2412 variant: "mse-worker".to_string(),
2413 tools: vec!["Read".to_string()],
2414 });
2415 blueprint
2416 }
2417
2418 struct AlwaysUnboundProvider;
2421
2422 #[async_trait::async_trait]
2423 impl AgentBindingProvider for AlwaysUnboundProvider {
2424 async fn bind(
2425 &self,
2426 requests: &[crate::blueprint::BindRequest],
2427 ) -> Result<Vec<crate::blueprint::BindOutcome>, crate::binding::BindingProviderError>
2428 {
2429 Ok(requests
2430 .iter()
2431 .map(|request| crate::blueprint::BindOutcome::Unbound {
2432 agent: request.agent.clone(),
2433 reason: "no capability manifest submitted".to_string(),
2434 })
2435 .collect())
2436 }
2437 }
2438
2439 #[tokio::test]
2443 async fn non_strict_unbound_agent_runs_declaration_only_with_degradation() {
2444 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2445 use crate::types::{RunId, TaskId};
2446
2447 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2448 let run_id = RunId::new();
2449 run_store
2450 .create(RunRecord {
2451 id: run_id.clone(),
2452 task_id: TaskId::new(),
2453 status: RunStatus::Running,
2454 step_entries: Vec::new(),
2455 degradations: Vec::new(),
2456 operator_sid: None,
2457 result_ref: None,
2458 input_json: Some("{}".to_string()),
2459 created_at: 0,
2460 updated_at: 0,
2461 })
2462 .await
2463 .unwrap();
2464 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2465
2466 let (bound, _) = load_or_resolve_bound_agents(
2467 &runner_blueprint(false),
2468 Some(&run_ctx),
2469 Some(&AlwaysUnboundProvider),
2470 LegacyWorkerBindingPolicy::Allow,
2471 )
2472 .await
2473 .expect("non-strict launch must succeed even without an attestation");
2474 assert!(
2475 bound[0].attestation.is_none(),
2476 "an unattested agent must stay DeclarationOnly"
2477 );
2478
2479 let run = run_store.get(&run_id).await.expect("run present");
2480 assert_eq!(run.degradations.len(), 1, "expected one degradation entry");
2481 assert_eq!(run.degradations[0].tool, "binding");
2482 assert_eq!(run.degradations[0].fallback, "DeclarationOnly");
2483 assert!(run.degradations[0].error.contains("no capability manifest"));
2484 }
2485
2486 #[tokio::test]
2490 async fn strict_unbound_agent_fails_with_requirements_in_message() {
2491 let error = load_or_resolve_bound_agents(
2492 &runner_blueprint(true),
2493 None,
2494 Some(&AlwaysUnboundProvider),
2495 LegacyWorkerBindingPolicy::Allow,
2496 )
2497 .await
2498 .expect_err("strict + Unbound must reject the launch");
2499 match error {
2500 TaskLaunchError::PreDispatch(message) => {
2501 assert!(message.contains("worker"), "message: {message}");
2502 assert!(message.contains("mse-worker"), "message: {message}");
2503 assert!(message.contains("Read"), "message: {message}");
2504 }
2505 other => panic!("expected PreDispatch, got {other:?}"),
2506 }
2507 }
2508
2509 #[tokio::test]
2512 async fn strict_without_provider_rejects_runner_backed_launch() {
2513 let error = load_or_resolve_bound_agents(
2514 &runner_blueprint(true),
2515 None,
2516 None,
2517 LegacyWorkerBindingPolicy::Allow,
2518 )
2519 .await
2520 .expect_err("strict + no provider must reject a Runner-backed launch");
2521 match error {
2522 TaskLaunchError::PreDispatch(message) => {
2523 assert!(
2524 message.contains("strict_binding requires a binding provider"),
2525 "message: {message}"
2526 );
2527 }
2528 other => panic!("expected PreDispatch, got {other:?}"),
2529 }
2530 }
2531
2532 #[tokio::test]
2535 async fn strict_with_correct_manifest_attests_the_agent() {
2536 use crate::binding::ManifestBindingProvider;
2537 use crate::blueprint::{AgentProviderCapability, AgentProviderManifest};
2538
2539 let provider = ManifestBindingProvider::new(AgentProviderManifest {
2540 provider_id: "operator-main-ai".to_string(),
2541 provider_revision: Some("1".to_string()),
2542 capabilities: vec![AgentProviderCapability {
2543 launch_variant: Some("mse-worker".to_string()),
2544 resolved_model: None,
2545 effective_tools: vec!["Read".to_string()],
2546 capability_snapshot_digest: None,
2547 }],
2548 });
2549 let (bound, _) = load_or_resolve_bound_agents(
2550 &runner_blueprint(true),
2551 None,
2552 Some(&provider),
2553 LegacyWorkerBindingPolicy::Allow,
2554 )
2555 .await
2556 .expect("strict launch with a correct manifest must attest");
2557 assert!(
2558 bound[0].attestation.is_some(),
2559 "a correctly attested agent must carry its attestation"
2560 );
2561 }
2562
2563 #[test]
2572 fn worker_bindings_carry_request_digest_and_model() {
2573 let mut blueprint = runner_blueprint(false);
2574 blueprint.agents[0].profile = Some(AgentProfile {
2575 model: Some("claude-sonnet".to_string()),
2576 ..Default::default()
2577 });
2578 let bound = resolve_bound_agents(&blueprint).expect("resolvable Runner refs");
2579 let bindings = worker_bindings_from_bound_agents(&bound);
2580
2581 let wb = bindings.get("worker").expect("worker binding present");
2582 assert_eq!(
2583 wb.request_digest.as_ref(),
2584 Some(&bound[0].binding_digest),
2585 "the spawn frame must carry the immutable snapshot digest"
2586 );
2587 assert!(wb
2588 .request_digest
2589 .as_ref()
2590 .unwrap()
2591 .as_str()
2592 .starts_with("sha256:"));
2593 assert_eq!(wb.requested_model.as_deref(), Some("claude-sonnet"));
2594 }
2595
2596 #[test]
2599 fn worker_bindings_omit_model_when_profile_has_none() {
2600 let bound = resolve_bound_agents(&runner_blueprint(false)).expect("resolvable Runner refs");
2601 let bindings = worker_bindings_from_bound_agents(&bound);
2602 let wb = bindings.get("worker").expect("worker binding present");
2603 assert!(wb.request_digest.is_some());
2604 assert!(wb.requested_model.is_none());
2605 }
2606
2607 #[tokio::test]
2608 async fn launch_without_run_ctx_appends_no_step_entries() {
2609 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2613 Ok(WorkerResult {
2614 value: json!(inv.prompt),
2615 ok: true,
2616 stats: None,
2617 })
2618 });
2619 let svc = build_service(factory);
2620 let blueprint = bp(
2621 step("echo", path("$.input"), path("$.out")),
2622 vec![agent("echo", "echo")],
2623 );
2624 let input = launch_input(blueprint, json!({ "input": "hi" }));
2625 assert!(
2626 input.run_ctx.is_none(),
2627 "automate() defaults run_ctx to None"
2628 );
2629 let out = svc.launch(input).await.expect("launch ok");
2630 assert_eq!(out.final_ctx["out"], "hi");
2631 }
2632
2633 #[tokio::test]
2639 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
2640 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2647 Ok(WorkerResult {
2648 value: json!({ "echoed": inv.prompt }),
2649 ok: true,
2650 stats: None,
2651 })
2652 });
2653 let svc = build_service(factory);
2654 let blueprint = bp(
2655 step("echo", path("$.input"), path("$.out")),
2656 vec![agent("echo", "echo")],
2657 );
2658 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2659 input.task_input = Some(TaskInputSpec {
2660 project_root: Some("/repo".to_string()),
2661 work_dir: Some("/repo/work".to_string()),
2662 task_metadata: Some(json!({ "issue": 19 })),
2663 });
2664 let out = svc.launch(input).await.expect("launch ok");
2665 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
2666 assert!(
2667 out.final_ctx.get("project_root").is_none(),
2668 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
2669 out.final_ctx
2670 );
2671 assert!(out.final_ctx.get("work_dir").is_none());
2672 assert!(out.final_ctx.get("task_metadata").is_none());
2673 }
2674
2675 #[tokio::test]
2676 async fn launch_with_task_input_none_is_a_no_op() {
2677 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2678 Ok(WorkerResult {
2679 value: json!(inv.prompt),
2680 ok: true,
2681 stats: None,
2682 })
2683 });
2684 let svc = build_service(factory);
2685 let blueprint = bp(
2686 step("echo", path("$.input"), path("$.out")),
2687 vec![agent("echo", "echo")],
2688 );
2689 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2690 assert!(input.task_input.is_none(), "automate() defaults to None");
2691 input.task_input = None;
2692 let out = svc.launch(input).await.expect("launch ok");
2693 assert_eq!(out.final_ctx["out"], "hi");
2694 }
2695
2696 #[tokio::test]
2697 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
2698 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2702 Ok(WorkerResult {
2703 value: json!(inv.prompt),
2704 ok: true,
2705 stats: None,
2706 })
2707 });
2708 let svc = build_service(factory);
2709 let blueprint = bp(
2710 step("echo", path("$.input"), path("$.out")),
2711 vec![agent("echo", "echo")],
2712 );
2713 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2714 input.task_input = Some(TaskInputSpec::default());
2715 let out = svc.launch(input).await.expect("launch ok");
2716 assert_eq!(out.final_ctx["out"], "hi");
2717 }
2718
2719 #[test]
2724 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
2725 let bp_default = json!({ "seeded": "from-bp" });
2726 let task = json!({});
2727 let merged = merge_init_ctx(Some(&bp_default), &task);
2728 assert_eq!(merged, json!({ "seeded": "from-bp" }));
2729 }
2730
2731 #[test]
2732 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
2733 let bp_default = json!({});
2734 let task = json!({ "seeded": "from-task" });
2735 let merged = merge_init_ctx(Some(&bp_default), &task);
2736 assert_eq!(merged, json!({ "seeded": "from-task" }));
2737 }
2738
2739 #[test]
2740 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
2741 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2742 let task = json!({ "a": "task", "c": "task-only" });
2743 let merged = merge_init_ctx(Some(&bp_default), &task);
2744 assert_eq!(
2745 merged,
2746 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2747 );
2748 }
2749
2750 #[test]
2751 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
2752 let bp_default = json!({ "seeded": "from-bp" });
2753 let task = json!("plain-string-seed");
2754 let merged = merge_init_ctx(Some(&bp_default), &task);
2755 assert_eq!(merged, json!("plain-string-seed"));
2756 }
2757
2758 #[test]
2759 fn merge_init_ctx_no_bp_default_is_a_no_op() {
2760 let task = json!({ "input": "hi" });
2761 let merged = merge_init_ctx(None, &task);
2762 assert_eq!(merged, task);
2763 }
2764
2765 #[test]
2770 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
2771 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2775 let task = json!({ "a": "task", "c": "task-only" });
2776 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
2777 let two_layer = merge_init_ctx(Some(&bp_default), &task);
2778 assert_eq!(three_layer, two_layer);
2779 assert_eq!(
2780 three_layer,
2781 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2782 );
2783 }
2784
2785 #[test]
2786 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
2787 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2788 let task = json!({ "a": "task", "c": "task-only" });
2789 let run_override = json!({ "a": "run", "d": "run-only" });
2790 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2791 assert_eq!(
2792 merged,
2793 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
2794 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
2795 );
2796 }
2797
2798 #[test]
2799 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
2800 let bp_default = json!({ "seeded": "from-bp" });
2801 let task = json!({ "seeded": "from-task" });
2802 let run_override = json!("plain-string-run-seed");
2803 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2804 assert_eq!(merged, json!("plain-string-run-seed"));
2805 }
2806
2807 #[test]
2808 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
2809 let task = json!({ "input": "hi" });
2810 let merged = merge_init_ctx_3layer(None, &task, None);
2811 assert_eq!(merged, task);
2812 }
2813
2814 #[tokio::test]
2815 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
2816 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2819 Ok(WorkerResult {
2820 value: json!(inv.prompt),
2821 ok: true,
2822 stats: None,
2823 })
2824 });
2825 let svc = build_service(factory);
2826 let mut blueprint = bp(
2827 step("echo", path("$.greeting"), path("$.out")),
2828 vec![agent("echo", "echo")],
2829 );
2830 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
2831 let out = svc
2833 .launch(launch_input(blueprint, json!({})))
2834 .await
2835 .expect("launch ok");
2836 assert_eq!(out.final_ctx["out"], "hello from bp");
2837 }
2838
2839 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
2844 AgentDef {
2845 name: name.to_string(),
2846 kind: AgentKind::RustFn,
2847 spec: json!({ "fn_id": fn_id }),
2848 profile: None,
2849 meta: Some(meta),
2850 runner: None,
2851 runner_ref: None,
2852 verdict: None,
2853 lints: None,
2854 }
2855 }
2856
2857 #[test]
2858 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
2859 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2860 let (global, per_agent) = derive_agent_ctx(&blueprint);
2861 assert_eq!(global, None);
2862 assert!(per_agent.is_empty());
2863 }
2864
2865 #[test]
2866 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
2867 let mut blueprint = bp(
2868 step("echo", path("$.in"), path("$.out")),
2869 vec![
2870 agent_with_meta(
2871 "with-ctx",
2872 "echo",
2873 AgentMeta {
2874 ctx: Some(json!({ "org_conventions": "x" })),
2875 ..Default::default()
2876 },
2877 ),
2878 agent("no-ctx", "echo"),
2879 ],
2880 );
2881 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
2882 let (global, per_agent) = derive_agent_ctx(&blueprint);
2883 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
2884 assert_eq!(
2885 per_agent.len(),
2886 1,
2887 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
2888 );
2889 assert_eq!(
2890 per_agent.get("with-ctx"),
2891 Some(&json!({ "org_conventions": "x" }))
2892 );
2893 assert!(!per_agent.contains_key("no-ctx"));
2894 }
2895
2896 #[test]
2897 fn derive_context_policies_empty_blueprint_yields_empty_state() {
2898 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2899 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2900 assert_eq!(default_policy, None);
2901 assert!(per_agent.is_empty());
2902 }
2903
2904 #[test]
2905 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
2906 let mut blueprint = bp(
2907 step("echo", path("$.in"), path("$.out")),
2908 vec![
2909 agent_with_meta(
2910 "with-policy",
2911 "echo",
2912 AgentMeta {
2913 context_policy: Some(ContextPolicy {
2914 include: None,
2915 exclude: vec!["work_dir".to_string()],
2916 ..Default::default()
2917 }),
2918 ..Default::default()
2919 },
2920 ),
2921 agent("no-policy", "echo"),
2922 ],
2923 );
2924 blueprint.default_context_policy = Some(ContextPolicy {
2925 include: Some(vec!["project_root".to_string()]),
2926 exclude: vec![],
2927 ..Default::default()
2928 });
2929 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2930 assert_eq!(
2931 default_policy,
2932 Some(ContextPolicy {
2933 include: Some(vec!["project_root".to_string()]),
2934 exclude: vec![],
2935 ..Default::default()
2936 })
2937 );
2938 assert_eq!(per_agent.len(), 1);
2939 assert_eq!(
2940 per_agent.get("with-policy"),
2941 Some(&ContextPolicy {
2942 include: None,
2943 exclude: vec!["work_dir".to_string()],
2944 ..Default::default()
2945 })
2946 );
2947 assert!(!per_agent.contains_key("no-policy"));
2948 }
2949
2950 #[test]
2956 fn derive_step_metas_empty_blueprint_yields_empty_map() {
2957 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2958 assert!(derive_step_metas(&blueprint).is_empty());
2959 }
2960
2961 #[test]
2962 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
2963 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2964 blueprint.metas = vec![
2965 MetaDef {
2966 name: "heavy-scan".to_string(),
2967 ctx: json!({ "work_dir": "/x" }),
2968 },
2969 MetaDef {
2970 name: "light-scan".to_string(),
2971 ctx: json!({ "work_dir": "/y" }),
2972 },
2973 ];
2974 let metas = derive_step_metas(&blueprint);
2975 assert_eq!(metas.len(), 2);
2976 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
2977 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
2978 }
2979
2980 #[test]
2981 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
2982 let mut blueprint = bp(
2983 step("echo", path("$.in"), path("$.out")),
2984 vec![agent_with_meta(
2985 "with-meta-ref",
2986 "echo",
2987 AgentMeta {
2988 ctx: Some(json!({ "work_dir": "/inline-wins" })),
2989 meta_ref: Some("shared".to_string()),
2990 ..Default::default()
2991 },
2992 )],
2993 );
2994 blueprint.metas = vec![MetaDef {
2995 name: "shared".to_string(),
2996 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
2997 }];
2998 let (_, per_agent) = derive_agent_ctx(&blueprint);
2999 assert_eq!(
3000 per_agent.get("with-meta-ref"),
3001 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
3002 "inline ctx must win the collided key while pool-only keys survive the merge"
3003 );
3004 }
3005
3006 #[test]
3007 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
3008 let mut blueprint = bp(
3009 step("echo", path("$.in"), path("$.out")),
3010 vec![agent_with_meta(
3011 "with-meta-ref-only",
3012 "echo",
3013 AgentMeta {
3014 meta_ref: Some("shared".to_string()),
3015 ..Default::default()
3016 },
3017 )],
3018 );
3019 blueprint.metas = vec![MetaDef {
3020 name: "shared".to_string(),
3021 ctx: json!({ "work_dir": "/base" }),
3022 }];
3023 let (_, per_agent) = derive_agent_ctx(&blueprint);
3024 assert_eq!(
3025 per_agent.get("with-meta-ref-only"),
3026 Some(&json!({ "work_dir": "/base" }))
3027 );
3028 }
3029
3030 #[test]
3031 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
3032 let blueprint = bp(
3033 step("echo", path("$.in"), path("$.out")),
3034 vec![agent_with_meta(
3035 "with-unresolved-meta-ref",
3036 "echo",
3037 AgentMeta {
3038 ctx: Some(json!({ "work_dir": "/inline-only" })),
3039 meta_ref: Some("missing".to_string()),
3040 ..Default::default()
3041 },
3042 )],
3043 );
3044 let (_, per_agent) = derive_agent_ctx(&blueprint);
3046 assert_eq!(
3047 per_agent.get("with-unresolved-meta-ref"),
3048 Some(&json!({ "work_dir": "/inline-only" })),
3049 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
3050 );
3051 }
3052
3053 #[test]
3069 fn resolve_runner_legacy_fallback_matches_derive_worker_bindings_semantics() {
3070 fn legacy_agent(name: &str, variant: &str, tools: Vec<&str>) -> AgentDef {
3071 AgentDef {
3072 name: name.to_string(),
3073 kind: AgentKind::Operator,
3074 spec: json!({}),
3075 profile: Some(AgentProfile {
3076 worker_binding: Some(variant.to_string()),
3077 tools: tools.into_iter().map(str::to_string).collect(),
3078 ..Default::default()
3079 }),
3080 meta: None,
3081 runner: None,
3082 runner_ref: None,
3083 verdict: None,
3084 lints: None,
3085 }
3086 }
3087
3088 let blueprint = bp(
3089 step("planner", path("$.in"), path("$.out")),
3090 vec![
3091 legacy_agent("planner", "planning-worker", vec!["Read", "Grep"]),
3092 legacy_agent("coder", "code-worker", vec![]),
3093 agent("no-binding", "echo"),
3094 ],
3095 );
3096
3097 let derived = derive_worker_bindings(&blueprint);
3098
3099 for agent_def in &blueprint.agents {
3100 let resolved = resolve_runner(&blueprint, agent_def).expect("no unresolved refs");
3101 match derived.get(&agent_def.name) {
3102 Some(binding) => {
3103 assert_eq!(
3104 resolved,
3105 Some(Runner::WsClaudeCode {
3106 variant: binding.variant.clone(),
3107 tools: binding.tools.clone(),
3108 }),
3109 "resolve_runner must synthesize the same WsClaudeCode Runner \
3110 derive_worker_bindings produces for agent '{}'",
3111 agent_def.name
3112 );
3113 }
3114 None => {
3115 assert_eq!(
3116 resolved, None,
3117 "agent '{}' has no derive_worker_bindings entry, so resolve_runner \
3118 must resolve to None too (no other tier declared)",
3119 agent_def.name
3120 );
3121 }
3122 }
3123 }
3124 }
3125
3126 #[test]
3127 fn ws_operator_runner_projects_into_the_existing_spawn_binding() {
3128 let mut blueprint = bp(
3129 step("reviewer", path("$.in"), path("$.out")),
3130 vec![agent("reviewer", "echo")],
3131 );
3132 blueprint.agents[0].runner = Some(Runner::WsOperator {
3133 variant: "mse-reviewer".to_string(),
3134 tools: vec!["Read".to_string(), "Grep".to_string()],
3135 });
3136
3137 let derived = derive_worker_bindings(&blueprint);
3138 let binding = derived
3139 .get("reviewer")
3140 .expect("ws_operator must feed the canonical spawn binding path");
3141 assert_eq!(binding.variant, "mse-reviewer");
3142 assert_eq!(binding.tools, ["Read", "Grep"]);
3143 }
3144
3145 fn counting_echo_service() -> (TaskLaunchService, Arc<std::sync::atomic::AtomicUsize>) {
3154 use std::sync::atomic::{AtomicUsize, Ordering};
3155 let calls = Arc::new(AtomicUsize::new(0));
3156 let counter = calls.clone();
3157 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
3158 let counter = counter.clone();
3159 async move {
3160 counter.fetch_add(1, Ordering::SeqCst);
3161 Ok(WorkerResult {
3162 value: json!({ "echoed": inv.prompt }),
3163 ok: true,
3164 stats: None,
3165 })
3166 }
3167 });
3168 (build_service(factory), calls)
3169 }
3170
3171 async fn seed_legacy_run(run_store: &Arc<dyn crate::store::run::RunStore>) -> crate::RunId {
3172 use crate::store::run::{RunRecord, RunStatus};
3173 use crate::types::TaskId;
3174 let run_id = crate::RunId::new();
3175 run_store
3176 .create(RunRecord {
3177 id: run_id.clone(),
3178 task_id: TaskId::new(),
3179 status: RunStatus::Running,
3180 step_entries: Vec::new(),
3181 degradations: Vec::new(),
3182 operator_sid: None,
3183 result_ref: None,
3184 input_json: Some("{}".to_string()),
3187 created_at: 0,
3188 updated_at: 0,
3189 })
3190 .await
3191 .expect("seed legacy RunRecord");
3192 run_id
3193 }
3194
3195 #[tokio::test]
3201 async fn backfilled_run_replays_legacy_keys_stably_across_two_resumes() {
3202 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3203 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3204 use std::sync::atomic::Ordering;
3205 use std::sync::Mutex;
3206
3207 let (svc, echo_calls) = counting_echo_service();
3208 let blueprint = bp(
3209 step("echo", path("$.input"), path("$.out")),
3210 vec![agent("echo", "echo")],
3211 );
3212 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3213 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3214 let run_id = seed_legacy_run(&run_store).await;
3215
3216 let rc1 = RunContext::new(run_id.clone(), run_store.clone())
3220 .with_replay_store(replay_store.clone())
3221 .with_resume();
3222 let mut input1 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3223 input1.run_ctx = Some(rc1);
3224 let out1 = svc.launch(input1).await.expect("phase-1 resume launch ok");
3225 assert_eq!(out1.final_ctx["out"]["echoed"], "hi");
3226 assert_eq!(
3227 echo_calls.load(Ordering::SeqCst),
3228 1,
3229 "phase 1 dispatches the worker once (nothing to replay yet)"
3230 );
3231 let entries = replay_store
3232 .list_by_run(&run_id)
3233 .await
3234 .expect("list replay rows");
3235 assert_eq!(
3236 entries.len(),
3237 1,
3238 "phase 1 must log exactly one legacy-hashed replay row"
3239 );
3240 let run = run_store.get(&run_id).await.expect("run present");
3241 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3242 assert_eq!(
3243 SnapshotOrigin::from_snapshot(&snap),
3244 SnapshotOrigin::ResumeBackfill,
3245 "phase 1 must pin the snapshot as resume_backfill"
3246 );
3247
3248 let cursor = ReplayCursor::from_entries(entries);
3253 let rc2 = RunContext::new(run_id.clone(), run_store.clone())
3254 .with_replay_store(replay_store.clone())
3255 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
3256 .with_resume();
3257 let mut input2 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3258 input2.run_ctx = Some(rc2);
3259 let out2 = svc.launch(input2).await.expect("phase-2 resume launch ok");
3260 assert_eq!(out2.final_ctx["out"]["echoed"], "hi");
3261 assert_eq!(
3262 echo_calls.load(Ordering::SeqCst),
3263 1,
3264 "phase 2 must REPLAY the legacy-hashed row — the worker must not run again"
3265 );
3266 let run2 = run_store.get(&run_id).await.expect("run present");
3267 let snap2: Value = serde_json::from_str(run2.input_json.as_deref().unwrap()).unwrap();
3268 assert_eq!(
3269 SnapshotOrigin::from_snapshot(&snap2),
3270 SnapshotOrigin::ResumeBackfill,
3271 "origin must stay resume_backfill across resumes (replay key stability)"
3272 );
3273 }
3274
3275 #[tokio::test]
3280 async fn launch_origin_run_uses_digest_keys_and_misses_legacy_replay_row() {
3281 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3282 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3283 use std::sync::atomic::Ordering;
3284 use std::sync::Mutex;
3285
3286 let (svc, echo_calls) = counting_echo_service();
3287 let blueprint = bp(
3288 step("echo", path("$.input"), path("$.out")),
3289 vec![agent("echo", "echo")],
3290 );
3291 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3292 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3293
3294 let backfill_run = seed_legacy_run(&run_store).await;
3296 let rc_bf = RunContext::new(backfill_run.clone(), run_store.clone())
3297 .with_replay_store(replay_store.clone())
3298 .with_resume();
3299 let mut input_bf = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3300 input_bf.run_ctx = Some(rc_bf);
3301 svc.launch(input_bf).await.expect("backfill launch ok");
3302 assert_eq!(echo_calls.load(Ordering::SeqCst), 1);
3303 let legacy_entries = replay_store
3304 .list_by_run(&backfill_run)
3305 .await
3306 .expect("list legacy rows");
3307 assert_eq!(legacy_entries.len(), 1);
3308
3309 let launch_run = seed_legacy_run(&run_store).await;
3313 let cursor = ReplayCursor::from_entries(legacy_entries);
3314 let rc_launch = RunContext::new(launch_run.clone(), run_store.clone())
3315 .with_replay_store(replay_store.clone())
3316 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
3317 let mut input_launch = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3318 input_launch.run_ctx = Some(rc_launch);
3319 svc.launch(input_launch)
3320 .await
3321 .expect("launch-origin launch ok");
3322 assert_eq!(
3323 echo_calls.load(Ordering::SeqCst),
3324 2,
3325 "a launch-origin Run keys replay by binding digest, so the \
3326 legacy-hashed row must MISS and the worker must run"
3327 );
3328 let run = run_store.get(&launch_run).await.expect("run present");
3329 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3330 assert_eq!(SnapshotOrigin::from_snapshot(&snap), SnapshotOrigin::Launch);
3331 }
3332
3333 #[test]
3341 fn task_launch_error_flow_eval_struct_variant_display_preserves_prefix() {
3342 let err = TaskLaunchError::FlowEval {
3343 message: "dispatcher error at ref foo".to_string(),
3344 failed_step: Some("foo".to_string()),
3345 verdict_value: Some(json!({"verdict": "BLOCKED"})),
3346 partial_ctx: Some(json!({"steps": {}})),
3347 };
3348 assert_eq!(err.to_string(), "flow eval: dispatcher error at ref foo");
3349
3350 let err_bare = TaskLaunchError::FlowEval {
3354 message: "unresolved extern".to_string(),
3355 failed_step: None,
3356 verdict_value: None,
3357 partial_ctx: None,
3358 };
3359 assert_eq!(err_bare.to_string(), "flow eval: unresolved extern");
3360 }
3361
3362 #[tokio::test]
3367 async fn task_launch_flow_eval_error_carries_failed_step_and_verdict_value() {
3368 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3369 use crate::types::{RunId, TaskId};
3370
3371 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3372 Ok(WorkerResult {
3373 value: json!({ "verdict": "BLOCKED", "reason": "not applicable" }),
3374 ok: false,
3375 stats: None,
3376 })
3377 });
3378 let svc = build_service(factory);
3379 let blueprint = bp(
3380 step("gate", path("$.input"), path("$.out")),
3381 vec![agent("gate", "gate")],
3382 );
3383
3384 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3385 let run_id = RunId::new();
3386 run_store
3387 .create(RunRecord {
3388 id: run_id.clone(),
3389 task_id: TaskId::new(),
3390 status: RunStatus::Running,
3391 step_entries: Vec::new(),
3392 degradations: Vec::new(),
3393 operator_sid: None,
3394 result_ref: None,
3395 input_json: Some("{}".to_string()),
3396 created_at: 0,
3397 updated_at: 0,
3398 })
3399 .await
3400 .expect("seed RunRecord");
3401
3402 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
3403 input.run_ctx = Some(RunContext::new(run_id, run_store));
3404
3405 let err = svc.launch(input).await.expect_err("expected FlowEval");
3406 match err {
3407 TaskLaunchError::FlowEval {
3408 message,
3409 failed_step,
3410 verdict_value,
3411 partial_ctx,
3412 } => {
3413 assert!(
3414 message.contains("blocked"),
3415 "expected message to mention blocked, got: {message}"
3416 );
3417 assert_eq!(
3418 failed_step,
3419 Some("gate".to_string()),
3420 "failed_step should be the Blueprint step ref, not the opaque StepId"
3421 );
3422 let vv = verdict_value.expect("verdict_value must be Some for Blocked");
3423 assert_eq!(vv["verdict"], "BLOCKED");
3424 assert_eq!(vv["reason"], "not applicable");
3425 assert!(
3429 partial_ctx.is_some(),
3430 "partial_ctx must be Some when a RunContext was supplied"
3431 );
3432 }
3433 other => panic!("expected FlowEval, got {other:?}"),
3434 }
3435 }
3436
3437 #[tokio::test]
3443 async fn task_launch_flow_eval_error_partial_ctx_reconstructs_from_run_store() {
3444 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3445 use crate::types::{RunId, TaskId};
3446
3447 let factory = RustFnInProcessSpawnerFactory::new()
3448 .register_fn("upper", |inv| async move {
3449 Ok(WorkerResult {
3450 value: json!(inv.prompt.to_uppercase()),
3451 ok: true,
3452 stats: None,
3453 })
3454 })
3455 .register_fn("gate", |_inv| async move {
3456 Ok(WorkerResult {
3457 value: json!({ "verdict": "BLOCKED" }),
3458 ok: false,
3459 stats: None,
3460 })
3461 })
3462 .register_fn("never", |inv| async move {
3463 Ok(WorkerResult {
3464 value: json!(inv.prompt),
3465 ok: true,
3466 stats: None,
3467 })
3468 });
3469 let svc = build_service(factory);
3470 let flow = FlowNode::Seq {
3471 children: vec![
3472 step("upper", path("$.in"), path("$.s1")),
3473 step("gate", path("$.s1"), path("$.s2")),
3474 step("never", path("$.s2"), path("$.s3")),
3475 ],
3476 };
3477 let blueprint = bp(
3478 flow,
3479 vec![
3480 agent("upper", "upper"),
3481 agent("gate", "gate"),
3482 agent("never", "never"),
3483 ],
3484 );
3485
3486 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3487 let run_id = RunId::new();
3488 run_store
3489 .create(RunRecord {
3490 id: run_id.clone(),
3491 task_id: TaskId::new(),
3492 status: RunStatus::Running,
3493 step_entries: Vec::new(),
3494 degradations: Vec::new(),
3495 operator_sid: None,
3496 result_ref: None,
3497 input_json: Some("{}".to_string()),
3498 created_at: 0,
3499 updated_at: 0,
3500 })
3501 .await
3502 .expect("seed RunRecord");
3503
3504 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
3505 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
3506
3507 let err = svc.launch(input).await.expect_err("expected FlowEval");
3508 let partial_ctx = match err {
3509 TaskLaunchError::FlowEval { partial_ctx, .. } => {
3510 partial_ctx.expect("partial_ctx must be Some")
3511 }
3512 other => panic!("expected FlowEval, got {other:?}"),
3513 };
3514 let steps = partial_ctx
3515 .get("steps")
3516 .and_then(|v| v.as_object())
3517 .expect("partial_ctx.steps object");
3518 assert_eq!(
3522 steps.len(),
3523 2,
3524 "expected 2 step_entries (upper passed + gate blocked), got: {steps:?}"
3525 );
3526 let mut status_by_ref: HashMap<String, String> = HashMap::new();
3527 for (_step_id, entry) in steps {
3528 let step_ref = entry
3529 .get("step_ref")
3530 .and_then(|v| v.as_str())
3531 .expect("step_ref present")
3532 .to_string();
3533 let status = entry
3534 .get("status")
3535 .and_then(|v| v.as_str())
3536 .expect("status present")
3537 .to_string();
3538 status_by_ref.insert(step_ref, status);
3539 }
3540 assert_eq!(
3541 status_by_ref.get("upper").map(String::as_str),
3542 Some("passed")
3543 );
3544 assert_eq!(
3545 status_by_ref.get("gate").map(String::as_str),
3546 Some("blocked")
3547 );
3548 assert!(
3549 !status_by_ref.contains_key("never"),
3550 "step 'never' must not appear — flow-ir stops dispatching after Blocked abort"
3551 );
3552 }
3553
3554 #[tokio::test]
3560 async fn task_launch_flow_eval_error_without_run_ctx_has_none_fields() {
3561 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3562 Ok(WorkerResult {
3563 value: json!({ "verdict": "BLOCKED" }),
3564 ok: false,
3565 stats: None,
3566 })
3567 });
3568 let svc = build_service(factory);
3569 let blueprint = bp(
3570 step("gate", path("$.input"), path("$.out")),
3571 vec![agent("gate", "gate")],
3572 );
3573 let err = svc
3574 .launch(launch_input(blueprint, json!({ "input": "hi" })))
3575 .await
3576 .expect_err("expected FlowEval");
3577 match err {
3578 TaskLaunchError::FlowEval {
3579 failed_step,
3580 verdict_value,
3581 partial_ctx,
3582 ..
3583 } => {
3584 assert_eq!(
3585 failed_step, None,
3586 "failed_step must be None without run_ctx"
3587 );
3588 assert_eq!(
3589 verdict_value, None,
3590 "verdict_value must be None without run_ctx"
3591 );
3592 assert_eq!(
3593 partial_ctx, None,
3594 "partial_ctx must be None without run_ctx"
3595 );
3596 }
3597 other => panic!("expected FlowEval, got {other:?}"),
3598 }
3599 }
3600}